From 16c74edd8e4e2954c436e85f8ca3480d4625407a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 25 Oct 2026 18:26:13 +0000
Subject: [PATCH 1/2] fix(docx): end a page-margin section at a panel's cut
A page that DocumentSession.pageMargins sets against other top and
bottom margins started no Word section where it started inside a panel:
Word set it with the margins in force. Where the export cuts that panel
at the row of its table the page starts with, the paragraph between the
two parts now ends the section, breaking no page before itself, where
the page above has room for it under the part.
A section's margins are planned once it is written, while panels are
cut once the document is: in a section whose margins vary, its panels'
cuts are decided when it is written and made as decided. The copy of
the header rows a part after a cut opens with holds no drawing: one the
export anchors there at the section's end is the table's first page's.
---
CHANGELOG.md | 37 ++
.../architecture/backend-capability-matrix.md | 4 +-
docs/recipes/docx-export.md | 34 +-
.../InvoicePanelOverPagesDocxTest.java | 78 ++++-
.../semantic/docx/DocxPageMarginSections.java | 98 ++++--
.../semantic/docx/DocxSemanticBackend.java | 160 +++++++--
.../backend/semantic/docx/DocxTableSplit.java | 23 ++
.../semantic/docx/DocxPanelOverPagesTest.java | 317 +++++++++++++++++-
.../semantic/docx/DocxTableSplitTest.java | 32 ++
9 files changed, 705 insertions(+), 78 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f632cc489..1c182e7d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,43 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export writes the margins of a page that starts at a panel's cut as a Word section
+ there.**
+ - A page that `session.pageMargins(...)` sets against other top and bottom margins than Word
+ would set it with starts a Word section with them, where it starts between blocks of the body
+ or among a body table's rows. Where it started inside a panel, it started none and was named:
+ Word set it with the margins in force. Measured in Word 16 and LibreOffice 24.2 on a card
+ whose table runs over three pages, a top margin 54pt larger from page 2 on stood every line of
+ pages 2 and 3 52.9pt high; margins of 20pt from page 2 on ran the card onto five pages in Word
+ and four in LibreOffice, where the page sets three.
+ - Where the export cuts such a panel at the row of its table the page starts with, the
+ paragraph between the two parts now ends the section: it breaks no page before itself, the
+ section's own break opening the page, and stands under the part above it. In both editors
+ every line of the card now stands on the page the page sets it on, lower by the cut's top
+ border only. `ObsidianInvoice`, whose pages from 2 on have a larger foot, is written so when its
+ line items run over pages: its cut where page 2 starts ends a section, and the pages after it
+ are set with page 2's margins.
+ - Where the page above has room for the cut and not for that paragraph under the part, the
+ panel is cut there as before and the page is named. A page the panel is not cut at is named,
+ as before. A panel in a section whose pages share one set of margins, or whose header or footer
+ counts its own pages, is cut as before.
+ - In a section whose margins change, a panel's cuts are decided once the section is written,
+ before the document's page references are settled: a row holding a page reference keeps its
+ page whole, as any field does, though one whose anchor has no bookmark ends up as its number.
+ A drawing the export anchors in the table's header row after that stays in the first part, the
+ page it is drawn on, and the part after a cut opens with a copy of the row without it.
+ - With its line items repeated 3 to 11 times `ObsidianInvoice` sets every line on its page in
+ Word and LibreOffice 24.2, as it did. Repeated 12 times, its page 5 starts between its info
+ cards and its footer divider, a page break the editors now place themselves, as page 5's
+ margins are page 2's: Word sets the cards on page 5 with the divider, because `keepNext` on
+ the paragraphs inside the cards' cell keeps their table with the paragraph after it, and both
+ editors drop the divider's 13pt of space above it at a page they break themselves. The same
+ invoice with one set of margins on every page is set the same way in both editors.
+ - Where a page the panel runs onto is not cut and a later one is, LibreOffice 24.2 sets the part
+ after that cut with the margins of the section before: it reads no section's margins after a
+ table it broke over a page itself. Word reads them.
+ - No document of the corpus has such a panel; its export is unchanged.
+
- **A DOCX export cuts a panel whose table the layout breaks over pages into a table a page.**
- 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
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 7a7a3fac5..971a0736b 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -122,7 +122,7 @@ honour an option ignores it (documented contract).
| Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ (not written, so the file opens unprotected; reported `DROPPED`, `protection`) |
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) |
| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a |
-| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list or such a line the layout moves to a new page in the flow holds its space above there as a paragraph does. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing. A panel in the body the layout breaks over pages inside a table it holds is a table a page, cut at the row each page starts with (`cutThePanelsAtTheirPages`): Word repeats no header row of a table nested in a cell, so each part after a cut opens with copies of the table's header rows, where it has them, and the page breaks between the parts are fixed |
+| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list or such a line the layout moves to a new page in the flow holds its space above there as a paragraph does. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing. A panel in the body the layout breaks over pages inside a table it holds is a table a page, cut at the row each page starts with (`cutThePanelsAtTheirPages`): Word repeats no header row of a table nested in a cell, so each part after a cut opens with copies of the table's header rows, where it has them, and the page breaks between the parts are fixed; where the page after a cut has other top and bottom margins, and the page above has room for it, the paragraph between the parts ends a Word section with them (per-page margins, below) |
| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
## Output surface and lifecycle
@@ -133,7 +133,7 @@ honour an option ignores it (documented contract).
| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` | ❌ (throws with a pointer to the PDF backend — POI's slide rasterizer cannot honour embedded fonts, and the PDF raster of the same graph is the canonical image output) | n/a (a `FixedLayoutRenderer` surface; the semantic SPI has no raster output) |
| Raster-slide mode — every page as one full-slide picture, pixel-exact to the PDF/PNG output (`Builder.rasterSlides(dpi)`) | n/a (the PDF raster is the source) | ✅ `PptxFixedLayoutBackend` | n/a (a fixed-layout backend builder option) |
| Multi-section documents (`renderSections`, per-section chrome, cross-section links) | ✅ `buildSectionsDocument` in `PdfFixedLayoutBackend` | ⚠️ `renderSections` in `PptxFixedLayoutBackend` (a deck carries one slide size, so every section must share the same page canvas — differing sizes throw) | ✅ `DocxSemanticBackend.exportSections`, reached through `MultiSectionDocument.export` / `toDocxBytes` — a Word section per section, each with its own page size, orientation, margins and page-zone header and footer (the text header and footer slots are written per section, their page total as `SECTIONPAGES`). Page numbers restart at 1 in every section and a zone's total is `SECTIONPAGES`, as the PDF counts; a section with no zone of its own gets an empty header or footer rather than Word's inherited one; styles, fonts and bookmark names are shared, so links cross sections. With different even and odd pages stated, Word starts every section on an odd page and adds a blank page before a section whose section before it runs to an odd number of pages, which the report names (LibreOffice adds none) |
-| Per-page margins (`DocumentSession.pageMargins`) | ✅ laid out page by page (`PageGeometry`) | ✅ drawn from the layout | ⚠️ `DocxPageMarginSections` — the first page's top and bottom margins are the section's; a page set against other top and bottom margins than Word sets it with starts a Word section with them where it starts between blocks of the body, an explicit page break there becoming the section's break and a hairline between two tables carrying its end, the title-page part kept to the first section, as is the page count from 1 in a document of several sections — measured in Word and LibreOffice on Windows, on pages of paragraphs, after a page break, after a table, between two tables and under a card's held margin, every page where the layout sets it, line for line, where one set of margins ran a page with a larger bottom margin on past its foot. Each section is named (`page margin`): its page break is fixed when the text above it is edited. Side margins stay the document's, which the body is written against and the page sets its header and footer from; a page whose side margins differ is named. A page that starts among a body table's rows splits the table at that row, the second part opening with copies of the header rows the layout repeats, the hairline between the parts ending the section — measured in both editors, every page holds the lines the layout sets on it; the split is named on the table. A page that starts inside another block (a paragraph's lines, a panel), at a table's row a cell spans rows across or only the header rows stand above, under a header row holding a bookmark, a drawing or a numbered list item, where a header row or a row that would move holds a page reference, after a page whose content reaches its foot under a table or in its rows, or in a document of sections whose header or footer counts its section's pages starts none and is named, Word setting it with the margins in force |
+| Per-page margins (`DocumentSession.pageMargins`) | ✅ laid out page by page (`PageGeometry`) | ✅ drawn from the layout | ⚠️ `DocxPageMarginSections` — the first page's top and bottom margins are the section's; a page set against other top and bottom margins than Word sets it with starts a Word section with them where it starts between blocks of the body, an explicit page break there becoming the section's break and a hairline between two tables carrying its end, the title-page part kept to the first section, as is the page count from 1 in a document of several sections — measured in Word and LibreOffice on Windows, on pages of paragraphs, after a page break, after a table, between two tables and under a card's held margin, every page where the layout sets it, line for line, where one set of margins ran a page with a larger bottom margin on past its foot. Each section is named (`page margin`): its page break is fixed when the text above it is edited. Side margins stay the document's, which the body is written against and the page sets its header and footer from; a page whose side margins differ is named. A page that starts among a body table's rows splits the table at that row, the second part opening with copies of the header rows the layout repeats, the hairline between the parts ending the section — measured in both editors, every page holds the lines the layout sets on it; the split is named on the table. A page that starts where a panel is cut at the row of its table the page starts with (`DocxTableSplit.splitInItsPanel`) ends the section in the paragraph between the two parts, which then breaks no page before itself — measured in both editors on a card over three pages with other margins from page 2, every line on its page, lower by the cut's top border only; where the page above has room for the cut and not for that paragraph under the part, the page is named. In a section whose margins change, the cuts are decided once the section is written, before page references are settled: a row holding one keeps its page whole, as any field does. LibreOffice 24.2 reads no section's margins after a table it broke over a page itself (a page the panel runs onto not cut, a later one cut); Word does. A page that starts inside another block (a paragraph's lines, a panel not cut there), at a table's row a cell spans rows across or only the header rows stand above, under a header row holding a bookmark, a drawing or a numbered list item, where a header row or a row that would move holds a page reference, after a page whose content reaches its foot under a table or in its rows, or in a document of sections whose header or footer counts its section's pages starts none and is named, Word setting it with the margins in force |
| Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | ✅ `PptxDeterminismWriter` (pinned OPC created/modified + zip entry-time normalization) | ✅ `DocxDeterminism` (`DocxSemanticBackend.builder().deterministic(true)` or `deterministic(Instant)`: pinned OPC created/modified + zip entry-time normalization; an embedded font's obfuscation key is derived from the font, so it needs no pinning) |
| ServiceLoader discovery (`FixedLayoutBackendProvider`) | ✅ `PdfFixedLayoutBackendProvider` (`format() == "pdf"`) | ✅ `PptxFixedLayoutBackendProvider` (`format() == "pptx"`) | n/a (semantic SPI: `SemanticBackend`) |
| `DocumentSession` convenience methods | ✅ `buildPdf` / `writePdf` / `toPdfBytes` / `toImages` | ✅ `buildPptx` / `writePptx` / `toPptxBytes` (resolved via `BackendProviders.fixedLayout("pptx")`; session chrome applies; fails with `MissingBackendException` naming `graph-compose-render-pptx` when the backend is absent) | ✅ `session.export(new DocxSemanticBackend(...), target)` — the semantic backend is named directly rather than discovered |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index b0d080c0c..3a2d7625a 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -194,8 +194,33 @@ header rows the layout repeats on each page, and the hairline between the two pa
section. Measured in Word and LibreOffice on Windows, every page holds the lines the layout sets
on it. The report names the split on the table (`page margin`).
-A page that starts inside any other block — the lines of a paragraph, a panel — does not start a
-section. Nor does a page that starts at a table's row where a cell spans rows across it, where
+A page that starts inside a panel the export cuts at the row of its table that page starts with
+(*Over pages*, under *What a panel keeps and loses*) ends the section in the paragraph between the
+two parts. That
+paragraph then breaks no page before itself, as the section's own break opens the page, and
+stands under the part above it, so the page above must leave room for it under its last row, above
+the bottom margin Word sets that page with, as well as for the borders the cut keeps there; where
+the page has room for the cut and not for that paragraph as well, the panel is cut as before and
+the page is named. In a section whose margins change, a panel's cuts are decided once the section
+is written, before the document's page references are settled: a row holding a page reference
+keeps its page whole, as any field does, though one whose anchor has no bookmark ends up as its
+number. A section whose header or footer counts its own pages starts no Word section for a page's
+margins, and its panels are cut as in a section of one set of margins. Measured in Word 16 and LibreOffice 24.2 on a card whose table runs over three
+pages: with one set of margins, a top margin 54pt larger from page 2 on stood every line of pages
+2 and 3 52.9pt high, and margins of 20pt from page 2 on ran the card onto five pages in Word and
+four in LibreOffice; with the section at the cut, every line stands on the page the page sets it
+on, lower by the cut's top border only. `ObsidianInvoice` is written so from page 2 on, its larger
+foot there. With its line items repeated 3 to 11 times every line stands on its page in both
+editors. Repeated 12 times, page 5 starts between its info cards and its footer divider, a page
+break the editors place themselves: Word sets the cards on page 5 with the divider, `keepNext` on
+the paragraphs inside the cards' cell keeping their table with the paragraph after it, and both
+editors drop the divider's 13pt of space above it at a page they break themselves — as they do
+with one set of margins on every page. Where a page a panel runs onto is not cut and a later one
+is, LibreOffice 24.2 sets the part after that cut with the earlier section's margins: it reads no
+section's margins after a table it broke over a page itself. Word reads them.
+
+A page that starts inside any other block — the lines of a paragraph, a panel not cut there —
+does not start a section. Nor does a page that starts at a table's row where a cell spans rows across it, where
only the header rows stand above it, or where a header row holds a bookmark or a drawing, which
the file can hold only once, or a numbered list item, which its copy would number on. Nor where a header row or a row from that one on holds a page
reference, which the export settles in the paragraph it wrote it in once the body is written. A
@@ -740,8 +765,9 @@ How it lands:
the row each page starts with, the part after a cut opening with copies of the table's header
rows and no padding above, as the page goes on with it, a part before a cut with no padding
below, as the page sets the rows down to the page's foot, and a hairline paragraph between the
- parts breaking the page before itself, which adds no page where the part above fills its own.
- Written as one table, Word broke the panel's row where its lines ran out and repeated no header
+ parts breaking the page before itself, which adds no page where the part above fills its own;
+ where the page after the cut has other top and bottom margins, that paragraph ends a Word
+ section instead (*Margins that change from page to page*, above). Written as one table, Word broke the panel's row where its lines ran out and repeated no header
row of the table nested in its cell (measured in Word 16 and LibreOffice 24.2:
`ObsidianInvoice`'s and `SubscriptionInvoice`'s line items over two pages stood 24 to 30pt
high in Word on the second, its header missing, and over three pages a row stood a page early
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/invoice/presets/InvoicePanelOverPagesDocxTest.java b/qa/src/test/java/com/demcha/compose/document/templates/invoice/presets/InvoicePanelOverPagesDocxTest.java
index 9d005b5d9..e5197359c 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/invoice/presets/InvoicePanelOverPagesDocxTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/invoice/presets/InvoicePanelOverPagesDocxTest.java
@@ -43,22 +43,70 @@ void subscriptionsRuledLineItemsAreATableAPage() throws Exception {
(s, d) -> SubscriptionInvoice.create().compose(s, d), "LineItemsRuled", "DESCRIPTION");
}
- /**
- * Exports the invoice with its line items repeated {@code times} and checks that the card the
- * layout breaks over pages is as many tables as it has pages, each after the first set apart by
- * a paragraph that breaks the page before itself and opening with the line items' header.
- */
- private static void assertCutAtItsPages(StructuredInvoiceData data, int times,
- BiConsumer preset,
- String card, String header) throws Exception {
+ @Test
+ void obsidiansContinuationMarginsStartAWordSectionAtTheCardsFirstCut() throws Exception {
+ // From page 2 on the page sets a larger foot; the card's cut where page 2 starts ends a Word
+ // section with the first page's margins, and the pages after it are set with page 2's.
+ for (int times : new int[]{3, 6}) {
+ byte[] docx = export(longer(ObsidianInvoiceFixtures.invoice(), times),
+ (s, d) -> ObsidianInvoice.create().compose(s, d));
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ List body = document.getBodyElements();
+ List ending = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetSectPr())
+ .toList();
+ assertThat(ending).as("×%d: one section ends, at the card's first cut", times).singleElement()
+ .satisfies(between -> {
+ assertThat(body.get(body.indexOf(between) - 1)).isInstanceOf(XWPFTable.class);
+ assertThat(body.get(body.indexOf(between) + 1)).isInstanceOf(XWPFTable.class);
+ assertThat(between.isPageBreak()).isFalse();
+ assertThat(bottomOf(between.getCTP().getPPr().getSectPr()))
+ .isEqualTo(twips(ObsidianStyles.MARGIN_B_FIRST));
+ });
+ assertThat(bottomOf(document.getDocument().getBody().getSectPr()))
+ .isEqualTo(twips(ObsidianStyles.MARGIN_B_LATER));
+ }
+ }
+ }
+
+ private static long bottomOf(org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr section) {
+ return ((java.math.BigInteger) section.getPgMar().getBottom()).longValue();
+ }
+
+ private static long twips(double points) {
+ return Math.round(points * 20);
+ }
+
+ /** The invoice with its line items repeated {@code times}. */
+ private static StructuredInvoiceData longer(StructuredInvoiceData data, int times) {
List lines = new ArrayList<>();
for (int time = 0; time < times; time++) {
lines.addAll(data.serviceLines().lines());
}
- StructuredInvoiceData longer = new StructuredInvoiceData(data.brand(), data.supplier(), data.masthead(),
+ return new StructuredInvoiceData(data.brand(), data.supplier(), data.masthead(),
data.billTo(), data.shipTo(), data.summary(),
new InvoiceServiceLines(data.serviceLines().columns(), lines), data.totals(), data.payment(),
data.notes(), data.currencyCode());
+ }
+
+ private static byte[] export(StructuredInvoiceData data, BiConsumer preset)
+ throws Exception {
+ try (DocumentSession session = GraphCompose.document().create()) {
+ preset.accept(session, data);
+ return session.export(new DocxSemanticBackend());
+ }
+ }
+
+ /**
+ * Exports the invoice with its line items repeated {@code times} and checks that the card the
+ * layout breaks over pages is as many tables as it has pages, each after the first set apart by
+ * a paragraph that breaks the page before itself, or ends a Word section where the page's
+ * margins change, and opening with the line items' header.
+ */
+ private static void assertCutAtItsPages(StructuredInvoiceData data, int times,
+ BiConsumer preset,
+ String card, String header) throws Exception {
+ StructuredInvoiceData longer = longer(data, times);
int pages;
byte[] docx;
try (DocumentSession session = GraphCompose.document().create()) {
@@ -72,12 +120,16 @@ private static void assertCutAtItsPages(StructuredInvoiceData data, int times,
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
List body = document.getBodyElements();
List parts = new ArrayList<>();
- for (int index = 0; index < body.size(); index++) {
- if (body.get(index) instanceof XWPFParagraph between && between.isPageBreak()) {
- parts.add((XWPFTable) body.get(index + 1));
+ for (int index = 0; index + 1 < body.size(); index++) {
+ if (body.get(index) instanceof XWPFParagraph between
+ && (between.isPageBreak() || between.getCTP().isSetPPr() && between.getCTP().getPPr().isSetSectPr())
+ && body.get(index + 1) instanceof XWPFTable part
+ && !part.getRows().isEmpty() && !part.getRow(0).getCell(0).getTables().isEmpty()) {
+ parts.add(part);
}
}
- assertThat(parts).as("%s's parts after the first, %d lines", card, lines.size()).hasSize(pages - 1);
+ assertThat(parts).as("%s's parts after the first, %d lines", card, longer.serviceLines().lines().size())
+ .hasSize(pages - 1);
for (XWPFTable part : parts) {
XWPFTable items = part.getRow(0).getCell(0).getTables().get(0);
// The header's cells may hold tables of their own, which a cell's text leaves out.
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java
index 25880429f..c3284df99 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java
@@ -23,6 +23,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.function.Consumer;
/**
* Writes the top and bottom margins of a page the layout sets against other ones than Word sets
@@ -44,9 +45,10 @@
*
* A section ends in a paragraph of the body, so it can start only between blocks written
* there. A table the page starts among the rows of is split at the row it starts with, where it
- * can be ({@link DocxTableSplit#rowStarting}). Where the page starts inside any other block — the lines of a
- * paragraph, a panel — its margins are named instead, and Word sets that page with the margins in
- * force.
+ * can be ({@link DocxTableSplit#rowStarting}); a panel the export cuts at the row of its table the
+ * page starts with ends the section in the paragraph between its two parts ({@link PanelCut}).
+ * Where the page starts inside any other block — the lines of a paragraph, a panel not cut there —
+ * its margins are named instead, and Word sets that page with the margins in force.
*
* @author Artem Demchyshyn
*/
@@ -88,12 +90,36 @@ private record Start(int page, Margin margin, Carrier carrier) {
* What ends the section before a start: a page break written there, a paragraph standing there —
* the one before it, or a hairline between two tables — or else a hairline put before {@code at},
* or, where {@code row} is not -1, between the two parts of the table {@code at} is split into
- * before that row.
+ * before that row; or, where {@code cut} is set, the paragraph between the two parts of a panel
+ * cut there once the document is written.
*
* @param hairline whether what ends it is a hairline under a table, which needs room on its page
*/
private record Carrier(XWPFParagraph pageBreak, XWPFParagraph paragraph, IBodyElement at, int row,
- boolean hairline) {
+ boolean hairline, PanelCut cut) {
+ }
+
+ /**
+ * A cut the export makes in a panel of the body at the row of its table a page starts with, once
+ * the document is written: the panel is written as a table of one cell, and its part after the
+ * cut as a table of its own after it, a paragraph between the two. That paragraph ends a section
+ * there, the section's own break opening the page instead of a break before the paragraph:
+ * measured in Word and LibreOffice on Windows, a card whose table ran over three pages, from page
+ * 2 on with a top margin 54pt larger, stood every line of pages 2 and 3 where the page does but
+ * for its top border, where with one set of margins they stood 52.9pt high.
+ *
+ * The paragraph then stands under the part before the cut, on the page above: that page's
+ * last row must leave room for it, above the bottom margin Word sets that page with, as well as
+ * for what the cut needs there.
+ *
+ * @param rowsEnd how high above the page's foot the part's last row on the page above ends, in
+ * points
+ * @param needed the room the part needs under that row with the paragraph ending a section
+ * under it
+ * @param section takes the properties of the section the paragraph ends, which the cut writes
+ * there when it is made
+ */
+ record PanelCut(double rowsEnd, double needed, Consumer section) {
}
/**
@@ -147,6 +173,8 @@ static boolean vary(DocxLayoutMetrics layout) {
* @param from the index of the section's first body element
* @param owners the pages and node of each body element the section wrote
* @param separators the hairlines the body sets between two tables
+ * @param cuts the cuts the export makes in panels of the section, by the panel's table and
+ * the page each opens, from 0
* @param layout the section's layout
* @param report where the notes go
* @param section the report's path for the section, or {@code null} for a document of one
@@ -158,8 +186,9 @@ static boolean vary(DocxLayoutMetrics layout) {
* @return the plan
*/
static Plan plan(XWPFDocument document, int from, Map owners, Set separators,
- DocxLayoutMetrics layout, DocxExportReport.Builder report, String section,
- boolean countsItsPages, Margin sides, boolean zoned) {
+ Map> cuts, DocxLayoutMetrics layout,
+ DocxExportReport.Builder report, String section, boolean countsItsPages, Margin sides,
+ boolean zoned) {
Margin opening = layout.marginOf(0);
nameTheSides(layout, report, section, sides);
if (!vary(layout)) {
@@ -196,10 +225,13 @@ static Plan plan(XWPFDocument document, int from, Map owner
String notWritten = differ + ", which Word sets it with: ";
Owner inside = spanning(page, elements, owners);
XWPFTable table = inside == null ? null : tableOf(inside, elements, owners);
+ PanelCut cut = table == null ? null : cuts.getOrDefault(table, Map.of()).get(page);
Carrier carrier = null;
- if (table != null) {
+ if (cut != null) {
+ carrier = new Carrier(null, null, null, -1, false, cut);
+ } else if (table != null) {
int row = DocxTableSplit.rowStarting(table, page, index -> layout.rowPage(inside.node(), index));
- carrier = row < 0 ? null : new Carrier(null, null, table, row, true);
+ carrier = row < 0 ? null : new Carrier(null, null, table, row, true, null);
} else if (inside == null) {
int at = firstOn(page, elements, owners);
// Where the first block after it starts on a later page, none starts this one.
@@ -229,6 +261,13 @@ static Plan plan(XWPFDocument document, int from, Map owner
}
continue;
}
+ if (cut != null && !(cut.rowsEnd() - inForce.bottom() >= cut.needed())) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, layout.pathOf(inside.node()),
+ notWritten + "page " + page + "'s rows reach too near its foot for the paragraph that would end "
+ + "a Word section between the parts the panel is cut into there, which would open a page of "
+ + "its own");
+ continue;
+ }
// A page with no content has room for a hairline.
double room = layout.lowestContentOn(page - 1) - inForce.bottom();
if (carrier.hairline() && room < ROOM_FOR_THE_END) {
@@ -239,7 +278,11 @@ static Plan plan(XWPFDocument document, int from, Map owner
continue;
}
starts.add(new Start(page, margin, carrier));
- if (table != null) {
+ if (cut != null) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, layout.pathOf(inside.node()), differ
+ + ": written as a Word section that starts where the panel is cut at the row of its table page "
+ + (page + 1) + " starts with, in the paragraph between the two parts");
+ } else if (table != null) {
report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, layout.pathOf(inside.node()), differ
+ ": written as a Word section that starts at the row page " + (page + 1) + " starts with, the "
+ "table split there" + (DocxTableSplit.headerRows(table) > 0
@@ -285,7 +328,8 @@ private static void nameTheSides(DocxLayoutMetrics layout, DocxExportReport.Buil
/**
* Ends a section before each start of a plan: the paragraph ending it carries the section's
- * properties, and the body's own become the last section's.
+ * properties, and the body's own become the last section's. A section ending at a panel's cut
+ * hands its properties to the cut, which writes them once it is made ({@link PanelCut}).
*
* @param document the document, its section written
* @param plan where its pages start sections
@@ -300,22 +344,30 @@ static void apply(XWPFDocument document, Plan plan) {
// earlier still stands where the plan found it.
XWPFParagraph[] carriers = new XWPFParagraph[plan.starts().size()];
for (int index = carriers.length - 1; index >= 0; index--) {
- carriers[index] = carrierOf(document, plan.starts().get(index).carrier());
+ Carrier carrier = plan.starts().get(index).carrier();
+ if (carrier.cut() == null) {
+ carriers[index] = carrierOf(document, carrier);
+ }
}
Margin margin = plan.opening();
for (int index = 0; index < carriers.length; index++) {
+ Start start = plan.starts().get(index);
CTSectPr ending = (CTSectPr) opening.copy();
if (index > 0) {
asALaterSection(ending, opening, margin, plan.zoned());
}
- XWPFParagraph carrier = carriers[index];
- CTPPr properties = carrier.getCTP().isSetPPr() ? carrier.getCTP().getPPr() : carrier.getCTP().addNewPPr();
- // Kept with the next paragraph, on the page the section's break opens, it would go there.
- if (properties.isSetKeepNext()) {
- properties.unsetKeepNext();
+ if (start.carrier().cut() != null) {
+ start.carrier().cut().section().accept(ending);
+ } else {
+ XWPFParagraph carrier = carriers[index];
+ CTPPr properties = carrier.getCTP().isSetPPr() ? carrier.getCTP().getPPr() : carrier.getCTP().addNewPPr();
+ // Kept with the next paragraph, on the page the section's break opens, it would go there.
+ if (properties.isSetKeepNext()) {
+ properties.unsetKeepNext();
+ }
+ properties.setSectPr(ending);
}
- properties.setSectPr(ending);
- margin = plan.starts().get(index).margin();
+ margin = start.margin();
}
asALaterSection(body, opening, margin, plan.zoned());
}
@@ -397,15 +449,15 @@ private static Carrier carrierBefore(int at, List elements, Map 0 ? elements.get(at - 1) : null;
if (element instanceof XWPFParagraph breaking && owners.get(breaking).node() instanceof PageBreakNode) {
- return new Carrier(breaking, null, null, -1, false);
+ return new Carrier(breaking, null, null, -1, false, null);
}
if (element instanceof XWPFParagraph separator && separators.contains(separator)) {
- return new Carrier(null, separator, null, -1, true);
+ return new Carrier(null, separator, null, -1, true, null);
}
if (before instanceof XWPFParagraph last) {
- return new Carrier(null, last, null, -1, false);
+ return new Carrier(null, last, null, -1, false, null);
}
- return new Carrier(null, null, element, -1, true);
+ return new Carrier(null, null, element, -1, true, null);
}
/**
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 9ea0bf646..d959bb6e4 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -440,6 +440,9 @@ public final class DocxSemanticBackend implements SemanticBackend {
// Panels the layout breaks over pages inside the table they hold, cut there once the document is
// written (cutThePanelsAtTheirPages).
private final List panelsOverPages = new ArrayList<>();
+ // Where each of those is cut, decided once: where a page's margins start a Word section at a cut,
+ // when its section is written (cutsOpeningSections).
+ private final java.util.Map cutsDecided = new java.util.IdentityHashMap<>();
// Where the shapes this export draws are anchored — see DocxDrawingAnchors.
private final DocxDrawingAnchors anchors = new DocxDrawingAnchors(() -> nextDrawingId++,
paragraph -> grownLines.lineOf(paragraph));
@@ -1152,6 +1155,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
followedInFlow.clear();
tablesWritten.clear();
panelsOverPages.clear();
+ cutsDecided.clear();
slotDepth = 0;
surfaceBehind = null;
overlayDepth = 0;
@@ -1323,8 +1327,9 @@ private byte[] write(List sections, Path outputFile) throws Exc
boolean zoned = !context.outputOptions().zones().isEmpty()
|| !context.outputOptions().headersAndFooters().isEmpty();
DocxPageMarginSections.Plan margins = DocxPageMarginSections.plan(document, sectionFirstElement,
- bodyOwners, tableSeparators, layout, report, sectioned ? "section " + (index + 1) : null,
- countsItsSectionPages, context.canvas() == null ? null : context.canvas().margin(), zoned);
+ bodyOwners, tableSeparators, cutsOpeningSections(), layout, report,
+ sectioned ? "section " + (index + 1) : null, countsItsSectionPages,
+ context.canvas() == null ? null : context.canvas().margin(), zoned);
// The paragraph closing a section that ends with a table is also the one that
// carries the drawings nothing else on the last page carried.
reportDrawingsLeftOver(document,
@@ -7176,59 +7181,144 @@ private void noteATableOverPages(DocumentNode node, XWPFTable outer) {
*
* Done once the document is written — its drawings anchored, its page references settled —
* so that a part's copy carries what the writing put in it. A panel's own section's layout
- * reads its pages: each section is laid out on its own.
+ * reads its pages: each section is laid out on its own. A panel in a section whose pages are set
+ * against more than one set of margins was decided on when that section was written
+ * ({@link #cutsOpeningSections}); where a page's margins start a Word section at a cut, the
+ * paragraph between the parts ends it, and the section's own break opens the page.
*/
private void cutThePanelsAtTheirPages(XWPFDocument document) {
for (PanelOverPages over : panelsOverPages) {
- DocxLayoutMetrics itsLayout = over.layout();
- com.demcha.compose.document.layout.PlacedNode panel = itsLayout.placement(over.panel());
- com.demcha.compose.document.layout.PlacedNode placed = itsLayout.placement(over.table());
- DocumentBorders borders = paintOf(over.panel()).borders();
- double top = strokeWidth(borders == null ? null : borders.top());
- double bottom = strokeWidth(borders == null ? null : borders.bottom());
+ PanelCuts cuts = cutsDecided.computeIfAbsent(over, this::decideTheCuts);
XWPFTable piece = over.outer();
XWPFTable inner = over.inner();
- boolean header = DocxTableSplit.headerRows(inner) > 0;
+ int headerRows = DocxTableSplit.headerRows(inner);
// The table's rows the parts before the current one hold, after its header rows.
int before = 0;
- List cut = new ArrayList<>();
- java.util.Map left = new java.util.TreeMap<>();
- for (int page = panel.startPage() + 1; page <= panel.endPage(); page++) {
- if (page <= placed.startPage() || page > placed.endPage()) {
- left.put(page, "outside its table");
- continue;
- }
- int headerRows = DocxTableSplit.headerRows(inner);
- int skipped = before;
- int row = DocxTableSplit.rowStarting(inner, page,
- index -> itsLayout.rowPage(over.table(), index < headerRows ? index : index + skipped));
- if (row < 0) {
- left.put(page, "at a row its table cannot be split at");
- continue;
- }
- var margin = itsLayout.marginOf(page - 1);
- double room = itsLayout.lowestRowOn(over.table(), page - 1) - (margin == null ? 0 : margin.bottom());
- double needed = bottom + (cut.contains(page - 1) ? top + SEPARATOR_POINTS : 0) + ROOM_FOR_THE_LINES;
- if (!(room >= needed)) {
- left.put(page, "the page above setting its rows too near its foot for the borders Word sets inside "
- + "the part");
- continue;
- }
+ for (java.util.Map.Entry cut : cuts.cuts().entrySet()) {
+ int row = cut.getValue().row() - before;
DocxTableSplit.PanelPart part = DocxTableSplit.splitInItsPanel(document, piece, inner, row);
XWPFTable next = part.second();
// The paragraph closing the first part's table, and the paragraph between the parts,
// which keeps them two tables, are as short as one between tables.
holdToHairline(part.closer());
holdToHairline(part.between());
+ CTSectPr section = cuts.sections().get(cut.getKey());
+ if (section != null) {
+ // A break before the paragraph ending the section would open a page more.
+ part.between().getCTP().getPPr().unsetPageBreakBefore();
+ part.between().getCTP().getPPr().setSectPr(section);
+ }
setCellMarginTwips(piece.getRow(0).getCell(0), false, 0);
setCellMarginTwips(next.getRow(0).getCell(0), true, 0);
before += row - headerRows;
piece = next;
inner = next.getRow(0).getCell(0).getTables().get(0);
- cut.add(page);
}
- reportTheCut(over, header, top, cut, left);
+ reportTheCut(over, headerRows > 0, cuts.top(), new ArrayList<>(cuts.cuts().keySet()), cuts.left());
+ }
+ }
+
+ /**
+ * Where a panel the layout breaks over pages inside its table is cut ({@link #cutThePanelsAtTheirPages}).
+ *
+ * @param cuts each cut, by the page it opens, from 0 in the panel's section
+ * @param left the pages, from 0 in its section, left to Word, each with why
+ * @param sections the properties of the Word section that ends at a cut, by the page it opens, where
+ * that page's margins start one there: filled as the section's margins are applied
+ * ({@link DocxPageMarginSections#apply}, through {@link DocxPageMarginSections.PanelCut}),
+ * for cuts decided when their section was written, and each written when its cut is
+ * made
+ * @param top the width of the panel's top border, in points
+ */
+ private record PanelCuts(java.util.SortedMap cuts, java.util.Map left,
+ java.util.Map sections, double top) {
+ }
+
+ /**
+ * One cut of a panel.
+ *
+ * @param row the row of its table the page starts with, counted in the table as written whole
+ * @param rowsEnd how high above the page's foot the table's last row on the page above ends, in points
+ * @param needed the room the part before the cut needs under that row for the borders Word sets
+ * inside it
+ */
+ private record RowCut(int row, double rowsEnd, double needed) {
+ }
+
+ /**
+ * Decides where a panel the layout breaks over pages inside its table is cut: at the row of its
+ * table each page starts with, except where {@link #cutThePanelsAtTheirPages} leaves a page to
+ * Word. The rows are read in the table as written whole: a part after a cut holds the header rows
+ * and the rows from the cut on, so a later page starts with the same row either way.
+ */
+ private PanelCuts decideTheCuts(PanelOverPages over) {
+ DocxLayoutMetrics itsLayout = over.layout();
+ com.demcha.compose.document.layout.PlacedNode panel = itsLayout.placement(over.panel());
+ com.demcha.compose.document.layout.PlacedNode placed = itsLayout.placement(over.table());
+ DocumentBorders borders = paintOf(over.panel()).borders();
+ double top = strokeWidth(borders == null ? null : borders.top());
+ double bottom = strokeWidth(borders == null ? null : borders.bottom());
+ java.util.SortedMap cuts = new java.util.TreeMap<>();
+ java.util.Map left = new java.util.TreeMap<>();
+ for (int page = panel.startPage() + 1; page <= panel.endPage(); page++) {
+ if (page <= placed.startPage() || page > placed.endPage()) {
+ left.put(page, "outside its table");
+ continue;
+ }
+ int row = DocxTableSplit.rowStarting(over.inner(), page, index -> itsLayout.rowPage(over.table(), index));
+ if (row < 0) {
+ left.put(page, "at a row its table cannot be split at");
+ continue;
+ }
+ var margin = itsLayout.marginOf(page - 1);
+ double rowsEnd = itsLayout.lowestRowOn(over.table(), page - 1);
+ double room = rowsEnd - (margin == null ? 0 : margin.bottom());
+ double needed = bottom + (cuts.containsKey(page - 1) ? top + SEPARATOR_POINTS : 0) + ROOM_FOR_THE_LINES;
+ if (!(room >= needed)) {
+ left.put(page, "the page above setting its rows too near its foot for the borders Word sets inside "
+ + "the part");
+ continue;
+ }
+ cuts.put(page, new RowCut(row, rowsEnd, needed));
+ }
+ return new PanelCuts(cuts, left, new java.util.HashMap<>(), top);
+ }
+
+ /**
+ * The cuts of the panels of the section just written, where a page's margins may start a Word
+ * section ({@link DocxPageMarginSections}), by each panel's table and the page each cut opens;
+ * none where the section's pages share one set of margins.
+ *
+ * A section's margins are planned once it is written, before the next is, and its panels are
+ * cut only once the document is ({@link #cutThePanelsAtTheirPages}): so their cuts are decided
+ * now, and made as decided. A cut can end a section where the page above leaves room under the
+ * part before it for the paragraph between the parts as well, which then stands there.
+ *
+ * Decided now, a cut reads its rows before the document's page references are settled and its
+ * bookmarks kept: a row holding a page reference is still a field, and a header row a bookmark
+ * the export later takes out still holds it, so the page is not cut there, where once the document
+ * is written it would be. A section whose header or footer counts its own pages starts no Word
+ * section of its own for a page's margins, and its panels are decided once the document is
+ * written.
+ */
+ private java.util.Map> cutsOpeningSections() {
+ if (!marginsVary || countsItsSectionPages) {
+ return java.util.Map.of();
+ }
+ java.util.Map> found =
+ new java.util.IdentityHashMap<>();
+ for (PanelOverPages over : panelsOverPages) {
+ if (over.layout() != layout || cutsDecided.containsKey(over)) {
+ continue;
+ }
+ PanelCuts cuts = decideTheCuts(over);
+ cutsDecided.put(over, cuts);
+ java.util.Map pages = new java.util.HashMap<>();
+ cuts.cuts().forEach((page, cut) -> pages.put(page, new DocxPageMarginSections.PanelCut(cut.rowsEnd(),
+ cut.needed() + SEPARATOR_POINTS, section -> cuts.sections().put(page, section))));
+ found.put(over.outer(), pages);
}
+ return found;
}
/**
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
index 9927739bf..03546b34f 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
@@ -41,6 +41,9 @@ final class DocxTableSplit {
// row moved to the second part leaves that paragraph behind, and a copy of one is never settled.
private static final List FIELDS = List.of("fldChar", "instrText", "fldSimple");
+ // A drawing, which a panel's copy of the header rows leaves out (splitInItsPanel).
+ private static final List DRAWINGS = List.of("drawing", "pict", "object");
+
private DocxTableSplit() {
}
@@ -120,6 +123,12 @@ static XWPFParagraph split(XWPFDocument document, XWPFTable table, int row) {
* row on and what follows the table in the cell. Each part is the panel's table, its fill and
* its four borders. The paragraph put between them breaks the page before itself.
*
+ * The copy of the header rows holds no drawing. Where the cut is decided, they hold none
+ * ({@link #rowStarting}); one the export anchors in them after that, at their section's end — a
+ * shape of the table's first page standing beside a header's text, or one a page with no other
+ * paragraph to carry it leaves in its first cell's — is that page's, and stays in the first
+ * part.
+ *
* @param document the document the panel is in the body of
* @param panel the panel
* @param table the table in its cell
@@ -145,6 +154,7 @@ static PanelPart splitInItsPanel(XWPFDocument document, XWPFTable panel, XWPFTab
remove(continued, after.get(index));
}
keepTheRowsFrom(rest, row);
+ dropTheDrawings(rest, headerRows(rest));
try (XmlCursor before = second.getCTTbl().newCursor()) {
XWPFParagraph between = document.insertNewParagraph(before);
between.setPageBreak(true);
@@ -193,6 +203,19 @@ private static void keepTheRowsFrom(XWPFTable table, int row) {
}
}
+ /** Takes every drawing out of a table's first rows. */
+ private static void dropTheDrawings(XWPFTable table, int rows) {
+ for (int row = 0; row < rows; row++) {
+ for (String element : DRAWINGS) {
+ for (org.apache.xmlbeans.XmlObject found : table.getRow(row).getCtRow().selectPath(W + ".//w:" + element)) {
+ try (XmlCursor cursor = found.newCursor()) {
+ cursor.removeXml();
+ }
+ }
+ }
+ }
+ }
+
/** Takes a paragraph or a table out of a cell. */
private static void remove(XWPFTableCell cell, IBodyElement element) {
if (element instanceof XWPFParagraph paragraph) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
index cde94f8bc..6b57a178e 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
@@ -3,6 +3,7 @@
import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.api.MultiSectionDocument;
+import com.demcha.compose.document.api.PageMarginRule;
import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.dsl.TableBuilder;
@@ -24,6 +25,7 @@
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar;
import java.io.ByteArrayInputStream;
@@ -52,6 +54,12 @@ class DocxPanelOverPagesTest {
private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148);
private static final String NOTE = "the layout breaks it over pages inside its table";
+ private static final double PAGE_HEIGHT = 300;
+ // Other top and bottom margins than the document's 20pt, page 2's alone and from a page on, at
+ // which a 30-row card's table is cut where each of its four pages starts.
+ private static final DocumentInsets MIDDLE = new DocumentInsets(35, 20, 44, 20);
+ private static final DocumentInsets LATER = new DocumentInsets(45, 20, 34, 20);
+ private static final List LATER_TWIPS = List.of(900L, 680L);
@Test
void aPanelWhoseTableRunsOverAPageIsATableAPageCutAtTheRowsThePagesStartWith() throws Exception {
@@ -328,6 +336,273 @@ void aPanelInALaterSectionNamesItsPagesAsItsSectionCountsThem() throws Exception
assertThat(cutNote(report.get())).contains("cut at the row section 2, page 2 starts with");
}
+ @Test
+ void aPageWithOtherMarginsStartingAtACutEndsAWordSectionInTheParagraphBetweenTheParts() throws Exception {
+ // Measured in Word and LibreOffice: with one set of margins, a top margin 54pt larger from
+ // page 2 on stood every line of pages 2 and 3 52.9pt high; ended at the cut, line for line.
+ Exported exported = export(PAGE_HEIGHT, List.of(PageMarginRule.from(2, LATER)), card(30, true, null, null));
+ XWPFDocument document = exported.document();
+
+ List parts = parts(document);
+ assertThat(exported.pages()).as("the premise: three pages at least").hasSizeGreaterThanOrEqualTo(3);
+ assertThat(rowsOf(parts)).as("cut as without the margins").isEqualTo(exported.pages());
+ XWPFParagraph first = between(document, parts.get(0));
+ assertThat(endingASection(document)).containsExactly(first);
+ assertThat(first.isPageBreak()).as("the section's own break opens page 2").isFalse();
+ assertThat(first.getCTP().getPPr().getSpacing().getLine().toString()).isEqualTo("2");
+ assertThat(topAndBottom(first.getCTP().getPPr().getSectPr())).as("page 1's").containsExactly(400L, 400L);
+ assertThat(topAndBottom(body(document))).as("page 2's on").isEqualTo(LATER_TWIPS);
+ XWPFParagraph second = between(document, parts.get(1));
+ assertThat(second.isPageBreak()).as("page 3's margins are page 2's: the page breaks there").isTrue();
+ assertThat(marginNotes(exported.report())).singleElement().satisfies(note -> {
+ assertThat(note.path()).endsWith("SectionNode[0]");
+ assertThat(note.detail()).isEqualTo("page 2's top and bottom margins differ from page 1's: written as a "
+ + "Word section that starts where the panel is cut at the row of its "
+ + "table page 2 starts with, in the paragraph between the two parts");
+ });
+ }
+
+ @Test
+ void eachCutWhereTheMarginsChangeEndsASectionOfItsOwn() throws Exception {
+ Exported exported = export(PAGE_HEIGHT, List.of(PageMarginRule.page(2, MIDDLE), PageMarginRule.from(3, LATER)),
+ card(30, true, null, null));
+ XWPFDocument document = exported.document();
+
+ List parts = parts(document);
+ assertThat(exported.pages()).as("the premise: three pages at least").hasSizeGreaterThanOrEqualTo(3);
+ XWPFParagraph first = between(document, parts.get(0));
+ XWPFParagraph second = between(document, parts.get(1));
+ assertThat(endingASection(document)).containsExactly(first, second);
+ assertThat(second.isPageBreak()).isFalse();
+ assertThat(topAndBottom(first.getCTP().getPPr().getSectPr())).as("page 1's").containsExactly(400L, 400L);
+ assertThat(topAndBottom(second.getCTP().getPPr().getSectPr())).as("page 2's").containsExactly(700L, 880L);
+ assertThat(topAndBottom(body(document))).as("page 3's on").isEqualTo(LATER_TWIPS);
+ assertThat(marginNotes(exported.report())).extracting(DocxExportReport.Note::detail)
+ .satisfiesExactly(
+ page2 -> assertThat(page2).startsWith("page 2's top and bottom margins differ from page 1's: "
+ + "written as a Word section"),
+ page3 -> assertThat(page3).startsWith("page 3's top and bottom margins differ from page 2's: "
+ + "written as a Word section"));
+ }
+
+ @Test
+ void aCutAfterOneOnPagesOfTheSameMarginsEndsTheSectionWhereTheyChange() throws Exception {
+ Exported exported = export(PAGE_HEIGHT, List.of(PageMarginRule.from(3, LATER)), card(30, true, null, null));
+ XWPFDocument document = exported.document();
+
+ List parts = parts(document);
+ assertThat(exported.pages()).as("the premise: three pages at least").hasSizeGreaterThanOrEqualTo(3);
+ XWPFParagraph first = between(document, parts.get(0));
+ XWPFParagraph second = between(document, parts.get(1));
+ assertThat(first.isPageBreak()).isTrue();
+ assertThat(endingASection(document)).containsExactly(second);
+ assertThat(topAndBottom(second.getCTP().getPPr().getSectPr())).as("pages 1 and 2's").containsExactly(400L, 400L);
+ assertThat(topAndBottom(body(document))).isEqualTo(LATER_TWIPS);
+ }
+
+ @Test
+ void aCutWhosePageAboveLeavesNoRoomForTheParagraphEndingASectionIsMadeAndTheMarginsNamed() throws Exception {
+ // The paragraph ending a section stands under the part on the page above, where a break before
+ // it put it on the page after: where page 1's last row leaves room for the part's bottom border
+ // and a point, and not for that paragraph too, Word would set it on a page of its own.
+ double gap = gapLeavingRoomForTheCutAndNotTheSection();
+ Exported exported = export(PAGE_HEIGHT, List.of(PageMarginRule.from(2, LATER)), page -> {
+ page.addSpacer(spacer -> spacer.height(gap));
+ card(30, true, null, null).accept(page);
+ });
+ XWPFDocument document = exported.document();
+
+ List parts = parts(document);
+ assertThat(parts).hasSizeGreaterThanOrEqualTo(2);
+ XWPFParagraph first = between(document, parts.get(0));
+ assertThat(first.isPageBreak()).as("cut where page 2 starts, its page broken before the paragraph").isTrue();
+ assertThat(first.getCTP().getPPr().isSetSectPr()).isFalse();
+ assertThat(marginNotes(exported.report())).extracting(DocxExportReport.Note::detail).first().asString()
+ .isEqualTo("page 2's top and bottom margins differ from page 1's, which Word sets it with: page 1's rows "
+ + "reach too near its foot for the paragraph that would end a Word section between the parts "
+ + "the panel is cut into there, which would open a page of its own");
+ }
+
+ @Test
+ void theRoomAboveACutIsReckonedAgainstTheBottomMarginWordSetsThatPageWith() throws Exception {
+ // Page 2's own section is refused, so Word sets page 2 with page 1's 20pt foot rather than the
+ // 4pt one the page sets it with: under that, its rows leave no room for page 3's section.
+ List margins = List.of(PageMarginRule.page(2, new DocumentInsets(20, 20, 4, 20)),
+ PageMarginRule.from(3, LATER));
+ double gap = gapLeavingRoomForTheCutAndNotTheSection(margins);
+ Consumer content = page -> {
+ page.addSpacer(spacer -> spacer.height(gap));
+ card(30, true, null, null).accept(page);
+ };
+ try (DocumentSession session = session(PAGE_HEIGHT, margins, content)) {
+ DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph());
+ TableNode table = first(session.documentGraph().roots(), TableNode.class);
+ double rowsEnd = layout.lowestRowOn(table, 1);
+ assertThat(rowsEnd - 4).as("the premise: room enough above page 2's own foot").isGreaterThan(3.2);
+ assertThat(rowsEnd - 20).as("the premise: and not above page 1's").isLessThan(3.2);
+ }
+ Exported exported = export(PAGE_HEIGHT, margins, content);
+
+ assertThat(endingASection(exported.document())).isEmpty();
+ assertThat(marginNotes(exported.report())).extracting(DocxExportReport.Note::detail)
+ .anySatisfy(note -> assertThat(note).startsWith("page 3's top and bottom margins differ from page 1's, "
+ + "which Word sets it with: page 2's rows reach too near "
+ + "its foot"));
+ }
+
+ @Test
+ void aPanelInALaterSectionOfSeveralEndsAWordSectionAtItsCut() throws Exception {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ try (MultiSectionDocument sections = GraphCompose.documents()
+ .section(session(page -> page.addParagraph("First section")))
+ .section(session(PAGE_HEIGHT, List.of(PageMarginRule.from(2, LATER)), card(30, true, null, null)))
+ .create()) {
+ docx = sections.export(DocxSemanticBackend.builder().reportSink(report::set).build());
+ }
+ XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx));
+
+ XWPFParagraph first = between(document, parts(document).get(0));
+ List ending = endingASection(document);
+ assertThat(ending).as("the first section's end, then the card's first cut").hasSize(2).last().isSameAs(first);
+ assertThat(topAndBottom(first.getCTP().getPPr().getSectPr())).containsExactly(400L, 400L);
+ assertThat(topAndBottom(body(document))).isEqualTo(LATER_TWIPS);
+ assertThat(marginNotes(report.get())).singleElement().asString()
+ .contains("written as a Word section that starts where the panel is cut");
+ }
+
+ @Test
+ void aCardCutNowhereLeavesThePagesItStartsInsideNamed() throws Exception {
+ // An anchored card's bookmark opens in its table's header row: the table is split nowhere.
+ Exported exported = export(PAGE_HEIGHT, List.of(PageMarginRule.from(2, LATER)), page -> page.addSection(card -> card
+ .anchor("items").fillColor(SURFACE).stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6))
+ .addTable(table -> rows(table, 30, true))));
+
+ assertThat(endingASection(exported.document())).isEmpty();
+ assertThat(marginNotes(exported.report())).singleElement().asString()
+ .endsWith("each starts inside this block, where the export starts no Word section");
+ }
+
+ @Test
+ void aPageReferenceSettledAsItsNumberLetsItsPageBeCutWhereTheMarginsDoNotVary() throws Exception {
+ // Its anchor marks a block of drawing only, which gets no bookmark: once the document is written
+ // the field is taken back to its number, and the cut, decided then, sees no field in the rows.
+ Exported exported = export(PAGE_HEIGHT, List.of(), cardReferringToADrawing());
+
+ assertThat(exported.pages()).as("the premise: three pages").hasSize(3);
+ assertThat(exported.pages().get(1)).as("the premise: row 15 on page 2").contains("Row 15");
+ assertThat(parts(exported.document())).hasSize(3);
+ }
+
+ @Test
+ void whereTheMarginsVaryTheCutsAreDecidedWhileTheirPageReferencesAreStillFields() throws Exception {
+ // A section's margins are planned once it is written, before the references are settled: the
+ // row holding one is a field then, and its page is left whole and named, as for any field.
+ Exported exported = export(PAGE_HEIGHT, List.of(PageMarginRule.from(2, LATER)), cardReferringToADrawing());
+
+ int page = 0;
+ while (!exported.pages().get(page).contains("Row 15")) {
+ page++;
+ }
+ assertThat(page).as("the premise: row 15 on a page the card runs onto").isPositive();
+ assertThat(cutNote(exported.report())).contains("it is not cut where page " + (page + 1)
+ + " starts, at a row its table cannot be split at");
+ }
+
+ @Test
+ void aPanelOfAnEarlierSectionWhoseMarginsDoNotVaryIsDecidedOnceTheDocumentIsWritten() throws Exception {
+ // Only the panels of the section just written are decided with its margins: one of a section
+ // before it, of one set of margins, waits for its page reference to be settled.
+ byte[] docx;
+ try (MultiSectionDocument sections = GraphCompose.documents()
+ .section(session(PAGE_HEIGHT, List.of(), cardReferringToADrawing()))
+ .section(session(PAGE_HEIGHT, List.of(PageMarginRule.from(2, LATER)), card(30, true, null, null)))
+ .create()) {
+ docx = sections.export(new DocxSemanticBackend());
+ }
+ XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx));
+
+ List> rows = rowsOf(parts(document));
+ assertThat(rows.stream().filter(part -> part.contains("Row 15") && part.contains("Row 1")))
+ .as("the first card is cut where its page 2 starts, though row 15 held a field").isEmpty();
+ assertThat(rows.stream().filter(part -> part.contains("Item")).count())
+ .as("three parts of the first card, four of the second").isEqualTo(7);
+ }
+
+ @Test
+ void aPanelOfASectionCountingItsOwnPagesIsDecidedOnceTheDocumentIsWritten() throws Exception {
+ // Word would count a section of its own apart, so this one keeps one set of margins: its card
+ // is cut where each page starts, row 15's page too once its reference is settled as a number.
+ DocumentSession body = session(PAGE_HEIGHT, List.of(PageMarginRule.from(2, LATER)), cardReferringToADrawing());
+ body.chrome().zone(com.demcha.compose.document.output.DocumentPageZone.footer(20, page ->
+ new com.demcha.compose.document.dsl.RowBuilder().flexSpacer().add(page.pageNumber())
+ .add(page.pageTotal()).build()));
+ byte[] docx;
+ try (MultiSectionDocument sections = GraphCompose.documents()
+ .section(session(page -> page.addParagraph("Cover"))).section(body).create()) {
+ docx = sections.export(new DocxSemanticBackend());
+ }
+ XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx));
+
+ assertThat(endingASection(document)).as("the cover's section ends; the body's does not split").hasSize(1);
+ assertThat(rowsOf(parts(document))).as("row 15 in a part of its own page's rows")
+ .noneMatch(part -> part.contains("Row 15") && part.contains("Row 1"))
+ .anyMatch(part -> part.contains("Row 15"));
+ }
+
+ /**
+ * A card whose table's row 15 holds a page reference to the anchor of a block of drawing only,
+ * which the export writes no bookmark for.
+ */
+ private static Consumer cardReferringToADrawing() {
+ return page -> page
+ .addSection(drawn -> drawn.anchor("drawn").add(new com.demcha.compose.document.dsl.ShapeBuilder()
+ .size(120, 20).fillColor(DocumentColor.LIGHT_GRAY).build()))
+ .addSection(card -> card.fillColor(SURFACE).stroke(DocumentStroke.of(INK, 1))
+ .padding(DocumentInsets.of(6))
+ .addTable(table -> {
+ table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100))
+ .header("Item", "Amount").repeatHeader();
+ for (int row = 1; row <= 30; row++) {
+ table.rowCells(DocumentTableCell.text("Row " + row), row == 15
+ ? DocumentTableCell.node(new PageReferenceNode("drawn",
+ DocumentTextStyle.DEFAULT, TextAlign.LEFT))
+ : DocumentTableCell.text(String.valueOf(row)));
+ }
+ }));
+ }
+
+ /**
+ * A gap above a 30-row card, with page 2's margins {@link #LATER}, that leaves its table's last row
+ * on page 1 room above the page's foot for the first part's 1pt bottom border and a point, and not
+ * for the 0.1pt paragraph ending a section under it too, found by laying the page out.
+ */
+ private static double gapLeavingRoomForTheCutAndNotTheSection() {
+ return gapLeavingRoomForTheCutAndNotTheSection(List.of(PageMarginRule.from(2, LATER)));
+ }
+
+ /** {@link #gapLeavingRoomForTheCutAndNotTheSection()}, the pages set against some margins. */
+ private static double gapLeavingRoomForTheCutAndNotTheSection(List margins) {
+ double gap = 0;
+ for (int tries = 0; tries < 200; tries++) {
+ double height = gap;
+ try (DocumentSession session = session(PAGE_HEIGHT, margins, page -> {
+ page.addSpacer(spacer -> spacer.height(height));
+ card(30, true, null, null).accept(page);
+ })) {
+ DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph());
+ TableNode table = first(session.documentGraph().roots(), TableNode.class);
+ double room = layout.lowestRowOn(table, 0) - layout.marginOf(0).bottom();
+ if (room >= 2.0 && room < 2.1) {
+ return gap;
+ }
+ // Moved down by the gap, the rows keep their page while room is left: aim at 2.05.
+ gap += room > 2.05 ? Math.min(room - 2.05, 4) : 0.5;
+ }
+ }
+ throw new AssertionError("no gap leaves room for the cut and not the section");
+ }
+
@Test
void aPanelWhoseTableFitsItsPageIsOneTable() throws Exception {
Exported exported = export(card(8, true, null, null));
@@ -422,8 +697,16 @@ private static DocumentSession session(Consumer content) {
}
private static DocumentSession session(double pageHeight, Consumer content) {
+ return session(pageHeight, List.of(), content);
+ }
+
+ private static DocumentSession session(double pageHeight, List margins,
+ Consumer content) {
DocumentSession session = GraphCompose.document().pageSize(400, pageHeight).margin(DocumentInsets.of(20))
.create();
+ if (!margins.isEmpty()) {
+ session.pageMargins(margins);
+ }
session.pageFlow(content::accept);
return session;
}
@@ -433,8 +716,13 @@ private static Exported export(Consumer content) throws Excepti
}
private static Exported export(double pageHeight, Consumer content) throws Exception {
+ return export(pageHeight, List.of(), content);
+ }
+
+ private static Exported export(double pageHeight, List margins, Consumer content)
+ throws Exception {
AtomicReference report = new AtomicReference<>();
- try (DocumentSession session = session(pageHeight, content)) {
+ try (DocumentSession session = session(pageHeight, margins, content)) {
byte[] docx = session.export(new DocxSemanticBackend(report::set));
DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph());
List> pages = new ArrayList<>();
@@ -473,6 +761,33 @@ private static String cutNote(DocxExportReport report) {
return report.notes().stream().filter(note -> note.detail().contains(NOTE)).findFirst().orElseThrow().detail();
}
+ private static List marginNotes(DocxExportReport report) {
+ return report.notes().stream().filter(note -> note.subject().equals(DocxPageMarginSections.PAGE_MARGIN))
+ .toList();
+ }
+
+ /** The paragraph right after a part of the panel, between it and the next. */
+ private static XWPFParagraph between(XWPFDocument document, XWPFTable part) {
+ List body = document.getBodyElements();
+ return (XWPFParagraph) body.get(body.indexOf(part) + 1);
+ }
+
+ private static List endingASection(XWPFDocument document) {
+ return document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetSectPr())
+ .toList();
+ }
+
+ private static CTSectPr body(XWPFDocument document) {
+ return document.getDocument().getBody().getSectPr();
+ }
+
+ /** A section's top and bottom margins, in twips. */
+ private static List topAndBottom(CTSectPr section) {
+ return List.of(((java.math.BigInteger) section.getPgMar().getTop()).longValue(),
+ ((java.math.BigInteger) section.getPgMar().getBottom()).longValue());
+ }
+
/** The panel's tables in the body, those that hold a table in their one cell. */
private static List parts(XWPFDocument document) {
return document.getTables().stream()
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
index 7a5ddc360..606cb9267 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
@@ -5,6 +5,7 @@
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
+import org.apache.xmlbeans.XmlCursor;
import org.junit.jupiter.api.Test;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge;
@@ -96,6 +97,37 @@ void aTableIsNotSplitWhereAHeaderRowHoldsWhatTheFileHoldsOnce() throws Exception
}
}
+ @Test
+ void aPanelsPartAfterACutCopiesItsHeaderRowsWithoutTheirDrawings() throws Exception {
+ // A shape the export anchors in a header row once a cut is decided is the table's first
+ // page's: the first part keeps it, and the copy of the row opening the next part has none.
+ try (XWPFDocument written = new XWPFDocument()) {
+ XWPFTable items = table(written, 1, 4);
+ items.getRow(0).getCell(0).getParagraphs().get(0).createRun().getCTR().addNewDrawing();
+ XWPFTable outer = written.createTable(1, 1);
+ try (XmlCursor at = outer.getRow(0).getCell(0).getParagraphArray(0).getCTP().newCursor();
+ XmlCursor table = items.getCTTbl().newCursor()) {
+ table.moveXml(at);
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(bytes(written)))) {
+ XWPFTable panel = document.getTables().get(0);
+ XWPFTable inner = panel.getRow(0).getCell(0).getTables().get(0);
+
+ DocxTableSplit.PanelPart part = DocxTableSplit.splitInItsPanel(document, panel, inner, 3);
+
+ XWPFTable rest = part.second().getRow(0).getCell(0).getTables().get(0);
+ assertThat(texts(rest)).containsExactly("Header", "Row 3", "Row 4");
+ assertThat(drawings(rest.getRow(0))).as("the copy of the header row").isZero();
+ assertThat(drawings(inner.getRow(0))).as("the first part's header row").isEqualTo(1);
+ }
+ }
+ }
+
+ private static int drawings(XWPFTableRow row) {
+ return row.getCtRow().selectPath(
+ "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' .//w:drawing").length;
+ }
+
@Test
void aTableIsNotSplitWhereAHeaderRowHoldsANumberedItem() throws Exception {
// Word numbers the copy on from the original: the page's "1." would read "2." there.
From 5442b39609051b57460df6a5e48305d72770429b Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 25 Oct 2026 18:59:05 +0000
Subject: [PATCH 2/2] fix(docx): say which pages a field keeps a cut panel
whole at
A row holding a page reference leaves a panel uncut at that row's page
and every page before it, and so does a header row holding a bookmark
the export later takes out; the recipe, the matrix and the CHANGELOG now
say so. The CHANGELOG states how ObsidianInvoice's twelve-fold line
items stood before. NOT_REPEATED is built from DRAWINGS, and a test
writes a section at a body table's split and one at a panel's cut in
one document section.
---
CHANGELOG.md | 16 ++++++-----
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 27 ++++++++++---------
.../semantic/docx/DocxSemanticBackend.java | 8 +++---
.../backend/semantic/docx/DocxTableSplit.java | 11 +++++---
.../semantic/docx/DocxPanelOverPagesTest.java | 23 ++++++++++++++++
6 files changed, 61 insertions(+), 26 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c182e7d3..33ec90e2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,14 +29,18 @@ follow semantic versioning; release dates are ISO 8601.
as before. A panel in a section whose pages share one set of margins, or whose header or footer
counts its own pages, is cut as before.
- In a section whose margins change, a panel's cuts are decided once the section is written,
- before the document's page references are settled: a row holding a page reference keeps its
- page whole, as any field does, though one whose anchor has no bookmark ends up as its number.
- A drawing the export anchors in the table's header row after that stays in the first part, the
- page it is drawn on, and the part after a cut opens with a copy of the row without it.
+ before the document's page references are settled and its bookmarks kept: a row holding a page
+ reference leaves the panel uncut at that row's page and every page before it, as any field
+ does, though one whose anchor has no bookmark ends up as its number; so does a header row
+ holding a bookmark the export later takes out. A drawing the export anchors in the table's
+ header row after that stays in the first part, the page it is drawn on, and the part after a
+ cut opens with a copy of the row without it.
- With its line items repeated 3 to 11 times `ObsidianInvoice` sets every line on its page in
Word and LibreOffice 24.2, as it did. Repeated 12 times, its page 5 starts between its info
- cards and its footer divider, a page break the editors now place themselves, as page 5's
- margins are page 2's: Word sets the cards on page 5 with the divider, because `keepNext` on
+ cards and its footer divider. With page 1's margins in force on pages 2 to 4, page 5 opened a
+ Word section of its own, which fixed that break, and Word held the cards on page 4; page 5's
+ margins are now page 2's, so the editors place that break themselves: Word sets the cards on
+ page 5 with the divider, because `keepNext` on
the paragraphs inside the cards' cell keeps their table with the paragraph after it, and both
editors drop the divider's 13pt of space above it at a page they break themselves. The same
invoice with one set of margins on every page is set the same way in both editors.
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 971a0736b..657a44ad4 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -133,7 +133,7 @@ honour an option ignores it (documented contract).
| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` | ❌ (throws with a pointer to the PDF backend — POI's slide rasterizer cannot honour embedded fonts, and the PDF raster of the same graph is the canonical image output) | n/a (a `FixedLayoutRenderer` surface; the semantic SPI has no raster output) |
| Raster-slide mode — every page as one full-slide picture, pixel-exact to the PDF/PNG output (`Builder.rasterSlides(dpi)`) | n/a (the PDF raster is the source) | ✅ `PptxFixedLayoutBackend` | n/a (a fixed-layout backend builder option) |
| Multi-section documents (`renderSections`, per-section chrome, cross-section links) | ✅ `buildSectionsDocument` in `PdfFixedLayoutBackend` | ⚠️ `renderSections` in `PptxFixedLayoutBackend` (a deck carries one slide size, so every section must share the same page canvas — differing sizes throw) | ✅ `DocxSemanticBackend.exportSections`, reached through `MultiSectionDocument.export` / `toDocxBytes` — a Word section per section, each with its own page size, orientation, margins and page-zone header and footer (the text header and footer slots are written per section, their page total as `SECTIONPAGES`). Page numbers restart at 1 in every section and a zone's total is `SECTIONPAGES`, as the PDF counts; a section with no zone of its own gets an empty header or footer rather than Word's inherited one; styles, fonts and bookmark names are shared, so links cross sections. With different even and odd pages stated, Word starts every section on an odd page and adds a blank page before a section whose section before it runs to an odd number of pages, which the report names (LibreOffice adds none) |
-| Per-page margins (`DocumentSession.pageMargins`) | ✅ laid out page by page (`PageGeometry`) | ✅ drawn from the layout | ⚠️ `DocxPageMarginSections` — the first page's top and bottom margins are the section's; a page set against other top and bottom margins than Word sets it with starts a Word section with them where it starts between blocks of the body, an explicit page break there becoming the section's break and a hairline between two tables carrying its end, the title-page part kept to the first section, as is the page count from 1 in a document of several sections — measured in Word and LibreOffice on Windows, on pages of paragraphs, after a page break, after a table, between two tables and under a card's held margin, every page where the layout sets it, line for line, where one set of margins ran a page with a larger bottom margin on past its foot. Each section is named (`page margin`): its page break is fixed when the text above it is edited. Side margins stay the document's, which the body is written against and the page sets its header and footer from; a page whose side margins differ is named. A page that starts among a body table's rows splits the table at that row, the second part opening with copies of the header rows the layout repeats, the hairline between the parts ending the section — measured in both editors, every page holds the lines the layout sets on it; the split is named on the table. A page that starts where a panel is cut at the row of its table the page starts with (`DocxTableSplit.splitInItsPanel`) ends the section in the paragraph between the two parts, which then breaks no page before itself — measured in both editors on a card over three pages with other margins from page 2, every line on its page, lower by the cut's top border only; where the page above has room for the cut and not for that paragraph under the part, the page is named. In a section whose margins change, the cuts are decided once the section is written, before page references are settled: a row holding one keeps its page whole, as any field does. LibreOffice 24.2 reads no section's margins after a table it broke over a page itself (a page the panel runs onto not cut, a later one cut); Word does. A page that starts inside another block (a paragraph's lines, a panel not cut there), at a table's row a cell spans rows across or only the header rows stand above, under a header row holding a bookmark, a drawing or a numbered list item, where a header row or a row that would move holds a page reference, after a page whose content reaches its foot under a table or in its rows, or in a document of sections whose header or footer counts its section's pages starts none and is named, Word setting it with the margins in force |
+| Per-page margins (`DocumentSession.pageMargins`) | ✅ laid out page by page (`PageGeometry`) | ✅ drawn from the layout | ⚠️ `DocxPageMarginSections` — the first page's top and bottom margins are the section's; a page set against other top and bottom margins than Word sets it with starts a Word section with them where it starts between blocks of the body, an explicit page break there becoming the section's break and a hairline between two tables carrying its end, the title-page part kept to the first section, as is the page count from 1 in a document of several sections — measured in Word and LibreOffice on Windows, on pages of paragraphs, after a page break, after a table, between two tables and under a card's held margin, every page where the layout sets it, line for line, where one set of margins ran a page with a larger bottom margin on past its foot. Each section is named (`page margin`): its page break is fixed when the text above it is edited. Side margins stay the document's, which the body is written against and the page sets its header and footer from; a page whose side margins differ is named. A page that starts among a body table's rows splits the table at that row, the second part opening with copies of the header rows the layout repeats, the hairline between the parts ending the section — measured in both editors, every page holds the lines the layout sets on it; the split is named on the table. A page that starts where a panel is cut at the row of its table the page starts with (`DocxTableSplit.splitInItsPanel`) ends the section in the paragraph between the two parts, which then breaks no page before itself — measured in both editors on a card over three pages with other margins from page 2, every line on its page, lower by the cut's top border only; where the page above has room for the cut and not for that paragraph under the part, the page is named. In a section whose margins change, the cuts are decided once the section is written, before page references are settled and bookmarks kept: a row holding a page reference leaves the panel uncut at its page and every page before it, as any field does, and so does a header row holding a bookmark the export later takes out. LibreOffice 24.2 reads no section's margins after a table it broke over a page itself (a page the panel runs onto not cut, a later one cut); Word does. A page that starts inside another block (a paragraph's lines, a panel not cut there), at a table's row a cell spans rows across or only the header rows stand above, under a header row holding a bookmark, a drawing or a numbered list item, where a header row or a row that would move holds a page reference, after a page whose content reaches its foot under a table or in its rows, or in a document of sections whose header or footer counts its section's pages starts none and is named, Word setting it with the margins in force |
| Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | ✅ `PptxDeterminismWriter` (pinned OPC created/modified + zip entry-time normalization) | ✅ `DocxDeterminism` (`DocxSemanticBackend.builder().deterministic(true)` or `deterministic(Instant)`: pinned OPC created/modified + zip entry-time normalization; an embedded font's obfuscation key is derived from the font, so it needs no pinning) |
| ServiceLoader discovery (`FixedLayoutBackendProvider`) | ✅ `PdfFixedLayoutBackendProvider` (`format() == "pdf"`) | ✅ `PptxFixedLayoutBackendProvider` (`format() == "pptx"`) | n/a (semantic SPI: `SemanticBackend`) |
| `DocumentSession` convenience methods | ✅ `buildPdf` / `writePdf` / `toPdfBytes` / `toImages` | ✅ `buildPptx` / `writePptx` / `toPptxBytes` (resolved via `BackendProviders.fixedLayout("pptx")`; session chrome applies; fails with `MissingBackendException` naming `graph-compose-render-pptx` when the backend is absent) | ✅ `session.export(new DocxSemanticBackend(...), target)` — the semantic backend is named directly rather than discovered |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 3a2d7625a..ffccc558a 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -196,16 +196,17 @@ on it. The report names the split on the table (`page margin`).
A page that starts inside a panel the export cuts at the row of its table that page starts with
(*Over pages*, under *What a panel keeps and loses*) ends the section in the paragraph between the
-two parts. That
-paragraph then breaks no page before itself, as the section's own break opens the page, and
-stands under the part above it, so the page above must leave room for it under its last row, above
-the bottom margin Word sets that page with, as well as for the borders the cut keeps there; where
-the page has room for the cut and not for that paragraph as well, the panel is cut as before and
-the page is named. In a section whose margins change, a panel's cuts are decided once the section
-is written, before the document's page references are settled: a row holding a page reference
-keeps its page whole, as any field does, though one whose anchor has no bookmark ends up as its
-number. A section whose header or footer counts its own pages starts no Word section for a page's
-margins, and its panels are cut as in a section of one set of margins. Measured in Word 16 and LibreOffice 24.2 on a card whose table runs over three
+two parts. That paragraph then breaks no page before itself, as the section's own break opens the
+page, and stands under the part above it, so the page above must leave room for it under its last
+row, above the bottom margin Word sets that page with, as well as for the borders the cut keeps
+there; where the page has room for the cut and not for that paragraph as well, the panel is cut as
+before and the page is named. In a section whose margins change, a panel's cuts are decided once
+the section is written, before the document's page references are settled and its bookmarks kept:
+a row holding a page reference leaves the panel uncut at that row's page and every page before it,
+as any field does, though one whose anchor has no bookmark ends up as its number; so does a header
+row holding a bookmark the export later takes out. A section whose header or footer counts its own
+pages starts no Word section for a page's margins, and its panels are cut as in a section of one
+set of margins. Measured in Word 16 and LibreOffice 24.2 on a card whose table runs over three
pages: with one set of margins, a top margin 54pt larger from page 2 on stood every line of pages
2 and 3 52.9pt high, and margins of 20pt from page 2 on ran the card onto five pages in Word and
four in LibreOffice; with the section at the cut, every line stands on the page the page sets it
@@ -220,7 +221,8 @@ is, LibreOffice 24.2 sets the part after that cut with the earlier section's mar
section's margins after a table it broke over a page itself. Word reads them.
A page that starts inside any other block — the lines of a paragraph, a panel not cut there —
-does not start a section. Nor does a page that starts at a table's row where a cell spans rows across it, where
+does not start a section. Nor does a page that starts at a table's row where a cell spans rows
+across it, where
only the header rows stand above it, or where a header row holds a bookmark or a drawing, which
the file can hold only once, or a numbered list item, which its copy would number on. Nor where a header row or a row from that one on holds a page
reference, which the export settles in the paragraph it wrote it in once the body is written. A
@@ -767,7 +769,8 @@ How it lands:
below, as the page sets the rows down to the page's foot, and a hairline paragraph between the
parts breaking the page before itself, which adds no page where the part above fills its own;
where the page after the cut has other top and bottom margins, that paragraph ends a Word
- section instead (*Margins that change from page to page*, above). Written as one table, Word broke the panel's row where its lines ran out and repeated no header
+ section instead (*Margins that change from page to page*, above). Written as one table, Word
+ broke the panel's row where its lines ran out and repeated no header
row of the table nested in its cell (measured in Word 16 and LibreOffice 24.2:
`ObsidianInvoice`'s and `SubscriptionInvoice`'s line items over two pages stood 24 to 30pt
high in Word on the second, its header missing, and over three pages a row stood a page early
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index d959bb6e4..6794ab191 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -7180,7 +7180,9 @@ private void noteATableOverPages(DocumentNode node, XWPFTable outer) {
* a page of its own, the part after it on the next.
*
* Done once the document is written — its drawings anchored, its page references settled —
- * so that a part's copy carries what the writing put in it. A panel's own section's layout
+ * so that a part's copy carries what the writing put in it, but for a drawing anchored in the
+ * table's header rows after its cuts were decided, which is the first page's and stays in the
+ * first part ({@link DocxTableSplit#splitInItsPanel}). A panel's own section's layout
* reads its pages: each section is laid out on its own. A panel in a section whose pages are set
* against more than one set of margins was decided on when that section was written
* ({@link #cutsOpeningSections}); where a page's margins start a Word section at a cut, the
@@ -7296,8 +7298,8 @@ private PanelCuts decideTheCuts(PanelOverPages over) {
*
*
Decided now, a cut reads its rows before the document's page references are settled and its
* bookmarks kept: a row holding a page reference is still a field, and a header row a bookmark
- * the export later takes out still holds it, so the page is not cut there, where once the document
- * is written it would be. A section whose header or footer counts its own pages starts no Word
+ * the export later takes out still holds it, so the panel is not cut at that row's page, nor at
+ * any before it, where once the document is written it would be. A section whose header or footer counts its own pages starts no Word
* section of its own for a page's margins, and its panels are decided once the document is
* written.
*/
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
index 03546b34f..63b69b031 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
@@ -14,6 +14,7 @@
import java.util.List;
import java.util.function.IntUnaryOperator;
+import java.util.stream.Stream;
/**
* Splits a table of the body in two at the row a page starts with, so that a Word section can
@@ -33,17 +34,19 @@ final class DocxTableSplit {
private static final String W =
"declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' ";
+ // A drawing, whose id names one drawing: a panel's copy of the header rows leaves it out
+ // (splitInItsPanel).
+ private static final List DRAWINGS = List.of("drawing", "pict", "object");
+
// What a header row's copy cannot hold again: a bookmark's name marks one place, a drawing's id
// names one drawing, and a list item's number is counted on from the item before it.
- private static final List NOT_REPEATED = List.of("bookmarkStart", "drawing", "pict", "object", "numPr");
+ private static final List NOT_REPEATED =
+ Stream.concat(Stream.of("bookmarkStart", "numPr"), DRAWINGS.stream()).toList();
// A field, which the export settles once the body is written, in the paragraph it wrote it in: a
// row moved to the second part leaves that paragraph behind, and a copy of one is never settled.
private static final List FIELDS = List.of("fldChar", "instrText", "fldSimple");
- // A drawing, which a panel's copy of the header rows leaves out (splitInItsPanel).
- private static final List DRAWINGS = List.of("drawing", "pict", "object");
-
private DocxTableSplit() {
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
index 6b57a178e..309ca1702 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
@@ -450,6 +450,29 @@ void theRoomAboveACutIsReckonedAgainstTheBottomMarginWordSetsThatPageWith() thro
+ "its foot"));
}
+ @Test
+ void aSectionEndingAtABodyTablesSplitAndOneAtAPanelsCutAreEachWrittenInPlace() throws Exception {
+ // Page 2 starts among a body table's rows, which is split there; page 3 inside the card.
+ Exported exported = export(PAGE_HEIGHT, List.of(PageMarginRule.page(2, MIDDLE), PageMarginRule.from(3, LATER)),
+ page -> {
+ page.addTable(table -> rows(table, 16, true)).addParagraph("Between");
+ card(30, true, null, null).accept(page);
+ });
+ XWPFDocument document = exported.document();
+
+ List tables = document.getTables();
+ List parts = parts(document);
+ assertThat(tables.get(0).getNumberOfRows()).as("the premise: the body table split where page 2 starts")
+ .isLessThan(1 + 16);
+ List ending = endingASection(document);
+ assertThat(ending).hasSize(2);
+ assertThat(ending.get(0)).as("between the body table's parts").isSameAs(between(document, tables.get(0)));
+ assertThat(ending.get(1)).as("between the card's first two parts").isSameAs(between(document, parts.get(0)));
+ assertThat(topAndBottom(ending.get(0).getCTP().getPPr().getSectPr())).containsExactly(400L, 400L);
+ assertThat(topAndBottom(ending.get(1).getCTP().getPPr().getSectPr())).containsExactly(700L, 880L);
+ assertThat(topAndBottom(body(document))).isEqualTo(LATER_TWIPS);
+ }
+
@Test
void aPanelInALaterSectionOfSeveralEndsAWordSectionAtItsCut() throws Exception {
AtomicReference report = new AtomicReference<>();