From cd0ae1032cd34e87180a11d5bf1f640a05144bfa Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 25 Oct 2026 15:22:54 +0000 Subject: [PATCH 1/3] fix(docx): cut a panel whose table runs 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 one table of one cell: Word broke its row where its lines ran out and repeats no header row of a table nested in a cell, so the line items after the break stood 24 to 30pt high with no header, and over three pages a row stood a page early. Once the document is written, a panel whose own table is the only child the layout breaks over pages is cut at the row each page starts with, its pages read off its own section's layout. The part after a cut is a copy of the panel holding the table's header rows, the rows from that row on and what follows the table, with no top padding; a part before a cut has no bottom padding; a hairline paragraph between the parts breaks the page before itself. The report names the cut, its pages, its fixed page breaks, any page left whole and the top border a part after a cut stands lower by. --- CHANGELOG.md | 23 ++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 20 + .../InvoicePanelOverPagesDocxTest.java | 92 +++++ .../semantic/docx/DocxSemanticBackend.java | 146 +++++++- .../backend/semantic/docx/DocxTableSplit.java | 100 ++++- .../semantic/docx/DocxPanelOverPagesTest.java | 345 ++++++++++++++++++ 7 files changed, 716 insertions(+), 12 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/invoice/presets/InvoicePanelOverPagesDocxTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6d04f40..e47ad510e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **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 + one table of one cell: Word broke its row where its lines ran out, and repeats no header row of + a table nested in a cell. Measured in Word 16 and LibreOffice 24.2 on `ObsidianInvoice` and + `SubscriptionInvoice` with their line items set over two and three pages, the second page + stood 24 to 30pt high in Word, its header missing, and over three pages a row stood a page + early in both editors. + - Such a panel — the table one of its own children, and the only one the layout breaks over + pages — is now a table a page, cut at the row each page starts with: the part after a cut + opens with copies of the table's header rows and has no top padding, a part before one has no + bottom padding, as the page sets the rows down to the page's foot, and a hairline paragraph + between the parts breaks the page before itself. In both invoices, and in cards padded 6 and + 12pt over two and three pages, every line now stands on the page the layout sets it on in both + editors. A part after a cut stands about its top border's width lower than the page sets it, + as Word and LibreOffice draw the border inside the row: 1.1pt under a 1pt border, 2.8pt under + `SubscriptionInvoice`'s ruled top. + - The page breaks are fixed there, so rows an edit adds above a cut no longer run onto the next + page, and the report names the cut and any page left whole. Anything else the layout moves or + breaks over a page inside a panel — a block kept together, a stack, a paragraph — is left to + Word, as before. + - No document of the corpus has such a panel; its export is unchanged. + - **A DOCX export writes no page break where the page breaks none: in a row's column, a table's composed cell, a layer stack's layer, a shape container's layer or a canvas.** - The page places each of these as a box on one page: a page break in one is a box of no height, diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 83749b1e5..7a7a3fac5 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 | +| 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 | | 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 diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 3a8a4e680..e3c41e2ec 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -735,6 +735,26 @@ How it lands: an anchor's wrapper inside a panel is not cut; the page breaks no page in an alignment either, and the export still writes that break — in its place in the body, after the table in a panel. +- **Over pages.** A panel in the body that the layout breaks over pages inside a table it holds + — one of its own children, and the only one it breaks over pages — is a table a page: cut at + 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 + 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 + in both; cut, every line stands on the page the layout sets it on, there and in cards padded 6 + and 12pt). The page breaks are fixed there, and the report names the cut; a part after a cut + stands about its top border's width lower than the page sets it, Word and LibreOffice drawing + the border inside the row where the page strokes it on the panel's edge (1.1pt under a 1pt + border, 2.8pt under `SubscriptionInvoice`'s ruled top). A table is not cut at a page whose + first row continues a merged cell, nor where a row from that one on holds a field or its header + rows hold a bookmark, a drawing, a numbered item or a field; the report names a page so left + whole. Anything else the layout moves or breaks over + a page inside a panel — a block kept together, a stack, a paragraph — is left to Word, a block + kept named as above. - **Measured.** Cards stacked in a row's columns, panels nested in its table, and a card set in from a filled panel's text stand where the page sets them in Word 16 and LibreOffice on Windows, as far as the left edge and the right end above say. 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 new file mode 100644 index 000000000..9d005b5d9 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/invoice/presets/InvoicePanelOverPagesDocxTest.java @@ -0,0 +1,92 @@ +package com.demcha.compose.document.templates.invoice.presets; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.templates.data.invoice.InvoiceServiceLines; +import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceData; +import org.apache.poi.xwpf.usermodel.IBodyElement; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An invoice whose line items the layout sets over pages inside a card writes the card as a table a + * page, each opening with the line items' header, as the page repeats it. + * + *

Measured in Word 16: written as one table, {@code ObsidianInvoice}'s and + * {@code SubscriptionInvoice}'s line items stood 24 to 30pt high on the second page, their header + * missing, and over three pages one row stood a page early, in LibreOffice 24.2 too.

