Skip to content

fix(docx): write a table on the grid its cells occupy - #524

Merged
DemchaAV merged 3 commits into
developfrom
fix/docx-table-structure
Aug 8, 2026
Merged

fix(docx): write a table on the grid its cells occupy#524
DemchaAV merged 3 commits into
developfrom
fix/docx-table-structure

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Why

An authored table row is not a row of columns. A rowSpan covers positions in the rows below
it and those rows do not repeat the covered cells; a colSpan makes the number of authored
records differ from the number of columns. The DOCX backend read a row's records as its
columns:

int columnCount = node.rows().get(0).size();          // cell RECORDS, not columnsfor (int columnIdx = 0; columnIdx < rowCells.size()
        && columnIdx < row.getTableCells().size(); columnIdx++)

So a rowSpan shifted every row beneath it one column to the left, and a colSpan did that
and left the grid too narrow — the loop's second bound then stopped at the last column that
existed and the cells past it were never written. Neither showed up as an error.

The example catalogue was carrying a second shape of the same thing. WordExportExample
builds its side-by-side row from addSection(...), and a section inside a cell had no branch
of its own, so it reached the "unsupported content" placeholder: two cells of visible text
were exporting as <w:tc><w:p/></w:tc>. That is in assets/readme/examples/word-export-companion.docx
as committed, and the re-rendered file in this PR is the diff.

What changed

  • TableGrid, new in com.demcha.compose.document.layout and @Internal. The occupancy
    walk and the column-count rule moved out of the private TableLayoutSupport.buildLogicalRows
    / resolveColumnCount, which now delegate; the old copy is deleted. A backend has to reach
    the same grid as the compiler, and two implementations of that walk would drift with nothing
    to notice. Marked @Internal deliberately — a seam for backends, not a public promise.
  • colSpanw:gridSpan, rowSpanw:vMerge (RESTART on the owner, CONTINUE on
    the positions it covers), one physical cell per merged region, and the grid sized from the
    first row's colSpan sum rather than its record count.
  • A cell's text takes the most specific style in the table / column / row / cell cascade —
    the same order the layout pipeline merges in.
  • A composed cell writes its node. DocumentTableCell.node(...) leaves lines() empty by
    definition, which is what the backend used to read, so every composed cell exported blank.
    The same walk recurses into ContainerNode and SectionNode, which is what recovered the
    two cells above.
  • A multi-line cell is separated by a real break instead of a \n joined into one w:t,
    which Word renders as a single line.
  • A table whose rows cannot form a rectangle now fails the export, naming the position at
    fault. That rule is not new — it is layout's, moved with the walk — but DOCX did not apply it
    before, so a document the PDF backend refuses is no longer one DOCX quietly writes wrong.

Two shapes are guarded because they would otherwise have regressed: a table that claims no
column at all has no position to place anything in, so the grid is not widened to one nothing
covers; and a cell whose content writes nothing keeps a paragraph, since a w:tc must hold a
block-level element and the cell's own is removed before writing.

Out of scope, and the rest of #447: DocumentTableStyle fill and border paint, image
fitMode / scale, and hyperlink relationships.

Verification

./mvnw -B -ntp clean verify -pl :graph-compose-core,…,:graph-compose-coverage -am
BUILD SUCCESS, 1570 tests (476 / 144 / 31 / 5 / 90 / 104 / 720), 0 failures, 0 errors.
render-docx goes 24 → 31. The examples module is green on its own 73, which is where the
committed-asset guard runs.

New DocxTableStructureTest, 8 tests: a colSpan widening its cell instead of narrowing
the table (and the row below keeping all three cells); a rowSpan merging with the row below
keeping its own; a composed cell exporting its content; the style cascade; a multi-line cell
breaking rather than joining; a table claiming no column still exporting; a cell whose
content writes nothing keeping a paragraph; and a cell spanning both ways at once, where the
covered row's continuation needs the w:gridSpan as well as the marker — dropping it leaves
that row two grid columns short, which the assertion catches.

Reverting only DocxSemanticBackend.java to develop turns the first five red — three
assertions and two NullPointerExceptions on merge markup that is not there. The qa table
suites stay green across the extraction, which is what says the move changed no layout
behaviour.

Documentation

docs/architecture/backend-capability-matrix.md and render-docx/README.md — the table row
moves from "cell text becomes a real Word table, spans and per-cell style not applied" to the
structure being right, with the remaining gaps named: fill and border paint, and a composed
cell built from an image or a list, which still lands empty.

Lane: shared-engine — a render backend plus an @Internal extraction in core. No change
to a supported public API contract: TableGrid is a public class because a backend in
another module has to reach it, and @Internal is what says it carries no compatibility
promise.

Part of #447.

An authored row is not a row of columns. A rowSpan covers positions in the rows
below it and those rows do not repeat the covered cells; a colSpan makes the
number of authored records differ from the number of columns. The backend read a
row's records as its columns, so a rowSpan shifted every row beneath it one
column left, and a colSpan did that and left the grid too narrow as well —
dropping the cells past its end with nothing said.

colSpan and rowSpan now reach Word as w:gridSpan and w:vMerge. A cell's text
takes the most specific style in the table / column / row / cell cascade. A
composed cell writes its node instead of the lines() it does not have, and a
multi-line cell is separated by a real break rather than a newline Word reads as
a space.

The grid is resolved by TableGrid, moved out of the layout pipeline so the
backend and the compiler answer from one implementation rather than two that can
drift. It is @internal: a seam for backends, not a public promise. A table whose
rows cannot form a rectangle now fails the export naming the position at fault,
which is the rule layout already applied.

Two shapes that would otherwise have regressed: a table claiming no column at
all has no position to place anything in, so the grid is not widened to one that
nothing covers; and a cell whose content writes nothing keeps a paragraph, since
a w:tc must hold a block-level element and the cell's own was removed first.
The example builds a side-by-side row from addSection(...), and a section in a
cell had no branch of its own — it reached the "unsupported content" placeholder
and wrote an empty paragraph. Two cells of visible text were leaving the document
that way. The committed preview shows the state before; the catalogue renders
them now, and the drift guard fails on any preview that no longer matches its
example.
A cell owning a rectangle rather than a strip is where the cover matrix, the
gridSpan and the vMerge marker all meet, and the two existing cases each
exercised only one axis. The continuation cell needs the width as well as the
merge: without gridSpan the covered row is two grid columns short of its
neighbours, which the assertion now catches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant