Skip to content

fix(docx): break a page after a card where the layout breaks it - #964

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-break-after-a-card
Oct 25, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-break-after-a-card

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Why

Where the layout breaks a page between two blocks of the body, Word and LibreOffice place that break themselves. Two things made them place it elsewhere. Measured in Word 16 and LibreOffice 24.2 on two invoice templates with their line items repeated (median baseline difference against the engine's PDF on the page):

Document Before, Word / LibreOffice After, Word / LibreOffice
ObsidianInvoice, line items ×12 info cards on page 5 with the footer divider, 17 lines, +123pt / page 5 −13.0pt every line on its page, page 5 −0.0pt / +0.0pt
MeteredInvoice, line items ×9 page 4 −13.0pt / −12.8pt page 4 −0.7pt / −0.5pt
  • Keeps in a table cell. A block kept together or kept with the next had its paragraphs chained with w:keepNext, in a table cell too. Word reads keep-with-next on the paragraph a cell opens with as keeping the cell's whole row with what follows its table. ObsidianInvoice's info cards, kept together in a row's two cells, went with the footer divider under them to page 5. With w:keepNext taken off the paragraphs the two cards open with (hand-patched), Word kept them on page 4; taken off only the separators between tables inside the cards, or only off the last paragraph of a card, it moved them still. Word also holds no keep across a break inside a row (measured for the panel keep notes), so in a cell those keeps did nothing else.
  • Space above at the top of a page. Word and LibreOffice drop a paragraph's space above at the top of a page they break themselves. A paragraph, a list, a picture or a spacer opening a page holds it in a line of its own. A rule did not: the divider stood its 13pt margin high on page 5. A table, a row or a panel held only its own top edge, and left on the page above the gap the layout carries onto the new page where that gap does not fit at the foot of the page above: MeteredInvoice's closing row opens page 4 its 12.4pt gap below the margin, and stood at the margin. The recipe named that gap as not held.
  • A page break written at the layout's page start is no alternative: with pageBreakBefore on the divider (hand-patched), Word still carried the cards to page 5, and both editors still dropped the 13pt.

What changed

  • DocxSemanticBackend.keepOnOnePage takes whether the block is written in the body. In a table cell its paragraphs get w:keepLines alone. A row of columns, which may not split, keeps the block whole; elsewhere in a cell Word held none of those keeps anyway.
    • The separator between two tables in a cell keeps w:keepNext, which raiseRows reads as its mark. Measured: a row at a page's foot whose cell opens with a card, a separator and a second card stays on its page in Word and LibreOffice with the separator's w:keepNext or without it.
  • writeRule: a rule opening its page in the flow holds its top edge with holdAParagraphsTopEdgeOnItsPage, as a picture does: its margin, the edges of the containers opening with it and a gap carried there, in an empty exact line kept with it.
  • leaveThePageAboveForTheBlock (new), where a table, a row, a painted row or a panel opens its page: what the layout holds above the block there, past its own top margin, the edges of the containers opening with it and a layer's offset down a stack of one layer (named, not written), is the gap the layout carried. It is taken out of what the page above is left with and held with the block's own edge (holdTheSpaceAboveOnItsPage). The space below the block above stays on its page, as the layout leaves it there (layout probe: 6pt below a filler, a 4pt gap and a 6pt margin on the row start the row 10pt below the margin).
  • Docs: the DOCX recipe (keeps in a cell, a rule's space above, a table's carried gap, ObsidianInvoice ×12), the capability matrix's keep row, the CHANGELOG, and the keep fate in the field ledger.

Where it applies

  • Corpus (62 documents): 15 lose w:keepNext on paragraphs in table cells and change in nothing else; the report is unchanged (1445 notes). Converted by the same Word 16 and LibreOffice 24.2 builds before and after, every line of the 15 stands where it stood (0.00pt). No page of the corpus opens with a rule or a carried gap.
  • Templates: MerchantInvoice, MeteredInvoice, ObsidianInvoice, PaymentsInvoice, PlatformInvoice, SubscriptionInvoice and WorkspaceInvoice, their line items repeated 1 to 16 times. Of the 35 lengths whose layout opens a page with a block of their body, measured before and after in both editors, the two in the table move, nearer the page; the other 33 are identical page by page.

Tests

  • DocxKeepTogetherTest (+2): a block kept together in a row's cell keeps its lines together and nothing with the next, its row kept whole; two painted cards kept together in a row's two cells keep nothing in the row with the next, and every line of theirs together.
  • DocxBlockOpeningAPageTest (+11):
    • a rule the layout moves to a new page holds its margin in a line kept with it, the filler's height above that line and none above the rule; one whose gap is carried holds the gap and its margin; one left on the page of the block before it holds no line;
    • a row whose gap is carried holds the gap, the filler's height alone left on the page above; one whose gap fits there holds no line; a row's own margin is held with the gap, the margin below the block above left on its page; a row opening a padded section holds the gap with the padding; a row set 12pt down a stack of one layer holds none of the offset, which is named;
    • a table with a margin, a painted row and a panel each hold the gap carried onto their page.
  • Rewritten, their premise a keep inside a cell: DocxOneRowTableKeptWithTheNextTest — a heading kept with the next in a row's column, and a row kept with the next inside a card, keep nothing with the next, their rows kept whole; DocxPanelEdgeCasesTest — a block kept together inside a card keeps its lines alone. The field ledger's keep fate says where a keep is written.
  • InvoiceNaturalPageBreakDocxTest (qa, new, 2): ObsidianInvoice ×12 — no paragraph its info cards' cells open with keeps with the next, and the footer divider opening page 5 holds its margin in a line; MeteredInvoice ×9 — the closing row opening page 4 holds the gap the layout carries there.
  • Fail-closed: 12 sabotages, each run alone, all caught, the source byte-identical afterwards: a keep in a cell writing keepNext; a keep in the body writing none; a rule opening its page holding nothing; a rule holding its edge on any page; the carried gap not held; the gap reckoned with the block's own margin, with the containers' edges, or with a layer offset not written; the gap also left on the page above; a table or row, a painted row and a panel each leaving the gap above.
  • Gate:
    • ./mvnw -B -ntp -f aggregator/pom.xml clean verify -pl <core, render-pdf, render-docx, render-pptx, templates, testing, qa, coverage> -am → BUILD SUCCESS: core 823, render-pdf 339, render-docx 2256, testing 5, render-pptx 142, templates 127, qa 1835.
    • 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.
  • Corpus: 62/62 exported; 15 documents change, in w:keepNext inside table cells alone; the report unchanged (1445 notes); in Word 16 (16.0.20527) and LibreOffice 24.2 the 15 set every line where they did, base and new converted by the same build.

Named, not fixed

  • A table, a row or a panel the layout runs on past the page it opens has no place of its own on that page to measure, and holds its own top edge alone: a gap carried above it is not held.
  • A chart's data table, a barcode, a page reference's paragraph and a layer stack written as columns hold no space above them on a page the layout opens with them.
  • A table, a row or a panel the layout moves to a new page out of a container begun on the page above holds that container's top edge on the new page, though the layout spent it on the page above, as before; a paragraph holds only what the layout leaves above it there.
  • None of these opens a page in the corpus or in the template lengths measured.

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

Where the layout breaks a page between two blocks of the body, Word and
LibreOffice place the break themselves. A block kept together in a table
cell chained its paragraphs with keepNext, which Word reads on the
paragraph a cell opens with as keeping the whole row with what follows
its table: ObsidianInvoice's info cards went to the next page with the
footer divider. In a cell a kept block now keeps its lines together only.

Word and LibreOffice drop a paragraph's space above at the top of a page
they break themselves. A rule opening its page now holds its top edge in
a line of its own, as a paragraph and a picture do, and a table, a row
or a panel holds the gap the layout carries onto its page with its own
edge, where it left that gap on the page above.
The closing row's gap is 12.4pt; Word set the page 13.0pt high and
LibreOffice 12.8pt, as the CHANGELOG and the recipe say. Three comments
gave the gap as both editors' measurement.
@DemchaAV
DemchaAV marked this pull request as ready for review October 25, 2026 21:07
@DemchaAV
DemchaAV merged commit a55552c into 2.5-dev Oct 25, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-break-after-a-card branch October 25, 2026 21:20
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