Skip to content

fix(docx): cut a panel whose table runs over pages into a table a page - #962

Merged
DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-panel-across-a-page
Oct 25, 2026
Merged

DemchaAV merged 3 commits into
2.5-devfrom
fix/docx-panel-across-a-page

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Why

A panel — a painted section or container — is written as a table of one cell. When the layout sets a panel over pages because a table inside it runs on, it breaks that table between rows, repeats the table's header rows at the top of the next page, and goes on with the panel there with no padding above it. The export wrote the panel as one table, so Word broke the panel's row wherever its lines ran out. Word also repeats no header row of a table nested in a cell.

No document of the 62-document corpus has such a panel. Two real templates do, once their line items run over a page:

  • ObsidianInvoice's line-items card;
  • SubscriptionInvoice's ruled line items.

Measured in Word 16 and LibreOffice 24.2 with the line items repeated three and six times:

Document, page Word before LibreOffice before
Obsidian ×3, page 2 header missing, text 29.7pt high (median) 1.6pt high
Obsidian ×6, page 3 header missing; a row a page early, 71pt high a row a page early, 71pt high
Subscription ×6, page 2 header missing, 23.6pt high 2.65pt low

What changed

  • writePanel notes a panel written whole in the body that the layout breaks over pages inside one of its own children: a table, and the only child of any kind it breaks over pages (noteATableOverPages), with its own section's layout. writeTableRows records the Word table each table node is written as (tablesWritten).

  • cutThePanelsAtTheirPages cuts each such panel at the row of its table each page starts with (DocxTableSplit.rowStarting). It runs once the document is written, after the drawings are anchored and the page references settled, so a part's copy carries what the writing put in it. It reads each panel's pages off its own section's layout, as each section is laid out on its own. Each part after a cut has no top padding, as the page goes on with the panel at the page's top. Each part before a cut has no bottom padding, as the page sets the rows down to the page's foot and closes the panel only on its last page.

  • DocxTableSplit.splitInItsPanel (new) does one cut:

    • it copies the panel after itself;
    • the first part keeps what the cell holds up to the table, and the table's rows before the cut;
    • the copy keeps the header rows, the rows from the cut on, and what follows the table;
    • a hairline paragraph between the two parts breaks the page before itself.

    A break written in that paragraph instead put a blank page after SubscriptionInvoice's first part, which fills its page to the foot: a break before a paragraph that already opens a page adds none. split and splitInItsPanel share the copy and the row trimming.

  • Room at a cut: a part before a cut must hold, under the page's last row, the borders Word sets inside the part: its bottom border and, where that part opens its page, its top border and the 0.1pt paragraph before it, plus a point for the editor's lines. The page sets the rows down to its foot and draws the border taking no room. Where the page above leaves less (DocxLayoutMetrics.lowestRowOn against the page's bottom margin), Word would set the last row on a page of its own and the fixed break would open one more page, so that page is not cut.

  • Report: the panel is named once (APPROXIMATED, reportTheCut). The note gives the pages it is cut at, as the panel's section counts them, the header rows where the table has them, and each page not cut and why: outside its table, at a row the table cannot be split at, or too near the foot. A panel cut at no page, an anchored card's for one, whose bookmark opens in its table's header row, is named as written whole. It says the page breaks are fixed, so rows an edit adds above a cut no longer run onto the next page. Where the panel has a top border, it also says a part after a cut stands about that border's width lower: Word draws the border inside the row, while the page strokes it on the panel's edge.

  • Not cut:

    • a page the panel runs onto outside its table; a page whose first row continues a merged cell, or from whose first row on a row holds a field; a table whose header rows hold a bookmark, a drawing, a numbered item or a field (rowStarting, as for the page-margin split); a page above which the rows end too near the foot; the note names each;
    • a panel with two blocks over pages, a table and a paragraph or two tables, a table deeper than the panel's own children, or a panel already cut at an explicit page break;
    • anything else the layout moves or breaks inside a panel (a block kept together, a stack, a paragraph), which is left to Word as before.
  • Docs: the recipe's panel Over pages paragraph, the capability matrix's keep row, the CHANGELOG.

Verification

  • Measured with the probe (scratch, not committed): Obsidian ×3 and ×6 and Subscription ×6 through the engine, Word 16 and LibreOffice 24.2. Every page now holds the lines the page sets on it, the same count, in both editors.

    Document, page Word after LibreOffice after
    Obsidian ×3, page 2 +1.0pt, header present −1.5pt
    Obsidian ×6, page 3 +1.5pt, row on its page +0.2pt, row on its page
    Subscription ×6, page 2 +2.8pt +2.75pt

    Cards padded 6pt (1pt border, a 400×300 page, two and three pages) and 12pt (2pt border, A4) also stand on their pages in both editors: page 1 within 0.33pt; each later page lower by its top border and the 0.1pt paragraph between, +1.1pt and +2.1pt in Word and LibreOffice alike.

    What remains is the top border: Subscription's ruled top is 2.82pt, and it is named in the report.

  • Tests:

    • DocxPanelOverPagesTest (new, 22):
      • a card whose table runs over two pages is two tables, and over three pages three, each holding under its header exactly the rows the layout sets on its page;
      • a table with no header rows is cut with none, and its note says nothing of one;
      • the parts stand apart by a hairline paragraph that breaks the page before itself and holds no break run;
      • a part after a cut has no top padding, a part before one no bottom padding, and the first and last keep their own;
      • a heading before the table stays in the first part, a paragraph after it goes to the last, and the first part's cell closes with a hairline paragraph;
      • the cut is named, with its pages, its header rows, its fixed breaks and the top border;
      • a page whose rows hold a field (a page reference) is left whole and named, and the next page is still cut, pages 1 and 2 one part and page 3 another;
      • a card in the first of two sections is cut by its own section's layout, and one in the second names its pages as its section counts them;
      • an anchored card, whose bookmark opens in its table's header row, is cut nowhere and named as written whole;
      • a page the card starts outside its table (a heading and a gap fill page 1) is named, and the table is still cut where page 3 starts;
      • a page above which the rows end within the bottom border and a point of the foot is not cut, and is named; where the part above opens its page, its top border and the 0.1pt paragraph count too (a page height found where page 2 leaves room for one and not the other);
      • a panel cut nowhere whose table has no header rows says nothing of a header;
      • a page the card runs onto after its table is named;
      • negative, each with the layout's premise: a panel whose table fits its page, a panel the layout breaks between paragraphs, a panel with a paragraph over pages beside its table, a panel with two tables over pages, a table one level deeper than the panel's own children, and a body table over pages are not cut.
    • InvoicePanelOverPagesDocxTest (qa, new, 2): ObsidianInvoice (×3, ×6) and SubscriptionInvoice (×6) are each a table a page for as many pages as the layout sets the card over, every part after the first opening with the line items' header.
  • Fail-closed: 25 sabotages, each run alone, all caught: no cut; the panel never noted; a part after a cut keeping its top padding; a part before one keeping its bottom padding; the break written in the paragraph; the first part's cell left ending with its table, or closed by a paragraph a line tall; the paragraph between a line tall; no header rows after a cut; a later cut's rows read from the table's start; what follows the table kept in the first part; what precedes it kept in the second; no room kept under the last row, or none for a middle part's top border; a page after the table read as one inside it; the cut, its top border, its header rows or a lost header where there is none, a page left whole, a page outside the table, a panel cut nowhere or a later section's pages misnamed or not named; the cut reading the last section's layout; only tables counted over pages.

  • Corpus: no document of the 62 has a panel the layout breaks over pages; every document is unchanged part for part, and so is the report (1445 notes).

  • Gate:

    • ./mvnw -B -ntp clean verify -pl <core, render-pdf, render-docx, render-pptx, templates, testing, qa, coverage> -am → BUILD SUCCESS: core 823, render-pdf 339, render-docx 2230, testing 5, render-pptx 142, templates 127, qa 1832.
    • The JDK 17 knowledge checks pass with the API surfaces unchanged, and core javadoc has 0 warnings.
    • ./mvnw -f examples/pom.xml test → BUILD SUCCESS, 93 tests.

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

The layout breaks a table in a panel between rows, repeats its header
rows at the top of the next page and goes on with the panel there, no
padding above it. The export wrote the panel as one table of one cell:
Word broke its row where its lines ran out and repeats no header row of
a table nested in a cell, so the line items after the break stood 24 to
30pt high with no header, and over three pages a row stood a page early.

Once the document is written, a panel whose own table is the only child
the layout breaks over pages is cut at the row each page starts with,
its pages read off its own section's layout. The part after a cut is a
copy of the panel holding the table's header rows, the rows from that
row on and what follows the table, with no top padding; a part before a
cut has no bottom padding; a hairline paragraph between the parts
breaks the page before itself. The report names the cut, its pages, its
fixed page breaks, any page left whole and the top border a part after
a cut stands lower by.
…ge not cut

A part before a cut must hold, under the page's last row, the borders
Word sets inside it: its bottom border and, where it opens its page,
its top border and the paragraph before it. The page sets the rows
down to its foot and draws the border taking no room. Where the page
above leaves less than those and a point, Word would set the last row
on a page of its own and the fixed break would open one more, so that
page is not cut.

The report names each page not cut and why: outside the panel's
table, at a row the table cannot be split at, or too near the foot.
A panel cut at no page, an anchored card whose bookmark opens in its
table's header row for one, is named as written whole.
A panel in a later section names the pages it is cut at, and those it
is not, with its section, as other notes naming a section's pages do.
A panel cut at no page says Word repeats no header row only where its
table has one.
@DemchaAV
DemchaAV marked this pull request as ready for review October 25, 2026 16:34
@DemchaAV
DemchaAV merged commit 02e4c61 into 2.5-dev Oct 25, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-panel-across-a-page branch October 25, 2026 16:43
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