Skip to content

fix(docx): split a body table at the row a page with other margins starts - #952

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-page-margin-table-split
Oct 24, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-page-margin-table-split

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Why

A DOCX export writes a page whose top and bottom margins differ as a Word section, and a Word section starts only between blocks of the body. So a page that the layout started among a table's rows was named, and Word set it with the margins in force.

An invoice's line items are where that happens. WorkspaceInvoice sets another bottom margin from page 2. With its line items run onto three pages, Word and LibreOffice both set 58 words of its third page on its second.

What changed

  • DocxTableSplit (new, package-private)
    • rowStarting finds the row a page starts with, from the page the layout placed each row on.
    • split writes the rows from that row on as a table of their own, right after the first part. The second part opens with copies of the table's header rows: the layout repeats them at the top of each page, as Word repeats a table's leading header rows. A paragraph goes between the two parts.
    • The second part goes into the document's own lists of tables and body elements (insertTable). The passes after the section read those lists — the hidden cell closing marks, the one-row table keeps.
    • A table is not split where:
      • a cell spans rows across that row — the layout does break a page inside a row span;
      • only the header rows stand above it, which would leave them alone at the page's foot;
      • a header row holds what its copy cannot hold again: a bookmark or a drawing, whose name or id the file holds once, or a numbered list item, which the copy would number on from the original;
      • a header row, or a row from that one on, holds a field. The export settles a page reference once the body is written, through the paragraph it wrote it in: moving the row disconnects that paragraph, and a copied field would never be settled.
  • DocxPageMarginSections
    • Where a page starts inside a block that wrote a table, the plan asks DocxTableSplit for the row. The paragraph between the two parts ends the section, as a hairline. It needs room on the page above, as the hairline after a table does.
    • Carriers are found last to first, so a table split at two rows is split at the later one first, and the earlier row still stands where the plan found it.
    • The note goes on the table: the table is split there, the part after it opening with its header rows, and the page break is fixed there. So does the note where the rows above have no room for the hairline.
    • A page that starts inside a paragraph or a panel is named, as before.
    • A block named for a page before a written section is named again for a page after it, so a table named for page 2 and split for page 3 is named for page 4 too.
  • Docs: a CHANGELOG entry, the recipe section and the capability-matrix row. The fix(docx): write top and bottom margins that change from page to page as Word sections #951 entry no longer lists a table's rows among the blocks a section cannot start in.

Obsidian's line items sit in a painted panel, which waits on the panel being written in pieces where the layout breaks it.

Verification

  • DocxTableSplitTest (10 new tests):
    • the rows from the split row on go to a table of their own under the header rows, and it is in getTables() and getBodyElements() in order; a table with no header rows splits into two parts of its own rows;
    • the row a page starts with is the first the layout placed on it;
    • no split under the header alone, across a merged cell, under a header holding a bookmark, a drawing or a numbered item, after a row the layout did not place, or where a header row or a moving row holds a field (a field above the split row does not stop it);
    • only the leading repeated rows count as header rows.
  • DocxPageMarginSectionsTest (9 new, 2 rewritten):
    • a page starting among a table's rows splits it there. The first part holds the layout's page 1, the second opens with the header, a hairline between them ends the section, and the note is on the table;
    • a table without header rows is split with none, its note without the header clause;
    • a table over three sets of margins is split at each of their first rows, the sections ending with page 1's and page 2's margins;
    • a table split before a page break into a third set of margins ends two sections, the page break the second's;
    • a table of the second section of a document of several is split in it;
    • a table whose page-1 rows end a point above the foot is not split, and is named on the table;
    • a bookmark in a row moved to the second part is written once, there;
    • a page reference in a row that would move keeps the table whole: the export succeeds and the page is named;
    • a table named for page 2 (inside a row span), split for page 3 and inside a span again at page 4 is named for page 4;
    • a page starting inside a panel is named;
    • the page after one starting inside a block now uses a long paragraph, as a table no longer stays whole.
  • Fail-closed: 22 sabotages, each run alone, all caught. They break:
    • the guards: header alone, merge, bookmark, drawing, numbered item, unplaced row, a field in a moving row and one in a header row (8);
    • the row the page starts with (1);
    • the last-to-first order (1);
    • the split: the header copies, the document's lists, the first part's last row, where the paragraph goes, and splitting at all (5);
    • the room check, its note's path, and the hairline (3);
    • naming a block again after a written section (1);
    • the note: its header clause, left out and given where there is none, and its path (3).
  • Word and LibreOffice on Windows, from the export itself:
    • 400×600 probes:
      • A table with a repeated header, bottom margin 36→136pt from page 2: every page holds the layout's lines, each within 0.36pt in Word and 0.30pt in LibreOffice. The same across three sets of margins, split twice.
      • A table without a header: the same lines on every page. Pages 2 and 3 stand 0.4 to 0.8pt lower in both editors, as they do in the same table exported whole with one set of margins.
    • Invoices with their line items repeated six times:
      • WorkspaceInvoice: 58 words of page 3 set on page 2 before; now every word is on its page, within 0.5pt in both editors.
      • MerchantInvoice and PaymentsInvoice are split too. Both editors set every word on the same page as before, the layout's, and at the same height.
      • ObsidianInvoice is unchanged and named.
  • Corpus: 62 of 62 DOCX are byte for byte the same, and the 1384 notes are unchanged. None of its documents has margins that change from page to page.
  • 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 2128, testing 5, render-pptx 142, templates 127, qa 1830.
    • 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.

…arts

- A Word section starts only between blocks, so a page the layout
  started among a table's rows was named and Word set it with the
  margins before it: WorkspaceInvoice, its line items run onto three
  pages, set 58 words of its third page on its second.
- The table is written as two at the row the page starts with
  (DocxTableSplit), the second part opening with copies of its header
  rows, which the layout repeats on each page, and the hairline between
  the parts ends the section. Measured in Word and LibreOffice: every
  page holds the lines the layout sets on it.
- Not split, and named on the table as before, where a cell spans rows
  across that row, only the header rows stand above it, a header row
  holds a bookmark or a drawing, a header row or a moving row holds a
  page reference, which the export settles in the paragraph it wrote it
  in, or the rows above leave no room for the hairline.
- A table split at two rows is split at the later one first, and a
  block named before a written section is named again after it.
@DemchaAV
DemchaAV marked this pull request as draft October 24, 2026 06:15
…s split plainly

- A numbered list item in a header row is copied into the second part
  under the same list, where Word numbers it on from the original: the
  page's "1." would read "2." there. A table whose header row holds one
  is not split, as one holding a bookmark or a drawing is not.
- The split's note says the table is split there, the part after it
  opening with its header rows: "written as two" read wrong on a table
  split twice.
- Tests: a split before a page break into a third set of margins ends
  two sections; a table of a later section of a document of several is
  split in it; a numbered header row keeps the table whole.
- CHANGELOG: the margins in force rather than the page before's, the
  drift of a table without header rows as measured (0.4 to 0.8pt), what
  stays the same in MerchantInvoice and PaymentsInvoice, and Obsidian's
  one note.
@DemchaAV
DemchaAV marked this pull request as ready for review October 24, 2026 06:36
@DemchaAV
DemchaAV merged commit 243b82a into 2.5-dev Oct 24, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-page-margin-table-split branch October 24, 2026 07:30
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