Skip to content

fix(docx): write a painted card as a one-cell table - #734

Merged
DemchaAV merged 1 commit into
2.5-devfrom
feature/docx-panel-as-cell
Sep 23, 2026
Merged

DemchaAV merged 1 commit into
2.5-devfrom
feature/docx-panel-as-cell

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Why

A painted container (a card with a fill, an accent bar or an outline) exported as shading and borders on each paragraph inside it. Word has no element that wraps a run of paragraphs, so in LibreOffice the card came apart:

  • the accent bar broke beside every row and table inside it;
  • the band had white gaps where the space between blocks sat, and none under a table;
  • the top and bottom padding lay outside the band.

What changed

  • A painted container is a table of one cell (writeContainerChildren → writePanel), which is how a panel is built in Word by hand. The cell's shading is the fill behind everything inside, and its borders are the card's edges at full height. Its margins are the padding on all four sides. An unpainted container is unchanged: its children are written where it stood, indented by its insets.

  • The content goes through the ordinary writers. Children are written by the same writers a composed cell uses (writeCellNode became writeCellNodes), so they stay paragraphs, lists, rows, tables and pictures a reader edits as usual.

  • Geometry is translated from the page's. The engine centres a border on the panel's edge. LibreOffice keeps a cell's border inside the cell, and places a body table by its first cell's text, with the border centred on that margin line. Measured on a probe card:

    • each margin is the padding less half that side's border (insideTheBorders);
    • the table is half a border wider on each side;
    • a body table's w:tblInd is the text position less half the left border;
    • a table nested in a cell is placed by its outer edge, so there w:tblInd is the margin less half the left border.

    Before the half-border terms, the card sat 3pt right with a 3pt accent. Before the nested rule, a nested panel sat its whole padding right.

  • Width is the layout's. The table takes PlacedNode.placementWidth (new DocxLayoutMetrics.placedWidth). The engine sizes a card round short content, so a one-line card is narrow in Word as on the page. One point of slack is added, as auto columns already get, so an editor's substitute face does not wrap the longest line. Without it, the nested box's line wrapped in LibreOffice.

  • Unfilled table cells on a filled card, or inside a filled cell, are white (resolveCellFill, ENGINE_DEFAULT_CELL_FILL). The engine paints such cells white (TableCellLayoutStyle.DEFAULT), and an unshaded Word cell would show the colour behind it instead. A table straight on the page is unchanged: no shading is written there, as before.

  • No empty line under a nested table. Word ends a cell with a paragraph, and the one after a nested table was a full line tall. That put a blank line under every table in a card and at a card's bottom. It is now a 0.1pt hairline (holdToHairline). The next paragraph written in the cell takes it over; a second table written next makes it the separator between the two, with keepNext.

  • Pictures and nested content fit their cell. availableWidth() uses the cell's width when a cell of known width is being filled. writeRow now sets that width for each row cell, where before a picture in a row's column was sized to the page's width.

  • keepTogether() on a card the layout held on one page makes its row w:cantSplit.

  • What moved into the cell keeps what it had in the body:

    • writeNode reads what a block wrote from the cell being filled, not the body. Before, it read the body, which does not change inside a card. So a card's inner section with anchor(...) lost its bookmark, and a page reference to it pointed at nothing. Keeps on inner blocks were dropped the same way.
    • A block that takes over the closer counts it as its own.
    • A card that opens with a table is bookmarked from inside that table (edgeParagraph descends).
  • A page break inside a card closes the table and opens the card again after the break. Word breaks no page inside a cell, and the break used to land after the whole card.

  • Space above a card with nothing above it is held by a 0.1pt paragraph (holdTheSpaceAboveATable), where it used to be dropped:

    • a card's top margin at the top of the document or of a cell;
    • the top padding of the section it opens.
  • Edge cases:

    • An empty card is its padding tall; its one paragraph is a hairline.
    • A nested card's tblInd can be negative (half its border left of the cell), and is written signed.
    • With no canvas, or in a cell of unwritten width, no fixed width is written, so Word sizes the card. Before, the no-canvas case wrote a width derived from Double.MAX_VALUE.
    • Content of a filled table cell sits on that cell's fill, not the card's, so a chip over a zebra stripe is flattened against the stripe.
  • Removed the paragraph painter (applyContainerPaint, the fill-coloured hairline edges, the 31pt w:space cap, the list border spacing) and the containerPaint / cellPaintDepth state.

  • A translucent chip in an unshaded cell (a row's, inside a card) is flattened against the card's fill, not white (colourUnder).

  • The recipe's "What a panel keeps and loses", the Sections / Composed cells rows, the capability matrix row and the v2.5.0 CHANGELOG entries describe the table form.

Not carried: the corner radius (a cell is rectangular), warned once per export as before.

Known limits:

  • LibreOffice adds a border's width to a bordered table row's height, as it already did for every bordered table, so a card holding one runs about 1pt taller per row.
  • The placement rule for w:tblInd (body table by its text, nested table by its edge) was measured in LibreOffice only. Word has not been measured, and a nested card may sit a few points off there.

Verification

In an editor. Probe card: 3pt accent and 14pt padding, holding a title, a row, a wrapped paragraph, a nested outlined box, a table, an unpainted padded section with a table, and a list. Converted with LibreOffice and measured against the engine's PNG at 96 dpi:

engine LibreOffice
band left 62 62
accent bar 63–65 63–65
title text 84 83
nested box 82–456 81–457
nested text 94 93

The band's right edge is 3px wider, the point of slack. The nested line no longer wraps, and there is no blank line under the tables.

Tests. ./mvnw -B -ntp test -pl :graph-compose-render-docx → 345 run, 0 failures.

New DocxPanelEdgeCasesTest (11):

  • an anchor on a block inside a card, and one that takes over a table's closer;
  • keeps inside a card;
  • a card opening with a table is bookmarked inside it;
  • a page break splits the card;
  • the hairline holding the space above a card, and no holder when nothing is owed;
  • an empty card;
  • a negative nested tblInd;
  • no width without a canvas;
  • a chip over a stripe inside a card.

Each fix was sabotaged and fails its test.

DocxContainerPaintTest was rewritten for the table form (18):

  • one cell holding the paragraphs, with no paragraph shading;
  • accent as the cell's left border, other sides NIL; a uniform stroke on all four sides;
  • an unpainted section is not a table;
  • tcMar = padding, and tblInd = margin + padding;
  • width = the placed width + 1pt, for a one-word card and a wrapped one;
  • half-border margin, width and indent; a nested panel's tblInd = margin − half border;
  • top and bottom margin as the space around the table;
  • cantSplit only when kept together;
  • nested panel, row showing the panel through, and composed-cell stripe;
  • unfilled cells white on the card and unshaded on the page, with the default pinned to the engine's;
  • a paragraph taking over the closer; the hairline closer; two tables in a card separated by the closer.

Other test classes:

  • DocxPanelInsetTest (15): painted cases rewritten to cell margins; new picture-in-a-panel and picture-in-a-row cases.
  • DocxInlineBackgroundTest: new chip-in-a-row-on-a-card case; its paragraph walk now descends into nested tables.
  • DocxAdjacentTablesTest: the two panel-separator cases moved to the paint test.
  • DocxVerticalSpacingTest: two spacing cases no longer paint their wrapper, since they test the unpainted path.

Each of these was sabotaged and fails its test:

  • white fill;
  • closer reuse, and the hairline;
  • row cell width;
  • cantSplit;
  • body indent and nested indent;
  • the half-border margin, and the widening;
  • the slack;
  • the chip fallback.

Gate. Full reactor gate (clean verify over core, render-pdf/docx/pptx, templates, testing, qa, coverage) → BUILD SUCCESS (qa 1788 green). Examples after install: 93 green; the committed word-export-companion.docx did not move.

Merge order: stacked on #733 (fix/docx-panel-padding), which must merge first; this PR then retargets 2.5-dev.

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

Base automatically changed from fix/docx-panel-padding to 2.5-dev September 23, 2026 19:20
A container with a fill, per-side borders or a uniform stroke was painted
onto each paragraph inside it, and in an editor the card came apart: the
accent bar broke beside rows and tables, the band had gaps, and the top
and bottom padding lay outside it. It is now a table of one cell: the
shading is the fill, the borders run the card's full height, and the
padding is the cell's margins, less half of each border, with the table
half a border wider on each side so text and border land where the page
draws them. The table takes the width the layout placed the card at plus
a point of editor slack.

Blocks inside the card keep their anchors and keeps, a page break closes
the card and opens it again after the break, the space above a card with
nothing above it is held by a hairline paragraph, and the paragraph Word
requires after a nested table is a hairline the next paragraph takes over.
Unfilled table cells on a filled card or cell are written white, as the
engine paints them.
@DemchaAV
DemchaAV force-pushed the feature/docx-panel-as-cell branch from f8f495a to 311e8d1 Compare September 23, 2026 19:21
@DemchaAV
DemchaAV merged commit a79950a into 2.5-dev Sep 23, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feature/docx-panel-as-cell branch September 23, 2026 19:35
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