fix(docx): write a table on the grid its cells occupy - #524
Merged
Conversation
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.
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
An authored table row is not a row of columns. A
rowSpancovers positions in the rows belowit and those rows do not repeat the covered cells; a
colSpanmakes the number of authoredrecords differ from the number of columns. The DOCX backend read a row's records as its
columns:
So a
rowSpanshifted every row beneath it one column to the left, and acolSpandid thatand 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.
WordExportExamplebuilds its side-by-side row from
addSection(...), and a section inside a cell had no branchof 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 inassets/readme/examples/word-export-companion.docxas committed, and the re-rendered file in this PR is the diff.
What changed
TableGrid, new incom.demcha.compose.document.layoutand@Internal. The occupancywalk 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 reachthe same grid as the compiler, and two implementations of that walk would drift with nothing
to notice. Marked
@Internaldeliberately — a seam for backends, not a public promise.colSpan→w:gridSpan,rowSpan→w:vMerge(RESTARTon the owner,CONTINUEonthe 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.
the same order the layout pipeline merges in.
DocumentTableCell.node(...)leaveslines()empty bydefinition, which is what the backend used to read, so every composed cell exported blank.
The same walk recurses into
ContainerNodeandSectionNode, which is what recovered thetwo cells above.
\njoined into onew:t,which Word renders as a single line.
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:tcmust hold ablock-level element and the cell's own is removed before writing.
Out of scope, and the rest of #447:
DocumentTableStylefill and border paint, imagefitMode/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-docxgoes 24 → 31. Theexamplesmodule is green on its own 73, which is where thecommitted-asset guard runs.
New
DocxTableStructureTest, 8 tests: acolSpanwidening its cell instead of narrowingthe table (and the row below keeping all three cells); a
rowSpanmerging with the row belowkeeping 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:gridSpanas well as the marker — dropping it leavesthat row two grid columns short, which the assertion catches.
Reverting only
DocxSemanticBackend.javatodevelopturns the first five red — threeassertions and two
NullPointerExceptions on merge markup that is not there. The qa tablesuites stay green across the extraction, which is what says the move changed no layout
behaviour.
Documentation
docs/architecture/backend-capability-matrix.mdandrender-docx/README.md— the table rowmoves 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
@Internalextraction in core. No changeto a supported public API contract:
TableGridis apublicclass because a backend inanother module has to reach it, and
@Internalis what says it carries no compatibilitypromise.
Part of #447.