// F2 of the content writers: tables. Serializes `Table`/`TableRow`/
// `TableCell` — including column spans (`w:gridSpan`) and row spans
// (`w:vMerge`, with the continuation cells the reader strips synthesized
// back into the following rows) — into schema-valid `w:tbl` markup. Same
// contract as F1: fail closed on anything unsupported.
///|
/// Word's hard table-column limit. Beyond it the document is unusable in
/// Word anyway, and an unbounded span would drive the placement loop and
/// the w:gridCol emission through billions of iterations.
const MAX_GRID_COLUMNS : Int = 63
///|
/// Grid width (in columns) of one row: the sum of its cells' column spans.
/// Fails closed past `MAX_GRID_COLUMNS` — checked per cell BEFORE summing
/// so a huge span cannot overflow the accumulator.
fn row_grid_width(cells : Array[DocumentElement]) -> Int raise DocxError {
let mut width = 0
for cell in cells {
let span = match cell {
TableCell(col_span~, ..) => col_span
_ => 1
}
if span > MAX_GRID_COLUMNS || width + span > MAX_GRID_COLUMNS {
raise Unsupported(
message="a table row spans more than \{MAX_GRID_COLUMNS} grid columns (Word's column limit)",
)
}
if span >= 1 {
width += span
}
}
width
}
///|
/// A pending vertical merge: `remaining` continuation rows still to emit
/// for the merge that started at this grid column, spanning `col_span`
/// grid columns.
priv struct PendingMerge {
mut remaining : Int
col_span : Int
}
///|
fn write_table(
children : Array[DocumentElement],
properties : TableProperties,
ctx : WriteContext,
) -> XmlElement raise DocxError {
if properties.style_id is Some(_) || properties.style_name is Some(_) {
raise Unsupported(
message="the table writer cannot serialize table styles yet",
)
}
let rows : Array[(Array[DocumentElement], Bool)] = []
for child in children {
match child {
TableRow(children~, is_header~) => rows.push((children, is_header))
other =>
raise Unsupported(
message="a table can only contain rows; cannot serialize: \{block_kind_name(other)}",
)
}
}
if rows.length() == 0 {
raise Unsupported(
message="the table writer cannot serialize an empty table",
)
}
// The grid width is fixed by the widest row; narrower rows are rejected
// rather than padded (silent padding would invent cells).
let mut grid_width = 0
for row in rows {
let (cells, _) = row
let width = row_grid_width(cells)
if width > grid_width {
grid_width = width
}
}
if grid_width == 0 {
raise Unsupported(
message="the table writer cannot serialize a table with no cells",
)
}
// Active vertical merges by starting grid column.
let pending : Map[Int, PendingMerge] = Map([])
let row_nodes : Array[XmlNode] = []
for row_index, row in rows {
let (cells, is_header) = row
let cell_nodes : Array[XmlNode] = []
let mut grid_column = 0
let mut cell_cursor = 0
while grid_column < grid_width {
match pending.get(grid_column) {
Some(merge) if merge.remaining > 0 => {
// Synthesize the continuation cell the reader strips.
merge.remaining -= 1
cell_nodes.push(XmlElement(vmerge_continuation_cell(merge.col_span)))
grid_column += merge.col_span
if merge.remaining == 0 {
pending.remove(grid_column - merge.col_span)
}
continue
}
_ => ()
}
if cell_cursor >= cells.length() {
break
}
let cell = cells[cell_cursor]
cell_cursor += 1
guard cell is TableCell(children~, col_span~, row_span~) else {
raise Unsupported(
message="a table row can only contain cells; cannot serialize: \{block_kind_name(cell)}",
)
}
if col_span < 1 || row_span < 1 {
raise Unsupported(
message="table cell spans must be at least 1 (got col_span=\{col_span}, row_span=\{row_span})",
)
}
// A cell's span must not stride over ANY pending-merge column — the
// placement loop only meets merges at their start column, so an
// unchecked interior collision would silently push the vMerge
// continuation into a later row (schema-valid, reader-masked, wrong
// in Word — review finding on F2).
for offset in 1.. 0 {
raise Unsupported(
message="row \{row_index + 1}: a cell spanning columns \{grid_column + 1}..\{grid_column + col_span} collides with a vertical merge at column \{grid_column + offset + 1}",
)
}
}
if row_span > 1 {
if row_index + row_span > rows.length() {
raise Unsupported(
message="a row span of \{row_span} starting at row \{row_index + 1} exceeds the table's \{rows.length()} row(s)",
)
}
pending[grid_column] = { remaining: row_span - 1, col_span }
}
cell_nodes.push(
XmlElement(write_table_cell(children, col_span, row_span > 1, ctx)),
)
grid_column += col_span
}
if cell_cursor < cells.length() {
raise Unsupported(
message="row \{row_index + 1} is wider than the table grid (\{grid_width} column(s)) once vertical merges are placed",
)
}
if grid_column != grid_width {
raise Unsupported(
message="row \{row_index + 1} covers \{grid_column} of \{grid_width} grid column(s); ragged tables are not serializable",
)
}
let row_children : Array[XmlNode] = []
if is_header {
row_children.push(
XmlElement(
@xml.xml_element("w:trPr", children=[
XmlElement(@xml.xml_element("w:tblHeader")),
]),
),
)
}
row_children.append(cell_nodes)
row_nodes.push(XmlElement(@xml.xml_element("w:tr", children=row_children)))
}
// Verify no merge outlived the table (guarded above, but keep the
// invariant explicit).
for _, merge in pending {
if merge.remaining > 0 {
raise Unsupported(
message="a vertical merge extends past the last table row",
)
}
}
let grid_columns : Array[XmlNode] = []
for _ in 0.. XmlElement {
let tc_pr : Array[XmlNode] = []
if col_span > 1 {
tc_pr.push(
XmlElement(
@xml.xml_element("w:gridSpan", attributes={
"w:val": col_span.to_string(),
}),
),
)
}
tc_pr.push(XmlElement(@xml.xml_element("w:vMerge")))
@xml.xml_element("w:tc", children=[
XmlElement(@xml.xml_element("w:tcPr", children=tc_pr)),
XmlElement(@xml.xml_element("w:p")),
])
}
///|
fn write_table_cell(
children : Array[DocumentElement],
col_span : Int,
starts_merge : Bool,
ctx : WriteContext,
) -> XmlElement raise DocxError {
let nodes : Array[XmlNode] = []
// CT_TcPr sequence: gridSpan before vMerge.
let tc_pr : Array[XmlNode] = []
if col_span > 1 {
tc_pr.push(
XmlElement(
@xml.xml_element("w:gridSpan", attributes={
"w:val": col_span.to_string(),
}),
),
)
}
if starts_merge {
tc_pr.push(
XmlElement(
@xml.xml_element("w:vMerge", attributes={ "w:val": "restart" }),
),
)
}
if tc_pr.length() > 0 {
nodes.push(XmlElement(@xml.xml_element("w:tcPr", children=tc_pr)))
}
// A cell must end with a block; the schema requires at least one
// paragraph.
if children.length() == 0 {
nodes.push(XmlElement(@xml.xml_element("w:p")))
} else {
for child in children {
nodes.push(XmlElement(write_block(child, ctx)))
}
}
@xml.xml_element("w:tc", children=nodes)
}