+ */ +class InvoicePanelOverPagesDocxTest { + + @Test + void obsidiansLineItemsCardIsATableAPage() throws Exception { + assertCutAtItsPages(ObsidianInvoiceFixtures.invoice(), 3, (s, d) -> ObsidianInvoice.create().compose(s, d), + "LineItemsCard", "Description"); + assertCutAtItsPages(ObsidianInvoiceFixtures.invoice(), 6, (s, d) -> ObsidianInvoice.create().compose(s, d), + "LineItemsCard", "Description"); + } + + @Test + void subscriptionsRuledLineItemsAreATableAPage() throws Exception { + assertCutAtItsPages(SubscriptionInvoiceFixtures.invoice(), 6, + (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 { + 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(), + data.billTo(), data.shipTo(), data.summary(), + new InvoiceServiceLines(data.serviceLines().columns(), lines), data.totals(), data.payment(), + data.notes(), data.currencyCode()); + int pages; + byte[] docx; + try (DocumentSession session = GraphCompose.document().create()) { + preset.accept(session, longer); + PlacedNode placed = session.layoutGraph().nodes().stream() + .filter(node -> card.equals(node.semanticName())).findFirst().orElseThrow(); + pages = placed.endPage() - placed.startPage() + 1; + docx = session.export(new DocxSemanticBackend()); + } + assertThat(pages).as("the premise: the layout sets %s over pages", card).isGreaterThan(1); + 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)); + } + } + assertThat(parts).as("%s's parts after the first, %d lines", card, 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. + String first; + try (org.apache.xmlbeans.XmlCursor row = items.getRow(0).getCtRow().newCursor()) { + first = row.getTextValue(); + } + assertThat(first).as("its header first").contains(header); + } + } + } +} 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 4f1af3a30..06dc61a89 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 @@ -435,6 +435,11 @@ public final class DocxSemanticBackend implements SemanticBackend { // The part a panel's table being written holds of each container a page break cuts it at // (DocxPanelPieces), by node, taken by the container's writer. private final java.util.Map panelWindows = new java.util.IdentityHashMap<>(); + // The table each table node is written as (writeTableRows). + private final java.util.Map tablesWritten = new java.util.IdentityHashMap<>(); + // 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 the shapes this export draws are anchored — see DocxDrawingAnchors. private final DocxDrawingAnchors anchors = new DocxDrawingAnchors(() -> nextDrawingId++, paragraph -> grownLines.lineOf(paragraph)); @@ -1145,6 +1150,8 @@ private byte[] write(List sections, Path outputFile) throws Exc closingBlocks.clear(); keepsUnheld.clear(); followedInFlow.clear(); + tablesWritten.clear(); + panelsOverPages.clear(); slotDepth = 0; surfaceBehind = null; overlayDepth = 0; @@ -1329,6 +1336,7 @@ private byte[] write(List sections, Path outputFile) throws Exc nameTheBlocksNoBookmarkMarks(); keepTheLastBookmarkOfEachAnchor(document); settleThePageReferences(); + cutThePanelsAtTheirPages(document); hideTheClosingMark(document); hideTheCellClosingMarks(document.getTables()); letTheOneRowTablesKeptWithTheNextBreak(document.getTables()); @@ -7060,7 +7068,9 @@ && known && holdsWrappedText(node) * break runs through ({@link DocxPanelPieces}). A panel inside one so cut is written in the * piece of it each table holds. Inside a cell the break is not cut otherwise, and the panel is * written whole; so is it where the page breaks no page at a break — in a layer stack's layer, - * a shape container's layer or a canvas ({@link #breaksThePage}).

+ * a shape container's layer or a canvas ({@link #breaksThePage}). A panel written whole that the + * layout breaks over pages inside a table it holds is cut at those pages once the document is + * written ({@link #cutThePanelsAtTheirPages}).

*/ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint paint) throws Exception { DocxPanelPieces.Part window = panelWindows.remove(node); @@ -7072,6 +7082,7 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint } else { pieces = List.of(node.children().stream().map(DocxPanelPieces.Part::whole).toList()); } + int bodyTables = document.getTables().size(); for (int index = 0; index < pieces.size(); index++) { boolean onANewPage = false; if (index > 0) { @@ -7095,6 +7106,138 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint writePanelPiece(document, node, paint, () -> writeParts(document, piece, spacingOf(node), false, shape, DocxPanelPieces.followsABreak(piece, first)), first, last, onANewPage); } + if (window == null && pieces.size() == 1 && currentCell == null && inThePagedFlow() + && document.getTables().size() == bodyTables + 1) { + noteATableOverPages(node, document.getTables().get(bodyTables)); + } + } + + /** + * A panel written whole in the body, with a table among its own children the layout breaks + * over pages: a panel to cut at the rows those pages start with + * ({@link #cutThePanelsAtTheirPages}). + * + * @param panel the panel's node + * @param outer the table of one cell the panel is written as + * @param table the table among its children the layout breaks over pages + * @param inner the table that one is written as, in the panel's cell + * @param layout the layout of the section the panel is in + */ + private record PanelOverPages(DocumentNode panel, XWPFTable outer, TableNode table, XWPFTable inner, + DocxLayoutMetrics layout) { + } + + /** + * Notes a panel the layout breaks over pages inside a table it holds: one of its own children, + * the only one the layout breaks over pages, written in the panel's cell. + */ + private void noteATableOverPages(DocumentNode node, XWPFTable outer) { + if (layout.onOnePage(node) || layout.placement(node) == null) { + return; + } + TableNode over = null; + for (DocumentNode child : node.children()) { + if (layout.placement(child) == null || layout.onOnePage(child)) { + continue; + } + if (over != null || !(child instanceof TableNode table)) { + return; + } + over = table; + } + XWPFTable inner = over == null ? null : tablesWritten.get(over); + if (inner != null && outer.getRow(0).getCell(0).getBodyElements().contains(inner)) { + panelsOverPages.add(new PanelOverPages(node, outer, over, inner, layout)); + } + } + + /** + * Cuts each panel the layout breaks over pages inside the table it holds into a table a page, + * at the row of its table each page starts with, with a page break between. + * + *

The layout breaks such a table between rows, repeats its header rows at the top of the + * next page and goes on with the panel there, no padding above its text. Word breaks the + * panel's row where its lines run out instead, and repeats no header row of a table nested in a + * cell: measured in Word 16, the line items of {@code ObsidianInvoice} and + * {@code SubscriptionInvoice} set over two pages stood 24 to 30pt high on the second, its + * header missing, and over three one row stood a page early, in LibreOffice 24.2 too. Each part + * after a cut opens with copies of the table's header rows and has no top padding, and each part + * before one no bottom padding: the page sets the panel's last rows on a page down to its foot, + * and closes the panel only on its last page. A table that cannot be split at a page's row + * ({@link DocxTableSplit#rowStarting}) is left whole there.

+ * + *

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.

+ */ + private void cutThePanelsAtTheirPages(XWPFDocument document) { + for (PanelOverPages over : panelsOverPages) { + DocxLayoutMetrics itsLayout = over.layout(); + com.demcha.compose.document.layout.PlacedNode placed = itsLayout.placement(over.table()); + XWPFTable piece = over.outer(); + XWPFTable inner = over.inner(); + boolean header = DocxTableSplit.headerRows(inner) > 0; + // The table's rows the parts before the current one hold, after its header rows. + int before = 0; + List cut = new ArrayList<>(); + List left = new ArrayList<>(); + for (int page = placed.startPage() + 1; page <= placed.endPage(); page++) { + 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.add(page); + continue; + } + 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()); + 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); + } + if (!cut.isEmpty()) { + reportTheCut(over.panel(), itsLayout.pathOf(over.panel()), header, cut, left); + } + } + } + + /** + * Names a panel cut at its pages ({@link #cutThePanelsAtTheirPages}): the pages it is cut at, + * those it is not, its fixed page breaks, and the top border a part after a cut stands lower by. + * + * @param header whether its table has header rows a part after a cut opens with + * @param cut the pages, from 0, it is cut at + * @param left the pages, from 0, it runs onto whole, its table not split there + */ + private void reportTheCut(DocumentNode panel, String path, boolean header, List cut, List left) { + DocumentBorders borders = paintOf(panel).borders(); + double border = strokeWidth(borders == null ? null : borders.top()); + report.add(DocxExportReport.Severity.APPROXIMATED, panel.nodeKind(), path, + "the layout breaks it over pages inside its table: written as a table a page, cut at the " + + (cut.size() == 1 ? "row " : "rows ") + pagesNamed(cut) + (cut.size() == 1 ? " starts" : " start") + + " with" + + (header ? ", the part after a cut opening with the table's header rows" : "") + + ", and its page breaks fixed there, so rows an edit adds above a cut no longer run onto the page " + + "after it" + + (left.isEmpty() ? "" : "; " + pagesNamed(left) + ", whose first row its table cannot be split at, " + + (left.size() == 1 ? "is" : "are") + " left to Word") + + (border < 0.1 ? "" : "; in Word a part after a cut stands about its top border's width, " + + pointsOf(border) + "pt, lower than the page sets it: Word sets what a cell holds the whole border " + + "inside the row, where the page strokes it on the panel's edge, at the page's top")); + } + + /** Pages counted from 0, named as the page counts them: "page 2", "pages 2 and 3", "pages 2, 3 and 4". */ + private static String pagesNamed(List pages) { + List numbers = pages.stream().map(page -> String.valueOf(page + 1)).toList(); + return (numbers.size() == 1 ? "page " : "pages ") + listed(numbers); } /** @@ -16427,6 +16570,7 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except holdRowHeight(table, node, rowIdx); } breakRowsWhereTheLayoutDoes(table, node); + tablesWritten.put(node, table); indentTable(table); carryDrawingsInRows(table, node); reportFlattenedPaint(node, translucentFills, translucentRules, "its cells' fills", "its cells' rules"); 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 8a97c3e8c..9927739bf 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 @@ -1,5 +1,6 @@ package com.demcha.compose.document.backend.semantic.docx; +import org.apache.poi.xwpf.usermodel.IBodyElement; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFTable; @@ -16,7 +17,9 @@ /** * Splits a table of the body in two at the row a page starts with, so that a Word section can - * start there: a section ends in a paragraph, which stands between blocks. + * start there: a section ends in a paragraph, which stands between blocks; and cuts a panel there + * at a row of the table in its cell, which Word would otherwise break as its lines run out, the + * table's header rows not repeated (see {@link #splitInItsPanel}). * *

The rows from that row on go to a table of their own after it, which opens with copies of * the table's header rows: the layout repeats them at the top of each page the table runs onto, @@ -100,7 +103,69 @@ static int headerRows(XWPFTable table) { * @return the paragraph between the two parts */ static XWPFParagraph split(XWPFDocument document, XWPFTable table, int row) { - int header = headerRows(table); + XWPFTable second = copyAfter(document, table); + keepTheRowsBefore(table, row); + keepTheRowsFrom(second, row); + try (XmlCursor before = second.getCTTbl().newCursor()) { + return document.insertNewParagraph(before); + } + } + + /** + * Cuts a panel — a table of one cell in the body — in two at a row of a table its cell holds, + * with a page break between the two parts. + * + *

The first part keeps what its cell holds up to that table and the table's rows before + * that row; the second, a copy of the first, holds the table's header rows, its rows from that + * 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.

+ * + * @param document the document the panel is in the body of + * @param panel the panel + * @param table the table in its cell + * @param row the row the second part starts with, after the header rows + * @return the paragraph closing the first part's cell, the paragraph between the two parts and + * the second part, the table in its cell the first thing it holds + */ + static PanelPart splitInItsPanel(XWPFDocument document, XWPFTable panel, XWPFTable table, int row) { + XWPFTableCell cell = panel.getRow(0).getCell(0); + int nested = cell.getTables().indexOf(table); + XWPFTable second = copyAfter(document, panel); + XWPFTableCell continued = second.getRow(0).getCell(0); + XWPFTable rest = continued.getTables().get(nested); + List held = cell.getBodyElements(); + for (int index = held.size() - 1; index > held.indexOf(table); index--) { + remove(cell, held.get(index)); + } + // A cell ends in a paragraph: one after the table now ending the first part's. + XWPFParagraph closer = cell.addParagraph(); + keepTheRowsBefore(table, row); + List after = continued.getBodyElements(); + for (int index = after.indexOf(rest) - 1; index >= 0; index--) { + remove(continued, after.get(index)); + } + keepTheRowsFrom(rest, row); + try (XmlCursor before = second.getCTTbl().newCursor()) { + XWPFParagraph between = document.insertNewParagraph(before); + between.setPageBreak(true); + return new PanelPart(closer, between, second); + } + } + + /** + * What cutting a panel puts in and after its first part. + * + * @param closer the empty paragraph that ends the first part's cell, after its table + * @param between the paragraph between the two parts, which opens the next page: a break before + * it, which a paragraph that opens a page anyway does not add, where a break in it + * would put a page of its own after a part that fills its page + * @param second the second part + */ + record PanelPart(XWPFParagraph closer, XWPFParagraph between, XWPFTable second) { + } + + /** A copy of a table of the body, put right after it. */ + private static XWPFTable copyAfter(XWPFDocument document, XWPFTable table) { CTBody body = document.getDocument().getBody(); int written = List.of(body.getTblArray()).indexOf(table.getCTTbl()); try (XmlCursor from = table.getCTTbl().newCursor(); XmlCursor after = table.getCTTbl().newCursor()) { @@ -108,17 +173,32 @@ static XWPFParagraph split(XWPFDocument document, XWPFTable table, int row) { after.toNextToken(); from.copyXml(after); } - CTTbl copied = body.getTblArray(written + 1); - XWPFTable second = new XWPFTable(copied, document); - document.insertTable(document.getBodyElements().indexOf(table) + 1, second); - for (int index = row - 1; index >= header; index--) { - second.removeRow(index); - } + XWPFTable copy = new XWPFTable(body.getTblArray(written + 1), document); + document.insertTable(document.getBodyElements().indexOf(table) + 1, copy); + return copy; + } + + /** Takes a table's rows from one on away. */ + private static void keepTheRowsBefore(XWPFTable table, int row) { for (int index = table.getNumberOfRows() - 1; index >= row; index--) { table.removeRow(index); } - try (XmlCursor before = copied.newCursor()) { - return document.insertNewParagraph(before); + } + + /** Takes a table's rows between its header rows and one away. */ + private static void keepTheRowsFrom(XWPFTable table, int row) { + int header = headerRows(table); + for (int index = row - 1; index >= header; index--) { + table.removeRow(index); + } + } + + /** Takes a paragraph or a table out of a cell. */ + private static void remove(XWPFTableCell cell, IBodyElement element) { + if (element instanceof XWPFParagraph paragraph) { + cell.removeParagraph(cell.getParagraphs().indexOf(paragraph)); + } else if (element instanceof XWPFTable table) { + cell.removeTable(cell.getTables().indexOf(table)); } } 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 new file mode 100644 index 000000000..8eddf4ef1 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java @@ -0,0 +1,345 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.api.MultiSectionDocument; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.TableBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.PageReferenceNode; +import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.IBodyElement; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTable; +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.CTTcMar; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A panel the layout breaks over pages inside the table it holds is a table a page. + * + *

The layout breaks such a table between rows, repeats its header rows at the top of the next + * page and goes on with the panel there, no padding above its text. 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, {@code ObsidianInvoice}'s and {@code SubscriptionInvoice}'s line + * items set over two pages stood 24 to 30pt high on the second, and over three one row stood a + * page early, in LibreOffice 24.2 too. Cut at the row each page starts with, every line stands on + * the page the layout sets it on in both.

+ * + * @author Artem Demchyshyn + */ +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 CUT = "written as a table a page"; + + @Test + void aPanelWhoseTableRunsOverAPageIsATableAPageCutAtTheRowsThePagesStartWith() throws Exception { + Exported exported = export(card(20, true, null, null)); + + List parts = parts(exported.document()); + assertThat(exported.pages()).as("the premise: the card runs onto a second page").hasSize(2); + assertThat(parts).hasSize(2); + assertThat(rowsOf(parts)).as("each part the rows the page sets on its page, under the header") + .isEqualTo(exported.pages()); + } + + @Test + void aTableOverThreePagesIsCutTwiceEachPartUnderItsHeader() throws Exception { + Exported exported = export(card(30, true, null, null)); + + assertThat(exported.pages()).as("the premise: three pages").hasSize(3); + assertThat(rowsOf(parts(exported.document()))).isEqualTo(exported.pages()); + } + + @Test + void aTableWithNoHeaderRowsIsCutWithNoneAndItsNoteSaysNothingOfOne() throws Exception { + Exported exported = export(card(20, false, null, null)); + + List> rows = rowsOf(parts(exported.document())); + assertThat(rows).hasSize(2); + assertThat(rows.get(1).get(0)).as("the second part opens with its first row").startsWith("Row "); + assertThat(cutNote(exported.report())).doesNotContain("header"); + } + + @Test + void thePartsStandApartByAShortParagraphThatBreaksThePageBeforeItself() throws Exception { + XWPFDocument document = export(card(20, true, null, null)).document(); + + List body = document.getBodyElements(); + int first = body.indexOf(parts(document).get(0)); + XWPFParagraph between = (XWPFParagraph) body.get(first + 1); + assertThat(between.isPageBreak()).as("a break before it, which adds no page where it opens one").isTrue(); + assertThat(between.getRuns()).as("no break in it, which would").isEmpty(); + assertThat(between.getCTP().getPPr().getSpacing().getLine().toString()).as("a tenth of a point").isEqualTo("2"); + assertThat(body.get(first + 2)).isSameAs(parts(document).get(1)); + } + + @Test + void aPartAfterACutHasNoTopPaddingAndOneBeforeACutNoBottomPadding() throws Exception { + // The page sets the card's rows down to the page's foot and closes it on its last page only. + List parts = parts(export(card(30, true, null, null)).document()); + + assertThat(margin(parts.get(0), true)).as("the card's padding, its border's room taken").isPositive(); + assertThat(margin(parts.get(0), false)).isZero(); + assertThat(margin(parts.get(1), true)).isZero(); + assertThat(margin(parts.get(1), false)).isZero(); + assertThat(margin(parts.get(2), true)).isZero(); + assertThat(margin(parts.get(2), false)).as("the last closes the card").isPositive(); + } + + @Test + void whatTheCardHoldsBeforeAndAfterItsTableStaysWithTheFirstAndTheLastPart() throws Exception { + List parts = parts(export(card(20, true, + above -> above.addParagraph("Heading"), below -> below.addParagraph("Closing"))).document()); + + XWPFTableCell first = parts.get(0).getRow(0).getCell(0); + XWPFTableCell last = parts.get(parts.size() - 1).getRow(0).getCell(0); + assertThat(texts(first)).contains("Heading").doesNotContain("Closing"); + assertThat(texts(last)).contains("Closing").doesNotContain("Heading"); + IBodyElement closer = first.getBodyElements().get(first.getBodyElements().size() - 1); + assertThat(closer).as("the first part's cell, ending with its table, ends with a paragraph") + .isInstanceOf(XWPFParagraph.class); + assertThat(((XWPFParagraph) closer).getCTP().getPPr().getSpacing().getLine().toString()) + .as("a tenth of a point, as one closing a table in a cell").isEqualTo("2"); + } + + @Test + void theCutIsNamedWithItsPagesItsFixedBreaksAndTheTopBorderItsPartsStandLowerBy() throws Exception { + String note = cutNote(export(card(30, true, null, null)).report()); + + assertThat(note).contains("cut at the rows pages 2 and 3 start with"); + assertThat(note).contains("opening with the table's header rows"); + assertThat(note).contains("page breaks fixed there"); + assertThat(note).contains("in Word a part after a cut stands about its top border's width, 1pt, lower"); + } + + @Test + void aPageWhoseRowsHoldAFieldIsLeftWholeAndTheNextOneIsStillCut() throws Exception { + // A page reference in a row on page 2: the table is not split where page 2 starts, a + // field's copy being one the export does not settle, and is where page 3 does. + Exported exported = export(page -> page.addSection(card -> card.fillColor(SURFACE) + .stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6)) + .addParagraph(p -> p.text("Heading").anchor("top")) + .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("top", DocumentTextStyle.DEFAULT, + TextAlign.LEFT)) + : DocumentTableCell.text(String.valueOf(row))); + } + }))); + + 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(2); + assertThat(cutNote(exported.report())).contains("cut at the row page 3 starts with") + .contains("page 2, whose first row its table cannot be split at, is left to Word"); + } + + @Test + void aPanelInAnEarlierSectionIsCutByItsOwnSectionsLayout() throws Exception { + // Each section is laid out on its own: the cut reads the card's pages off its section's. + AtomicReference report = new AtomicReference<>(); + byte[] docx; + try (MultiSectionDocument sections = GraphCompose.documents() + .section(session(card(20, true, null, null))) + .section(session(page -> page.addParagraph("Second section"))) + .create()) { + docx = sections.export(DocxSemanticBackend.builder().reportSink(report::set).build()); + } + + assertThat(parts(new XWPFDocument(new ByteArrayInputStream(docx)))).hasSize(2); + assertThat(cutNote(report.get())).contains("cut at the row page 2 starts with"); + } + + @Test + void aPanelWhoseTableFitsItsPageIsOneTable() throws Exception { + Exported exported = export(card(8, true, null, null)); + + assertThat(exported.pages()).hasSize(1); + assertThat(parts(exported.document())).hasSize(1); + assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(CUT)); + } + + @Test + void aPanelTheLayoutBreaksBetweenParagraphsIsNotCut() throws Exception { + // Only a table the layout breaks over pages is cut: blocks it moves or breaks otherwise + // inside a panel are left to Word. + Exported exported = export(page -> page.addSection(card -> { + card.fillColor(SURFACE).padding(DocumentInsets.of(6)); + for (int line = 1; line <= 30; line++) { + card.addParagraph("Line " + line); + } + })); + + assertThat(exported.cardPages()).as("the premise: the card runs onto a second page").isGreaterThan(1); + assertThat(exported.document().getTables()).hasSize(1); + assertThat(exported.document().getParagraphs()).noneMatch(XWPFParagraph::isPageBreak); + } + + @Test + void aPanelWithAnotherBlockOverPagesBesideItsTableIsNotCut() throws Exception { + // A paragraph the layout breaks over pages above the table: the table is not the only one. + Exported exported = export(card(20, true, above -> above.addParagraph("Long " + "word ".repeat(400)), null)); + + assertThat(exported.cardPages()).as("the premise: the card runs over pages").isGreaterThan(2); + assertThat(parts(exported.document())).hasSize(1); + assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(CUT)); + } + + @Test + void aPanelWithTwoTablesOverPagesIsNotCut() throws Exception { + Exported exported = export(page -> page.addSection(card -> { + card.fillColor(SURFACE).stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6)); + card.addTable(table -> rows(table, 20, true)); + card.addTable(table -> rows(table, 20, true)); + })); + + assertThat(exported.cardPages()).as("the premise: the card runs over pages").isGreaterThan(2); + assertThat(exported.document().getParagraphs()).noneMatch(XWPFParagraph::isPageBreak); + } + + @Test + void aTableOverPagesOutsideAPanelIsNotCut() throws Exception { + // In the body Word repeats a table's header rows itself. + Exported exported = export(page -> page.addTable(table -> rows(table, 20, true))); + + assertThat(exported.pages()).as("the premise: the table runs onto a second page").hasSize(2); + assertThat(exported.document().getTables()).hasSize(1); + assertThat(exported.document().getParagraphs()).noneMatch(XWPFParagraph::isPageBreak); + } + + /** A card with a 1pt border and 6pt of padding holding a table of {@code count} rows. */ + private static Consumer card(int count, boolean header, Consumer above, + Consumer below) { + return page -> page.addSection(card -> { + card.fillColor(SURFACE).stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6)); + if (above != null) { + above.accept(card); + } + card.addTable(table -> rows(table, count, header)); + if (below != null) { + below.accept(card); + } + }); + } + + private static void rows(TableBuilder table, int count, boolean header) { + table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100)); + if (header) { + table.header("Item", "Amount").repeatHeader(); + } + for (int row = 1; row <= count; row++) { + table.row("Row " + row, String.valueOf(row)); + } + } + + /** + * An export, its report, the rows of its first table the layout sets on each page — header and + * data rows by their first cell's text — and the pages its first section runs over. + */ + private record Exported(XWPFDocument document, DocxExportReport report, List> pages, int cardPages) { + } + + private static DocumentSession session(Consumer content) { + DocumentSession session = GraphCompose.document().pageSize(400, 300).margin(DocumentInsets.of(20)).create(); + session.pageFlow(content::accept); + return session; + } + + private static Exported export(Consumer content) throws Exception { + AtomicReference report = new AtomicReference<>(); + try (DocumentSession session = session(content)) { + byte[] docx = session.export(new DocxSemanticBackend(report::set)); + DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph()); + List> pages = new ArrayList<>(); + TableNode table = first(session.documentGraph().roots(), TableNode.class); + if (table != null) { + boolean header = table.repeatedHeaderRowCount() > 0; + for (int row = header ? 1 : 0; row < table.rows().size(); row++) { + int page = layout.rowPage(table, row); + while (pages.size() <= page) { + pages.add(new ArrayList<>(header ? List.of("Item") : List.of())); + } + pages.get(page).add("Row " + (header ? row : row + 1)); + } + } + SectionNode card = first(session.documentGraph().roots(), SectionNode.class); + int cardPages = card == null || layout.placement(card) == null ? 0 + : layout.placement(card).endPage() - layout.placement(card).startPage() + 1; + return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get(), pages, cardPages); + } + } + + private static T first(List nodes, Class kind) { + for (DocumentNode node : nodes) { + if (kind.isInstance(node)) { + return kind.cast(node); + } + T inside = first(node.children(), kind); + if (inside != null) { + return inside; + } + } + return null; + } + + private static String cutNote(DocxExportReport report) { + return report.notes().stream().filter(note -> note.detail().contains(CUT)).findFirst().orElseThrow().detail(); + } + + /** 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() + .filter(table -> table.getNumberOfRows() == 1 && !table.getRow(0).getCell(0).getTables().isEmpty()) + .toList(); + } + + /** Each part's table's rows, by their first cell's text. */ + private static List> rowsOf(List parts) { + List> rows = new ArrayList<>(); + for (XWPFTable part : parts) { + List texts = new ArrayList<>(); + for (XWPFTableRow row : part.getRow(0).getCell(0).getTables().get(0).getRows()) { + texts.add(row.getCell(0).getText()); + } + rows.add(texts); + } + return rows; + } + + private static List texts(XWPFTableCell cell) { + return cell.getParagraphs().stream().map(XWPFParagraph::getText).toList(); + } + + private static long margin(XWPFTable part, boolean top) { + CTTcMar margins = part.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar(); + if (margins == null || !(top ? margins.isSetTop() : margins.isSetBottom())) { + return 0; + } + return DocxTwips.of((top ? margins.getTop() : margins.getBottom()).getW()); + } +} From bc35f9af74974a5c855da6668cb3534f081c0e31 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 25 Oct 2026 15:59:25 +0000 Subject: [PATCH 2/3] fix(docx): keep room under a panel part's last row, and name every page not cut A part before a cut must hold, under the page's last row, the borders Word sets inside it: its bottom border and, where it opens its page, its top border and the paragraph before it. The page sets the rows down to its foot and draws the border taking no room. Where the page above leaves less than those and a point, Word would set the last row on a page of its own and the fixed break would open one more, so that page is not cut. The report names each page not cut and why: outside the panel's table, at a row the table cannot be split at, or too near the foot. A panel cut at no page, an anchored card whose bookmark opens in its table's header row for one, is named as written whole. --- CHANGELOG.md | 9 +- docs/recipes/docx-export.md | 11 ++- .../semantic/docx/DocxLayoutMetrics.java | 18 ++++ .../semantic/docx/DocxSemanticBackend.java | 92 +++++++++++++------ .../semantic/docx/DocxPanelOverPagesTest.java | 89 ++++++++++++++++-- 5 files changed, 178 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47ad510e..f632cc489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,12 @@ follow semantic versioning; release dates are ISO 8601. as Word and LibreOffice draw the border inside the row: 1.1pt under a 1pt border, 2.8pt under `SubscriptionInvoice`'s ruled top. - The page breaks are fixed there, so rows an edit adds above a cut no longer run onto the next - page, and the report names the cut and any page left whole. Anything else the layout moves or - breaks over a page inside a panel — a block kept together, a stack, a paragraph — is left to - Word, as before. + page, and the report names the cut. A page is not cut, and is named, where the panel starts it + outside its table, where the table cannot be split at that row — its header rows holding an + anchored card's bookmark among them — or where the page above sets the rows too near its foot + for the borders Word sets inside the part; a panel cut at no page is named as written whole. + Anything else the layout moves or breaks over a page inside a panel — a block kept together, a + stack, a paragraph — is left to Word, as before. - No document of the corpus has such a panel; its export is unchanged. - **A DOCX export writes no page break where the page breaks none: in a row's column, a table's diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index e3c41e2ec..b0d080c0c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -749,10 +749,13 @@ How it lands: and 12pt). The page breaks are fixed there, and the report names the cut; a part after a cut stands about its top border's width lower than the page sets it, Word and LibreOffice drawing the border inside the row where the page strokes it on the panel's edge (1.1pt under a 1pt - border, 2.8pt under `SubscriptionInvoice`'s ruled top). A table is not cut at a page whose - first row continues a merged cell, nor where a row from that one on holds a field or its header - rows hold a bookmark, a drawing, a numbered item or a field; the report names a page so left - whole. Anything else the layout moves or breaks over + border, 2.8pt under `SubscriptionInvoice`'s ruled top). A panel is not cut where the page it + runs onto starts outside its table, where that page's first row continues a merged cell or a row + from it on holds a field, where the table's header rows hold a bookmark — an anchored card's —, + a drawing, a numbered item or a field, nor where the page above sets the panel's rows nearer its + foot than the borders Word sets inside the part there take, and a point for the editor's lines, + where Word would set the last row on a page of its own; the report names each such page, and a + panel cut at no page as written whole. Anything else the layout moves or breaks over a page inside a panel — a block kept together, a stack, a paragraph — is left to Word, a block kept named as above. - **Measured.** Cards stacked in a row's columns, panels nested in its table, and a card set in diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 9bf63ed5d..7c3656678 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -490,6 +490,24 @@ private Map rowHeightsOf(DocumentNode table) { * resembles the pattern matches nothing instead of failing the export. Each is kept with * the page it was placed on. */ + /** + * Where the lowest of a table's own rows on a page ends, measured up from the page's foot as + * {@link #lowestContentOn} measures. + * + * @param table the table node + * @param page the page, from 0 + * @return the height above the page's foot, or NaN where the table has no row of its own there + */ + double lowestRowOn(DocumentNode table, int page) { + double lowest = Double.NaN; + for (PlacedFragment fragment : ownRows(table)) { + if (fragment.pageIndex() == page) { + lowest = Double.isNaN(lowest) ? fragment.y() : Math.min(lowest, fragment.y()); + } + } + return lowest; + } + private Map placedRowsOf(DocumentNode table) { return placedRows.computeIfAbsent(table, node -> { Map rows = new HashMap<>(); 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 06dc61a89..6e2a47269 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 @@ -7163,8 +7163,14 @@ private void noteATableOverPages(DocumentNode node, XWPFTable outer) { * header missing, and over three one row stood a page early, in LibreOffice 24.2 too. Each part * after a cut opens with copies of the table's header rows and has no top padding, and each part * before one no bottom padding: the page sets the panel's last rows on a page down to its foot, - * and closes the panel only on its last page. A table that cannot be split at a page's row - * ({@link DocxTableSplit#rowStarting}) is left whole there.

+ * and closes the panel only on its last page.

+ * + *

A page is left to Word, and named: one the panel runs onto outside its table's pages; one + * whose first row the table cannot be split at ({@link DocxTableSplit#rowStarting}); and one + * whose page above ends the panel's rows nearer its foot than the borders Word sets inside the + * part there take, its bottom border and, where that part opens the page, its top border and + * the paragraph before it, and a point for the editor's lines: Word would set the last row on + * 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 @@ -7173,21 +7179,37 @@ private void noteATableOverPages(DocumentNode node, XWPFTable outer) { 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()); XWPFTable piece = over.outer(); XWPFTable inner = over.inner(); boolean header = DocxTableSplit.headerRows(inner) > 0; // The table's rows the parts before the current one hold, after its header rows. int before = 0; List cut = new ArrayList<>(); - List left = new ArrayList<>(); - for (int page = placed.startPage() + 1; page <= placed.endPage(); page++) { + 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.add(page); + 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; } DocxTableSplit.PanelPart part = DocxTableSplit.splitInItsPanel(document, piece, inner, row); @@ -7203,35 +7225,51 @@ private void cutThePanelsAtTheirPages(XWPFDocument document) { inner = next.getRow(0).getCell(0).getTables().get(0); cut.add(page); } - if (!cut.isEmpty()) { - reportTheCut(over.panel(), itsLayout.pathOf(over.panel()), header, cut, left); - } + reportTheCut(over.panel(), itsLayout.pathOf(over.panel()), header, top, cut, left); } } /** - * Names a panel cut at its pages ({@link #cutThePanelsAtTheirPages}): the pages it is cut at, - * those it is not, its fixed page breaks, and the top border a part after a cut stands lower by. + * Room a part before a cut keeps under its page's last row for the editor's lines, in points, + * besides the borders it sets there: Word's lines stand within a few tenths of the page's by a + * page's foot (measured: within 0.33pt in cards padded 6 and 12pt, 0.72pt in + * {@code ObsidianInvoice}). + */ + private static final double ROOM_FOR_THE_LINES = 1.0; + + /** + * Names a panel the layout breaks over pages inside its table ({@link #cutThePanelsAtTheirPages}): + * the pages it is cut at, its fixed page breaks and the top border a part after a cut stands + * lower by, or that it is written whole; and each page left to Word, and why. * * @param header whether its table has header rows a part after a cut opens with + * @param top the width of its top border, in points * @param cut the pages, from 0, it is cut at - * @param left the pages, from 0, it runs onto whole, its table not split there - */ - private void reportTheCut(DocumentNode panel, String path, boolean header, List cut, List left) { - DocumentBorders borders = paintOf(panel).borders(); - double border = strokeWidth(borders == null ? null : borders.top()); - report.add(DocxExportReport.Severity.APPROXIMATED, panel.nodeKind(), path, - "the layout breaks it over pages inside its table: written as a table a page, cut at the " - + (cut.size() == 1 ? "row " : "rows ") + pagesNamed(cut) + (cut.size() == 1 ? " starts" : " start") - + " with" - + (header ? ", the part after a cut opening with the table's header rows" : "") - + ", and its page breaks fixed there, so rows an edit adds above a cut no longer run onto the page " - + "after it" - + (left.isEmpty() ? "" : "; " + pagesNamed(left) + ", whose first row its table cannot be split at, " - + (left.size() == 1 ? "is" : "are") + " left to Word") - + (border < 0.1 ? "" : "; in Word a part after a cut stands about its top border's width, " - + pointsOf(border) + "pt, lower than the page sets it: Word sets what a cell holds the whole border " - + "inside the row, where the page strokes it on the panel's edge, at the page's top")); + * @param left the pages, from 0, left to Word, each with why + */ + private void reportTheCut(DocumentNode panel, String path, boolean header, double top, List cut, + java.util.Map left) { + StringBuilder detail = new StringBuilder("the layout breaks it over pages inside its table: "); + if (cut.isEmpty()) { + detail.append("written as one table, whose row Word breaks where its lines run out, repeating no header " + + "row of the table nested in its cell"); + } else { + detail.append("written as a table a page, cut at the ").append(cut.size() == 1 ? "row " : "rows ") + .append(pagesNamed(cut)).append(cut.size() == 1 ? " starts" : " start").append(" with") + .append(header ? ", the part after a cut opening with the table's header rows" : "") + .append(", and its page breaks fixed there, so rows an edit adds above a cut no longer run onto " + + "the page after it"); + } + java.util.Map> why = new java.util.LinkedHashMap<>(); + left.forEach((page, reason) -> why.computeIfAbsent(reason, key -> new ArrayList<>()).add(page)); + why.forEach((reason, pages) -> detail.append("; it is not cut where ").append(pagesNamed(pages)) + .append(pages.size() == 1 ? " starts, " : " start, ").append(reason)); + if (!cut.isEmpty() && top >= 0.1) { + detail.append("; in Word a part after a cut stands about its top border's width, ").append(pointsOf(top)) + .append("pt, lower than the page sets it: Word sets what a cell holds the whole border inside the " + + "row, where the page strokes it on the panel's edge, at the page's top"); + } + report.add(DocxExportReport.Severity.APPROXIMATED, panel.nodeKind(), path, detail.toString()); } /** Pages counted from 0, named as the page counts them: "page 2", "pages 2 and 3", "pages 2, 3 and 4". */ 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 8eddf4ef1..6c237bacc 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 @@ -51,7 +51,7 @@ 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 CUT = "written as a table a page"; + private static final String NOTE = "the layout breaks it over pages inside its table"; @Test void aPanelWhoseTableRunsOverAPageIsATableAPageCutAtTheRowsThePagesStartWith() throws Exception { @@ -136,8 +136,8 @@ void theCutIsNamedWithItsPagesItsFixedBreaksAndTheTopBorderItsPartsStandLowerBy( @Test void aPageWhoseRowsHoldAFieldIsLeftWholeAndTheNextOneIsStillCut() throws Exception { - // A page reference in a row on page 2: the table is not split where page 2 starts, a - // field's copy being one the export does not settle, and is where page 3 does. + // A page reference in a row on page 2: the table is not split where a row from page 2's + // first on holds a field, as a section's split of a table is not, and is where page 3 starts. Exported exported = export(page -> page.addSection(card -> card.fillColor(SURFACE) .stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6)) .addParagraph(p -> p.text("Heading").anchor("top")) @@ -156,7 +156,82 @@ void aPageWhoseRowsHoldAFieldIsLeftWholeAndTheNextOneIsStillCut() throws Excepti assertThat(exported.pages().get(1)).as("the premise: row 15 on page 2").contains("Row 15"); assertThat(parts(exported.document())).hasSize(2); assertThat(cutNote(exported.report())).contains("cut at the row page 3 starts with") - .contains("page 2, whose first row its table cannot be split at, is left to Word"); + .contains("it is not cut where page 2 starts, at a row its table cannot be split at"); + } + + @Test + void aCardThatCannotBeCutAtAnyPageIsNamedAsWrittenWhole() throws Exception { + // An anchored card's bookmark opens in its table's header row, which a part's copy would + // hold again: the table is split nowhere. + Exported exported = export(page -> page.addSection(card -> card.anchor("items").fillColor(SURFACE) + .stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6)) + .addTable(table -> rows(table, 20, true)))); + + assertThat(exported.pages()).as("the premise: the card runs onto a second page").hasSize(2); + assertThat(exported.document().getParagraphs()).noneMatch(XWPFParagraph::isPageBreak); + assertThat(cutNote(exported.report())).contains("written as one table") + .contains("it is not cut where page 2 starts, at a row its table cannot be split at") + .doesNotContain("page breaks fixed"); + } + + @Test + void aPageTheCardStartsOutsideItsTableIsNamedAndTheTableStillCut() throws Exception { + // A heading and a gap fill page 1: the table starts on page 2 and runs onto page 3. + Exported exported = export(card(20, true, above -> above.addParagraph("Heading") + .addSpacer(spacer -> spacer.height(225)), null)); + + assertThat(exported.cardPages()).as("the premise: the card runs over three pages").isEqualTo(3); + assertThat(exported.pages().get(0)).as("the premise: no row of the table on page 1").containsExactly("Item"); + assertThat(parts(exported.document())).hasSize(2); + assertThat(cutNote(exported.report())).contains("it is not cut where page 2 starts, outside its table"); + } + + @Test + void aPageAboveWhichTheRowsEndTooNearTheFootIsNotCut() throws Exception { + // Word sets the part's bottom border inside its row: where the page's last row on page 1 + // leaves less room than that above the foot, Word would set that row on a page of its own. + double gap = gapLeavingTheLastRowNearTheFoot(); + Exported exported = export(page -> { + page.addSpacer(spacer -> spacer.height(gap)); + card(20, true, null, null).accept(page); + }); + + assertThat(parts(exported.document())).as("not cut where page 2 starts").hasSize(1); + assertThat(cutNote(exported.report())).contains("it is not cut where page 2 starts, the page above setting " + + "its rows too near its foot"); + } + + @Test + void aTableDeeperThanThePanelsOwnChildrenIsNotCut() throws Exception { + Exported exported = export(page -> page.addSection(card -> card.fillColor(SURFACE) + .stroke(DocumentStroke.of(INK, 1)).padding(DocumentInsets.of(6)) + .addSection(inner -> inner.addTable(table -> rows(table, 20, true))))); + + assertThat(exported.cardPages()).as("the premise: the card runs onto a second page").isEqualTo(2); + assertThat(exported.document().getParagraphs()).noneMatch(XWPFParagraph::isPageBreak); + assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(NOTE)); + } + + /** + * A gap above the card that leaves its table's last row on page 1 less than its 1pt bottom + * border and a point above the page's foot, found by laying the page out. + */ + private static double gapLeavingTheLastRowNearTheFoot() { + for (double gap = 0; gap < 40; gap += 0.25) { + double height = gap; + try (DocumentSession session = session(page -> { + page.addSpacer(spacer -> spacer.height(height)); + card(20, 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 < 1.5 && layout.placement(table).endPage() == 1) { + return gap; + } + } + } + throw new AssertionError("no gap leaves the last row near the foot"); } @Test @@ -181,7 +256,7 @@ void aPanelWhoseTableFitsItsPageIsOneTable() throws Exception { assertThat(exported.pages()).hasSize(1); assertThat(parts(exported.document())).hasSize(1); - assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(CUT)); + assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(NOTE)); } @Test @@ -207,7 +282,7 @@ void aPanelWithAnotherBlockOverPagesBesideItsTableIsNotCut() throws Exception { assertThat(exported.cardPages()).as("the premise: the card runs over pages").isGreaterThan(2); assertThat(parts(exported.document())).hasSize(1); - assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(CUT)); + assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains(NOTE)); } @Test @@ -308,7 +383,7 @@ private static T first(List nodes, Class< } private static String cutNote(DocxExportReport report) { - return report.notes().stream().filter(note -> note.detail().contains(CUT)).findFirst().orElseThrow().detail(); + return report.notes().stream().filter(note -> note.detail().contains(NOTE)).findFirst().orElseThrow().detail(); } /** The panel's tables in the body, those that hold a table in their one cell. */ From 67437532d2f963c22b145eaeb42d1a2251e307bd Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 25 Oct 2026 16:34:06 +0000 Subject: [PATCH 3/3] fix(docx): name a cut panel's pages as its section counts them A panel in a later section names the pages it is cut at, and those it is not, with its section, as other notes naming a section's pages do. A panel cut at no page says Word repeats no header row only where its table has one. --- .../semantic/docx/DocxLayoutMetrics.java | 12 +- .../semantic/docx/DocxSemanticBackend.java | 45 +++++--- .../semantic/docx/DocxPanelOverPagesTest.java | 103 ++++++++++++++++-- 3 files changed, 129 insertions(+), 31 deletions(-) diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 7c3656678..3a3a02a57 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -484,12 +484,6 @@ private Map rowHeightsOf(DocumentNode table) { }); } - /** - * The index part of each placed row's name, kept as the text the layout wrote rather than - * parsed: a row is looked up by writing its index the same way, so a name that only - * resembles the pattern matches nothing instead of failing the export. Each is kept with - * the page it was placed on. - */ /** * Where the lowest of a table's own rows on a page ends, measured up from the page's foot as * {@link #lowestContentOn} measures. @@ -508,6 +502,12 @@ private Map rowHeightsOf(DocumentNode table) { return lowest; } + /** + * The index part of each placed row's name, kept as the text the layout wrote rather than + * parsed: a row is looked up by writing its index the same way, so a name that only + * resembles the pattern matches nothing instead of failing the export. Each is kept with + * the page it was placed on. + */ private Map placedRowsOf(DocumentNode table) { return placedRows.computeIfAbsent(table, node -> { Map rows = new HashMap<>(); 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 6e2a47269..9ea0bf646 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 @@ -7121,10 +7121,11 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint * @param outer the table of one cell the panel is written as * @param table the table among its children the layout breaks over pages * @param inner the table that one is written as, in the panel's cell - * @param layout the layout of the section the panel is in + * @param layout the layout of the section the panel is in, which counts its pages + * @param section what the report calls that section, as "section 2", or null for a document of one */ private record PanelOverPages(DocumentNode panel, XWPFTable outer, TableNode table, XWPFTable inner, - DocxLayoutMetrics layout) { + DocxLayoutMetrics layout, String section) { } /** @@ -7147,7 +7148,8 @@ private void noteATableOverPages(DocumentNode node, XWPFTable outer) { } XWPFTable inner = over == null ? null : tablesWritten.get(over); if (inner != null && outer.getRow(0).getCell(0).getBodyElements().contains(inner)) { - panelsOverPages.add(new PanelOverPages(node, outer, over, inner, layout)); + panelsOverPages.add(new PanelOverPages(node, outer, over, inner, layout, + sectioned ? "section " + (sectionIndex + 1) : null)); } } @@ -7225,7 +7227,7 @@ private void cutThePanelsAtTheirPages(XWPFDocument document) { inner = next.getRow(0).getCell(0).getTables().get(0); cut.add(page); } - reportTheCut(over.panel(), itsLayout.pathOf(over.panel()), header, top, cut, left); + reportTheCut(over, header, top, cut, left); } } @@ -7242,40 +7244,49 @@ private void cutThePanelsAtTheirPages(XWPFDocument document) { * the pages it is cut at, its fixed page breaks and the top border a part after a cut stands * lower by, or that it is written whole; and each page left to Word, and why. * + * @param over the panel * @param header whether its table has header rows a part after a cut opens with * @param top the width of its top border, in points - * @param cut the pages, from 0, it is cut at - * @param left the pages, from 0, left to Word, each with why + * @param cut the pages, from 0 in its section, it is cut at + * @param left the pages, from 0 in its section, left to Word, each with why */ - private void reportTheCut(DocumentNode panel, String path, boolean header, double top, List cut, + private void reportTheCut(PanelOverPages over, boolean header, double top, List cut, java.util.Map left) { StringBuilder detail = new StringBuilder("the layout breaks it over pages inside its table: "); if (cut.isEmpty()) { - detail.append("written as one table, whose row Word breaks where its lines run out, repeating no header " - + "row of the table nested in its cell"); + detail.append("written as one table, whose row Word breaks where its lines run out") + .append(header ? ", repeating no header row of the table nested in its cell" : ""); } else { - detail.append("written as a table a page, cut at the ").append(cut.size() == 1 ? "row " : "rows ") - .append(pagesNamed(cut)).append(cut.size() == 1 ? " starts" : " start").append(" with") + detail.append("written as tables cut at the ").append(cut.size() == 1 ? "row " : "rows ") + .append(pagesNamed(over.section(), cut)).append(cut.size() == 1 ? " starts" : " start") + .append(" with") .append(header ? ", the part after a cut opening with the table's header rows" : "") .append(", and its page breaks fixed there, so rows an edit adds above a cut no longer run onto " + "the page after it"); } java.util.Map> why = new java.util.LinkedHashMap<>(); left.forEach((page, reason) -> why.computeIfAbsent(reason, key -> new ArrayList<>()).add(page)); - why.forEach((reason, pages) -> detail.append("; it is not cut where ").append(pagesNamed(pages)) - .append(pages.size() == 1 ? " starts, " : " start, ").append(reason)); + why.forEach((reason, pages) -> detail.append("; it is not cut where ") + .append(pagesNamed(over.section(), pages)).append(pages.size() == 1 ? " starts, " : " start, ") + .append(reason)); if (!cut.isEmpty() && top >= 0.1) { detail.append("; in Word a part after a cut stands about its top border's width, ").append(pointsOf(top)) .append("pt, lower than the page sets it: Word sets what a cell holds the whole border inside the " + "row, where the page strokes it on the panel's edge, at the page's top"); } - report.add(DocxExportReport.Severity.APPROXIMATED, panel.nodeKind(), path, detail.toString()); + report.add(DocxExportReport.Severity.APPROXIMATED, over.panel().nodeKind(), over.layout().pathOf(over.panel()), + detail.toString()); } - /** Pages counted from 0, named as the page counts them: "page 2", "pages 2 and 3", "pages 2, 3 and 4". */ - private static String pagesNamed(List pages) { + /** + * Pages counted from 0 in a section, named as the section counts them: "page 2", "section 2, + * pages 2 and 3", "pages 2, 3 and 4". + * + * @param section what the report calls the section, or null for a document of one + */ + private static String pagesNamed(String section, List pages) { List numbers = pages.stream().map(page -> String.valueOf(page + 1)).toList(); - return (numbers.size() == 1 ? "page " : "pages ") + listed(numbers); + return (section == null ? "" : section + ", ") + (numbers.size() == 1 ? "page " : "pages ") + listed(numbers); } /** 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 6c237bacc..cde94f8bc 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 @@ -154,7 +154,10 @@ void aPageWhoseRowsHoldAFieldIsLeftWholeAndTheNextOneIsStillCut() throws Excepti 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(2); + List first = new ArrayList<>(exported.pages().get(0)); + first.addAll(exported.pages().get(1).subList(1, exported.pages().get(1).size())); + assertThat(rowsOf(parts(exported.document()))).as("pages 1 and 2 one part, page 3 another") + .containsExactly(first, exported.pages().get(2)); assertThat(cutNote(exported.report())).contains("cut at the row page 3 starts with") .contains("it is not cut where page 2 starts, at a row its table cannot be split at"); } @@ -190,7 +193,7 @@ void aPageTheCardStartsOutsideItsTableIsNamedAndTheTableStillCut() throws Except void aPageAboveWhichTheRowsEndTooNearTheFootIsNotCut() throws Exception { // Word sets the part's bottom border inside its row: where the page's last row on page 1 // leaves less room than that above the foot, Word would set that row on a page of its own. - double gap = gapLeavingTheLastRowNearTheFoot(); + double gap = gapLeavingTheLastRowNearTheFoot(true); Exported exported = export(page -> { page.addSpacer(spacer -> spacer.height(gap)); card(20, true, null, null).accept(page); @@ -198,7 +201,43 @@ void aPageAboveWhichTheRowsEndTooNearTheFootIsNotCut() throws Exception { assertThat(parts(exported.document())).as("not cut where page 2 starts").hasSize(1); assertThat(cutNote(exported.report())).contains("it is not cut where page 2 starts, the page above setting " - + "its rows too near its foot"); + + "its rows too near its foot") + .contains("repeating no header row of the table nested in its cell"); + } + + @Test + void aTableWithNoHeaderRowsCutNowhereIsNamedWithNoWordOfAHeader() throws Exception { + double gap = gapLeavingTheLastRowNearTheFoot(false); + Exported exported = export(page -> { + page.addSpacer(spacer -> spacer.height(gap)); + card(20, false, null, null).accept(page); + }); + + assertThat(cutNote(exported.report())).contains("written as one table").doesNotContain("header"); + } + + @Test + void aPartOpeningItsPageNeedsRoomForItsTopBorderAndTheParagraphBeforeItToo() throws Exception { + // A page height at which page 2's last row leaves room for a bottom border and a point, + // and not for the top border and the hairline paragraph the part opening page 2 sets too. + double height = heightLeavingTheMiddlePartTooLittleRoom(); + Exported exported = export(height, card(30, true, null, null)); + + assertThat(parts(exported.document())).as("cut where page 2 starts, not where page 3 does").hasSize(2); + assertThat(cutNote(exported.report())).contains("cut at the row page 2 starts with") + .contains("it is not cut where page 3 starts, the page above setting its rows too near its foot"); + } + + @Test + void aPageTheCardRunsOntoAfterItsTableIsNamed() throws Exception { + // A gap after the table moves the card's closing line to page 3. + Exported exported = export(card(20, true, null, below -> below.addSpacer(spacer -> spacer.height(240)) + .addParagraph("Closing"))); + + assertThat(exported.cardPages()).as("the premise: the card runs over three pages").isEqualTo(3); + assertThat(exported.pages()).as("the premise: its table over two").hasSize(2); + assertThat(parts(exported.document())).hasSize(2); + assertThat(cutNote(exported.report())).contains("it is not cut where page 3 starts, outside its table"); } @Test @@ -216,12 +255,12 @@ void aTableDeeperThanThePanelsOwnChildrenIsNotCut() throws Exception { * A gap above the card that leaves its table's last row on page 1 less than its 1pt bottom * border and a point above the page's foot, found by laying the page out. */ - private static double gapLeavingTheLastRowNearTheFoot() { + private static double gapLeavingTheLastRowNearTheFoot(boolean header) { for (double gap = 0; gap < 40; gap += 0.25) { double height = gap; try (DocumentSession session = session(page -> { page.addSpacer(spacer -> spacer.height(height)); - card(20, true, null, null).accept(page); + card(20, header, null, null).accept(page); })) { DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph()); TableNode table = first(session.documentGraph().roots(), TableNode.class); @@ -234,6 +273,30 @@ private static double gapLeavingTheLastRowNearTheFoot() { throw new AssertionError("no gap leaves the last row near the foot"); } + /** + * A page height at which a 30-row card runs over three pages, its rows on page 1 ending far + * enough above the foot for the first part's 1pt bottom border and a point, and those on page 2 + * ending room enough for that but not for the second part's 1pt top border and 0.1pt paragraph + * too, found by laying the pages out. + */ + private static double heightLeavingTheMiddlePartTooLittleRoom() { + for (double height = 280; height < 340; height += 0.25) { + try (DocumentSession session = session(height, card(30, true, null, null))) { + DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph()); + TableNode table = first(session.documentGraph().roots(), TableNode.class); + if (layout.placement(table).startPage() != 0 || layout.placement(table).endPage() != 2) { + continue; + } + double first = layout.lowestRowOn(table, 0) - layout.marginOf(0).bottom(); + double second = layout.lowestRowOn(table, 1) - layout.marginOf(1).bottom(); + if (first >= 2.05 && second >= 2.05 && second < 3.05) { + return height; + } + } + } + throw new AssertionError("no page height leaves the middle part too little room"); + } + @Test void aPanelInAnEarlierSectionIsCutByItsOwnSectionsLayout() throws Exception { // Each section is laid out on its own: the cut reads the card's pages off its section's. @@ -247,7 +310,22 @@ void aPanelInAnEarlierSectionIsCutByItsOwnSectionsLayout() throws Exception { } assertThat(parts(new XWPFDocument(new ByteArrayInputStream(docx)))).hasSize(2); - assertThat(cutNote(report.get())).contains("cut at the row page 2 starts with"); + assertThat(cutNote(report.get())).contains("cut at the row section 1, page 2 starts with"); + } + + @Test + void aPanelInALaterSectionNamesItsPagesAsItsSectionCountsThem() throws Exception { + AtomicReference report = new AtomicReference<>(); + byte[] docx; + try (MultiSectionDocument sections = GraphCompose.documents() + .section(session(page -> page.addParagraph("First section"))) + .section(session(card(20, true, null, null))) + .create()) { + docx = sections.export(DocxSemanticBackend.builder().reportSink(report::set).build()); + } + + assertThat(parts(new XWPFDocument(new ByteArrayInputStream(docx)))).hasSize(2); + assertThat(cutNote(report.get())).contains("cut at the row section 2, page 2 starts with"); } @Test @@ -340,14 +418,23 @@ private record Exported(XWPFDocument document, DocxExportReport report, List content) { - DocumentSession session = GraphCompose.document().pageSize(400, 300).margin(DocumentInsets.of(20)).create(); + return session(300, content); + } + + private static DocumentSession session(double pageHeight, Consumer content) { + DocumentSession session = GraphCompose.document().pageSize(400, pageHeight).margin(DocumentInsets.of(20)) + .create(); session.pageFlow(content::accept); return session; } private static Exported export(Consumer content) throws Exception { + return export(300, content); + } + + private static Exported export(double pageHeight, Consumer content) throws Exception { AtomicReference report = new AtomicReference<>(); - try (DocumentSession session = session(content)) { + try (DocumentSession session = session(pageHeight, content)) { byte[] docx = session.export(new DocxSemanticBackend(report::set)); DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph()); List> pages = new ArrayList<>();