Skip to content

fix(docx): anchor a drawing that is all its table cell holds in that cell, so it moves with its row - #798

Merged
DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-rota-group-icons
Oct 1, 2026
Merged

DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-rota-group-icons

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Why

Every shape the DOCX export draws is anchored to the page: positionH/positionV relativeFrom="page", at the layout's coordinates. A drawing that sits alone in a table cell (an icon beside its label in a row, which the export writes as a Word table) therefore stays at its page position, while Word lays the rows above it out a fraction of a point taller or shorter than the page.

  • In CobaltRota, the three band icons stood 4pt, 9pt and 14pt above their labels in Word. The last one stood above its navy strip.
  • An icon composed in a table cell (DocumentTableCell.node) has no place of its own in the layout. Its fragments are the table's, under the table's path, so nothing tied them to their row.

What changed

  • DocxDrawings.drawingInCell / CellOrigin. A drawing anchored in a table cell's paragraph: layoutInCell="1", placed from the cell's text column (positionH relativeFrom="column") and the paragraph's top (positionV relativeFrom="paragraph"). DocxDrawingAnchors.anchorInCell puts it in a given paragraph.
  • A drawing in a row of the flow (drawingCellFor, DrawingCell).
    • It qualifies when it is a layer stack or shape container that only draws (shapes, a drawn picture, a badge's initials) and is all its cell holds. It must also have no margins, be laid out on one page, and paint nothing outside its box (paintsInsideItsBox).
    • The cell's paragraph is then held at the drawing's height as an exact line, and queueDrawings anchors the drawing's shapes there.
    • Anything else goes to the page as before.
  • A drawing composed in a table cell (anchorComposedDrawing, DocxCellDrawings).
    • drawCellDrawing keeps the table's drawings waiting (tableDrawings) instead of anchoring them at once.
    • DocxLayoutMetrics.cellBoxes gives every box where the layout placed the table's cell being written, first placement first. It is found by the cell's name among the table's own rows, once per table, so an unnamed table nested in a cell does not lend its cells' boxes to the outer one (as long as the nested table stops short of the outer table's width).
    • A cell holding only a layer stack of shapes, with no margin or padding, takes the waiting fragments inside its first box when they are exactly its shapes: each of its kind and size, in paint order, on one page.
    • When the table's cell holds any other drawing, everything in it is left to the page. Which of them a later node paints is not known, and one left to the page would otherwise be taken for the next.
    • A header's copies on later pages lie outside the first box, so a later row never takes them. A drawing no cell took is never passed over and given to the next.
    • A repeated header row (w:tblHeader) is repeated by Word together with the drawing anchored in it. So the header's copies in its boxes on later pages are dropped rather than drawn a second time on the page.
    • Whatever no cell takes is anchored to the page when the table ends, as before.
  • Paint order (DocxDrawingAnchors.Ordered). A shape takes its place in the paint order when it is drawn, not when it finds a paragraph.
    • A shape waiting for its page's first paragraph is no longer painted over an icon anchored in a cell after it.
    • A table's composed drawings keep the places they took when the table began, wherever they are anchored.
  • Lines stay rules. A drawing holding a line is left to the page on both paths (DocxCellDrawings.holdsALine). A line alone in a cell, or in a stack of one layer, is a rule written as a paragraph border.
  • The badge path (a shape holding its initials) goes through queueDrawings, so a badge alone in its cell is anchored there.
  • Export report. It says whether a drawing, a drawn picture included, was anchored in its cell or to the page. A shape anchored in its cell is never reported as dropped.
  • Docs. CHANGELOG.md, the composed-cell paragraph of docs/recipes/docx-export.md, and the rectangle and ellipse rows of docs/architecture/backend-capability-matrix.md.

Verification

  • Full reactor gate: ./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa,:graph-compose-coverage -am gives BUILD SUCCESS (1791 + 127 tests).
    • render-docx runs 728 tests.
    • After install, examples run 93 green.
    • The knowledge checks pass.
  • DocxCellDrawingTest (new, 14 tests).
    • An icon composed in a table cell is anchored in that cell, held at its height, with nothing left on the page.
    • Two bands' icons of one size, red then blue, each land in their own cell.
    • A header's icon repeated on the second page is not taken by the rows there. The layout's page count shows the table reaching a second page, the 16 rows each hold their own blue icon, and there is no second copy of the header's icon.
    • A cell holding two drawings leaves both on the page.
    • A table nested unnamed in a cell does not lend its cells' boxes to the outer rows below it.
    • A padded stack composed in a cell stays on the page.
    • An icon no cell took (its mark holds a line) is not given to the next cell.
    • An icon alone in a cell of a row in the flow is anchored in that cell.
    • A shape waiting for its page's first paragraph is painted under an icon anchored in a cell after it.
    • A drawing with a margin stays on the page.
    • A drawing painting past its box stays on the page whole.
    • A line alone in a composed cell stays a rule. A line alone, or a mark holding a line, in a row of the flow stays on the page.
  • Each rule fails its own test when reverted:
    • Removing the composed path fails the composed, two-icon, repeated-header and passed-over tests.
    • Removing the cell box filter fails the repeated-header and passed-over tests.
    • Taking the cell boxes from every row rather than the table's own fails the nested-table test.
    • Keeping the header's later copies fails the repeated-header test.
    • Dropping the padding check fails the padded-stack test.
    • Dropping the only-drawing check fails the two-drawings test.
    • Dropping the inside-the-box check fails the past-the-box test.
    • Ordering shapes when anchored fails the paint-order test.
    • Dropping the composed line guard fails the passed-over test, whose mark holds a line. Dropping the flow line guard fails both flow line tests.
    • Removing the flow path fails the row and paint-order tests.
  • Word render (manual). A two-page table with an icon in its repeated header draws one header icon on each page.
  • DocxComposedCellTest.aTileHoldingOnlyDrawing… now also asserts the composed tile stays on the page.
  • DocxDrawingsTest.aBadgeOutsideAPanelHoldsItsInitialsToo now expects the badge, alone in its row's cell, to be anchored in that cell.
  • Template corpus (62 documents), against 2.5-dev: five documents change. The figures below are measured on exactly the output this branch writes.
    • CobaltRota, Word: p90 drift 11.7pt → 6.4pt, lines more than 2pt off 45 → 42.
    • CobaltRota, LibreOffice: p90 10.2pt → 7.3pt, lines more than 2pt off 47 → 46.
    • CobaltRota stays one page. In Word and in LibreOffice each band icon stands beside its label.
    • ProposalNorthline: lines more than 2pt off in Word 7 → 6.
    • MerchantInvoice, PlatformInvoice and ProposalEditorial: text drift and page count unchanged in Word and in LibreOffice.

Known limits

  • The cell's text column is taken to start where the drawing does. A drawing centred in a cell wider than itself, or set in from its cell's edge by more than the cell's margins, is placed at the column's edge.
  • A composed drawing in a table cell that also holds other drawings stays on the page.
  • A table with horizontal padding, or a row whose last column is covered by a row span from above, has no own rows by ownRows' width test, so its composed drawings stay on the page. placedRow, rowHeight and tableColumns share that test.
  • MerchantInvoice still runs to two pages in Word. That comes from its payment panel's border, which Word keeps inside the row, and is a separate change.

Lane: shared-engine (render-docx). No public API change.

… a repeated header's later copies, and leave a padded stack to the page
…cell's only drawing, and drop a header's copies only on later pages
@DemchaAV
DemchaAV merged commit 7712e38 into 2.5-dev Oct 1, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-rota-group-icons branch October 1, 2026 07:46
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.

2 participants