diff --git a/CHANGELOG.md b/CHANGELOG.md index 238756805..d622bef71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export writes top and bottom margins that change from page to page as Word + sections.** + - `DocumentSession.pageMargins(...)` reached the layout only. The DOCX wrote the canvas's + margins on every page and said nothing: a page with a larger bottom margin ran 7 lines of + 33 further in Word and LibreOffice, past its foot, and a rule for page 1 alone was lost too. + - The internal layout graph carries the margins each page is laid out against + (`LayoutGraph.pageGeometry`), and the export writes the first page's top and bottom margins + as the section's. A later page whose top and bottom margins differ from those Word sets it + with starts a Word section with them, where it starts between blocks of the body. An + explicit page break there becomes the section's break, the hairline between two tables + carries its end, and the title-page header and footer stay with the first section, as, in a + document of several sections, does its page count from 1. Measured in Word and LibreOffice + on Windows — on pages of paragraphs, on a page after a page break, after a table, between + two tables and under a card's held margin — every page stood + where the layout sets it, line for line. The report names each such section (`page + margin`): its page break is fixed when the text above it is edited. The room for the line + closing a section, and the space above a block the layout opens a page with, are reckoned + against that page's margins. + - Side margins stay the document's, which the body is written against — a rule's length and a + panel's room among them — and from which the page sets its header and footer. A page whose + side margins differ is named. + - The report names, and the export starts no section at, a page that starts inside a block — + the rows of a table, the lines of a paragraph, a panel — or after a page whose content + reaches its foot under a table, and a page of a document of several sections whose header or + footer counts its section's pages, which Word would count apart. The four invoice presets + that set another bottom margin from page 2 start their second page inside their line items, + so their later pages are named, not written. + - The corpus is unchanged: none of its documents has margins that change from page to page. + - **A DOCX export names a document's timeline rails once.** - Every rail the layout draws shares one path, so each timeline named its rail again under it, word for word, and a rail running onto another page named it again there: @@ -3871,6 +3900,11 @@ follow semantic versioning; release dates are ISO 8601. ### Tests +- **The DOCX field ledger names what a session hands an export beside the tree.** Every part + of `SemanticExportContext` and of the `LayoutGraph` it carries has a fate, and a part with + none fails the ledger. A session's per-page margins reached the layout, no export read them, + and no entry said so. + - **The DOCX field ledger states where a field it gave as written, or as moving nothing, is lost.** - A drawing the export drops — with no layout, in a page zone, painted only with a gradient diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java b/core/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java index 1ad35ef74..713f4e158 100644 --- a/core/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java +++ b/core/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java @@ -70,7 +70,6 @@ static LayoutGraph apply(LayoutGraph base, List fills) { } } combined.addAll(base.fragments()); - return new LayoutGraph(base.canvas(), base.totalPages(), - base.nodes(), combined); + return base.withFragments(combined); } } diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentPageZones.java b/core/src/main/java/com/demcha/compose/document/api/DocumentPageZones.java index acfc20a3e..84905370d 100644 --- a/core/src/main/java/com/demcha/compose/document/api/DocumentPageZones.java +++ b/core/src/main/java/com/demcha/compose/document/api/DocumentPageZones.java @@ -94,7 +94,7 @@ static LayoutGraph apply(LayoutGraph base, compiler, registry, resources, markdown)); } } - return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined); + return base.withFragments(combined); } private static List placeZone(DocumentPageZone zone, diff --git a/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java b/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java index 9f7332fb6..5300384c4 100644 --- a/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java +++ b/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java @@ -131,7 +131,7 @@ static LayoutGraph apply(LayoutGraph base, List passes) { combined.addAll(under.getOrDefault(body.size(), List.of())); combined.addAll(over); // Nodes pass through untouched: a pass contributes drawing, never structure. - return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined); + return base.withFragments(combined); } /** diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index bb4872714..8716f72ee 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -72,7 +72,8 @@ public LayoutGraph compile(DocumentGraph graph, PrepareContext prepareContext, F LayoutCanvas canvas = prepareContext.canvas(); LAYOUT_LOG.debug("layout.compile.start roots={} canvas={}x{}", graph.roots().size(), Math.round(canvas.width()), Math.round(canvas.height())); PAGINATION_LOG.debug("pagination.compile.start roots={} innerHeight={}", graph.roots().size(), Math.round(canvas.innerHeight())); - CompilerState state = new CompilerState(canvas, prepareContext.pageGeometry()); + PageGeometry pageGeometry = prepareContext.pageGeometry(); + CompilerState state = new CompilerState(canvas, pageGeometry); List nodes = new ArrayList<>(); List fragments = new ArrayList<>(); @@ -107,7 +108,7 @@ public LayoutGraph compile(DocumentGraph graph, PrepareContext prepareContext, F int totalPages = nodes.isEmpty() && fragments.isEmpty() ? 0 : state.maxTouchedPage + 1; - LayoutGraph layoutGraph = new LayoutGraph(canvas, totalPages, nodes, fragments); + LayoutGraph layoutGraph = new LayoutGraph(canvas, totalPages, nodes, fragments, pageGeometry); PAGINATION_LOG.debug( "pagination.compile.end roots={} pages={} nodes={} fragments={} durationMs={}", graph.roots().size(), diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutGraph.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutGraph.java index d4f6eef58..3b1bc9ca7 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/LayoutGraph.java +++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutGraph.java @@ -6,25 +6,53 @@ /** * Canonical resolved v2 layout graph. * - * @param canvas physical page canvas used for the graph - * @param totalPages number of pages required by the graph - * @param nodes placed semantic nodes in deterministic order - * @param fragments placed render fragments in deterministic order + * @param canvas physical page canvas used for the graph + * @param totalPages number of pages required by the graph + * @param nodes placed semantic nodes in deterministic order + * @param fragments placed render fragments in deterministic order + * @param pageGeometry the margin each page was laid out against — the canvas's on every page + * unless the document sets per-page margins + * @since 2.5.0 carries {@code pageGeometry} */ public record LayoutGraph( LayoutCanvas canvas, int totalPages, List nodes, - List fragments + List fragments, + PageGeometry pageGeometry ) { /** - * Validates required graph fields and freezes ordered node/fragment lists. + * Validates required graph fields and freezes ordered node/fragment lists. A graph given no + * page geometry has the canvas's margin on every page. */ public LayoutGraph { Objects.requireNonNull(canvas, "canvas"); nodes = List.copyOf(nodes); fragments = List.copyOf(fragments); + pageGeometry = pageGeometry == null ? PageGeometry.of(canvas, List.of()) : pageGeometry; } -} + /** + * A graph laid out against the canvas's margin on every page. + * + * @param canvas physical page canvas used for the graph + * @param totalPages number of pages required by the graph + * @param nodes placed semantic nodes in deterministic order + * @param fragments placed render fragments in deterministic order + */ + public LayoutGraph(LayoutCanvas canvas, int totalPages, List nodes, List fragments) { + this(canvas, totalPages, nodes, fragments, null); + } + /** + * This layout with other fragments: what a pass that draws over a laid-out document gives + * back, its pages, nodes and page geometry the same. + * + * @param fragments the fragments in their paint order + * @return the layout with them + * @since 2.5.0 + */ + public LayoutGraph withFragments(List fragments) { + return new LayoutGraph(canvas, totalPages, nodes, fragments, pageGeometry); + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/PageGeometry.java b/core/src/main/java/com/demcha/compose/document/layout/PageGeometry.java index 9eb571063..1efd618fe 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/PageGeometry.java +++ b/core/src/main/java/com/demcha/compose/document/layout/PageGeometry.java @@ -76,4 +76,24 @@ public double innerWidthForPage(int pageIndex) { public double innerHeightForPage(int pageIndex) { return Math.max(0.0, canvas.height() - marginForPage(pageIndex).vertical()); } + + /** + * Equal to another resolver of the same canvas and overrides, as the layout graph that + * carries it is equal to one laid out alike. + */ + @Override + public boolean equals(Object other) { + return other instanceof PageGeometry geometry + && canvas.equals(geometry.canvas) && overrides.equals(geometry.overrides); + } + + @Override + public int hashCode() { + return Objects.hash(canvas, overrides); + } + + @Override + public String toString() { + return "PageGeometry[canvas=" + canvas + ", overrides=" + overrides + "]"; + } } diff --git a/core/src/test/java/com/demcha/compose/document/layout/PageGeometryTest.java b/core/src/test/java/com/demcha/compose/document/layout/PageGeometryTest.java new file mode 100644 index 000000000..880c989d1 --- /dev/null +++ b/core/src/test/java/com/demcha/compose/document/layout/PageGeometryTest.java @@ -0,0 +1,37 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.engine.components.style.Margin; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A page geometry is a value: a layout graph carries it, and two layouts laid out alike are equal + * and print alike. + */ +class PageGeometryTest { + + private static final LayoutCanvas CANVAS = LayoutCanvas.from(400, 600, Margin.of(36)); + + @Test + void twoResolversOfTheSameCanvasAndOverridesAreEqual() { + PageGeometry one = PageGeometry.of(CANVAS, List.of(new PageMarginOverride(1, Integer.MAX_VALUE, + new Margin(36, 36, 136, 36)))); + PageGeometry other = PageGeometry.of(CANVAS, List.of(new PageMarginOverride(1, Integer.MAX_VALUE, + new Margin(36, 36, 136, 36)))); + + assertThat(one).isEqualTo(other).hasSameHashCodeAs(other); + assertThat(one.toString()).isEqualTo(other.toString()); + assertThat(one).isNotEqualTo(PageGeometry.of(CANVAS, List.of())); + } + + @Test + void aLayoutGraphGivenNoGeometryHasTheCanvasMarginOnEveryPage() { + LayoutGraph graph = new LayoutGraph(CANVAS, 2, List.of(), List.of()); + + assertThat(graph.pageGeometry()).isEqualTo(PageGeometry.of(CANVAS, List.of())); + assertThat(graph.pageGeometry().marginForPage(1)).isEqualTo(CANVAS.margin()); + } +} diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 534607780..01fe5f666 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -133,6 +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 inside a block (a table's rows, a paragraph's lines, a panel), after a page whose content reaches its foot under a table, 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 4f1ec820f..7ed4a21f5 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -169,6 +169,32 @@ Styles, fonts and bookmark names are shared across the document, so a link in th reaches an anchor in the body. `export(backend)` takes any semantic backend; one that cannot combine sections refuses more than one. +### Margins that change from page to page + +A Word section holds one set of margins. A page that `session.pageMargins(...)` sets against +other top and bottom margins than Word sets it with starts a section of its own with them, +where it starts between blocks of the body: the paragraph that ends the page before carries the +section's end, an explicit page break there becomes the section's break, a hairline between two +tables carries it there, and the first page's title-page header and footer stay with the first +section, as, in a document of several sections, does its page count from 1. Measured in Word +and LibreOffice on Windows — on pages of paragraphs, on a page after a page break, after a +table, between two tables and under a card's held margin — a page break into +a section with the later page's margins set every page where the page sets it, line for line; +one set of margins for the whole document ran a page with a larger bottom margin on past its +foot, 7 lines of 33 further. The report names the section (`page margin`): its page break is +fixed, so text an edit adds above it no longer runs onto that page. + +Side margins stay the document's. The body is written against its width — a rule's length, a +panel's room — and the page sets its header and footer there, so a page whose side margins +differ is named and Word sets its text between the document's. + +A section ends in a paragraph of the body, so a page that starts inside a block — the rows of a +table, the lines of a paragraph, a panel — does not start one, nor does a page after one whose +content reaches its foot under a table, where the line that would end the section has no room. +Neither does a page of a document of several sections whose header or footer counts its +section's pages: Word would count a section of its own apart. The report names each (`page +margin`), on the block where there is one, and Word sets that page with the margins in force. + ## Finding out what the export could not carry The export says what it drops — but it says it to the log, which a service generating diff --git a/qa/src/test/java/com/demcha/compose/document/api/PageMarginTest.java b/qa/src/test/java/com/demcha/compose/document/api/PageMarginTest.java index b6b645b64..8b0282101 100644 --- a/qa/src/test/java/com/demcha/compose/document/api/PageMarginTest.java +++ b/qa/src/test/java/com/demcha/compose/document/api/PageMarginTest.java @@ -71,6 +71,54 @@ void perPageMarginShiftsTheContentBoxHorizontallyAndVertically() throws Exceptio } } + @Test + void theLayoutGraphCarriesTheMarginEachPageIsLaidOutAgainst() throws Exception { + // A semantic backend reads it to lay its own pages out alike. A page background, a + // zone and a timeline's rail each add their fragments to a new graph, which keeps it. + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageMargins(List.of( + PageMarginRule.page(1, DocumentInsets.symmetric(15, 10)), + PageMarginRule.from(2, DocumentInsets.symmetric(40, 70)))); + session.pageBackground(com.demcha.compose.document.style.DocumentColor.rgb(250, 250, 250)); + session.chrome().zone(com.demcha.compose.document.output.DocumentPageZone.header(18, + page -> new com.demcha.compose.document.dsl.ParagraphBuilder().text("Header").build())); + com.demcha.compose.document.style.DocumentColor ink = com.demcha.compose.document.style.DocumentColor.rgb(26, 86, 148); + session.pageFlow() + .addTimeline(timeline -> timeline.connector(ink, 1) + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(6, ink), entry -> entry.title("One")) + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(6, ink), entry -> entry.title("Two"))) + .addPageBreak(b -> { }) + .addParagraph(p -> p.text("Body")) + .build(); + + LayoutGraph graph = session.layoutGraph(); + + assertThat(graph.fragments()).as("the passes ran") + .anyMatch(fragment -> fragment.path() != null && fragment.path().startsWith("@timeline-rail")); + assertThat(graph.pageGeometry().marginForPage(0).left()).isCloseTo(10.0, within(0.01)); + assertThat(graph.pageGeometry().marginForPage(1).left()).isCloseTo(70.0, within(0.01)); + assertThat(graph.pageGeometry().marginForPage(4).left()).isCloseTo(70.0, within(0.01)); + } + } + + @Test + void aLayoutWithNoPageMarginsHasTheCanvasMarginOnEveryPage() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 300) + .margin(new DocumentInsets(11, 12, 13, 14)) + .create()) { + session.pageFlow().addParagraph(p -> p.text("Body")).build(); + + LayoutGraph graph = session.layoutGraph(); + + assertThat(graph.pageGeometry().marginForPage(0)).isEqualTo(graph.canvas().margin()); + assertThat(graph.pageGeometry().marginForPage(3)).isEqualTo(graph.canvas().margin()); + } + } + @Test void naturalOverflowResolvesEachBlockAtItsStartPageWidth() throws Exception { // Same vertical margin on both pages (so pagination is width-independent), 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 17ccdc8f6..079338ebd 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 @@ -54,7 +54,7 @@ final class DocxLayoutMetrics { /** What an export with no compiled layout uses: every question answers "unknown". */ static final DocxLayoutMetrics EMPTY = - new DocxLayoutMetrics(new IdentityHashMap<>(), java.util.Set.of(), Map.of(), Map.of(), List.of(), 0); + new DocxLayoutMetrics(new IdentityHashMap<>(), java.util.Set.of(), Map.of(), Map.of(), List.of(), 0, null); private final Map paths; // Nodes one instance of which stands at more than one place — see placedMoreThanOnce. @@ -64,6 +64,8 @@ final class DocxLayoutMetrics { // Every fragment, in the order the page paints them. private final List painted; private final int pageCount; + // The margin each page was laid out against, or null with no layout. + private final com.demcha.compose.document.layout.PageGeometry pageGeometry; // A table's measured cells by name, filled on first use — see measuredCellsOf. private final Map> measuredCells = new IdentityHashMap<>(); private final Map>> cellBoxes = new IdentityHashMap<>(); @@ -89,13 +91,15 @@ private DocxLayoutMetrics(Map paths, Map> fragments, Map placed, List painted, - int pageCount) { + int pageCount, + com.demcha.compose.document.layout.PageGeometry pageGeometry) { this.paths = paths; this.repeated = repeated; this.fragments = fragments; this.placed = placed; this.painted = painted; this.pageCount = pageCount; + this.pageGeometry = pageGeometry; } /** @@ -121,7 +125,7 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) { if (layout == null) { // No measurements, but the paths still name the nodes — which is what a // diagnostic note needs to say where in the document it came from. - return new DocxLayoutMetrics(paths, repeated, Map.of(), Map.of(), List.of(), 0); + return new DocxLayoutMetrics(paths, repeated, Map.of(), Map.of(), List.of(), 0, null); } Map> fragments = new HashMap<>(); for (PlacedFragment fragment : layout.fragments()) { @@ -131,7 +135,18 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) { for (PlacedNode node : layout.nodes()) { placed.putIfAbsent(node.path(), node); } - return new DocxLayoutMetrics(paths, repeated, fragments, placed, layout.fragments(), layout.totalPages()); + return new DocxLayoutMetrics(paths, repeated, fragments, placed, layout.fragments(), layout.totalPages(), + layout.pageGeometry()); + } + + /** + * The margin the layout set a page against: the canvas's, or a per-page margin's. + * + * @param page the page, from 0 + * @return its margin, or {@code null} with no layout behind this index + */ + com.demcha.compose.engine.components.style.Margin marginOf(int page) { + return pageGeometry == null ? null : pageGeometry.marginForPage(page); } /** @@ -1880,6 +1895,24 @@ List clipsOf(DocumentNode node) { record Clip(PlacedFragment opening, List painted) { } + /** + * Where a page's content ends, in points up from the page's foot: the bottom of its lowest + * fragment. A page's backgrounds, its zones and a timeline's rail — fragments under a path of + * the layout's own, starting {@code @} — are drawn elsewhere and take no room in the flow. + * + * @param page the page, from 0 + * @return the bottom of its lowest fragment, or {@code NaN} where it holds no content + */ + double lowestContentOn(int page) { + double lowest = Double.NaN; + for (PlacedFragment fragment : paintedOn(page)) { + if (fragment.path() != null && !fragment.path().startsWith("@")) { + lowest = Double.isNaN(lowest) ? fragment.y() : Math.min(lowest, fragment.y()); + } + } + return lowest; + } + private List paintedOn(int page) { if (paintedByPage == null) { paintedByPage = new HashMap<>(); 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 new file mode 100644 index 000000000..ad00a69eb --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java @@ -0,0 +1,403 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.engine.components.style.Margin; +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.XWPFRun; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.xmlbeans.XmlCursor; +import org.apache.xmlbeans.XmlObject; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBrType; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Writes the top and bottom margins of a page the layout sets against other ones than Word sets + * it with — {@code DocumentSession.pageMargins} — as a Word section that starts where that page + * starts. + * + *

A Word section holds one set of margins, and its first page starts on a page of its own. + * 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 — a page break into a section with + * the later page's margins sets that page and the pages after it where the layout does, line for + * line; + * with one set of margins for the whole document, a page whose bottom margin is larger ran its + * text on past the page's foot. A section starting in the middle of a page, which Word reads as + * taking its top and bottom margins from the next page, LibreOffice does not.

+ * + *

Side margins stay the document's: the body is written against its width, a rule's length + * and a panel's room included, and the page sets its header and footer there. A page whose side + * margins differ is named.

+ * + *

A section ends in a paragraph of the body, so it can start only between blocks written + * there. Where the page starts inside a block — the rows of a table, the lines of a paragraph, + * a panel — its margins are named instead, and Word sets that page with the margins in force.

+ * + * @author Artem Demchyshyn + */ +final class DocxPageMarginSections { + + /** What the report calls a page's margins. */ + static final String PAGE_MARGIN = "page margin"; + + private static final double POINT_TO_TWIP = 20; + + // A section's end needs a line under the block before it; past a table there is none but a + // hairline, the one it is given or the one between two tables, which a page whose content + // reaches its foot moves onto a page of its own. Two points, as a table's rows rounded to whole + // twips stand an editor's page of them a little lower than the layout's. + private static final double ROOM_FOR_THE_END = 2; + + /** + * The pages a body element's content stands on, from the node that wrote it. + * + * @param first the page it starts on, from 0 + * @param last the page it ends on + * @param node the node that wrote it + */ + record Owner(int first, int last, DocumentNode node) { + } + + /** + * Where a section starts. + * + * @param page its first page, from 0 + * @param margin the margins its top and bottom are written from + * @param carrier what ends the section before it + */ + 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}. + * + * @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, boolean hairline) { + } + + /** + * Where a section's pages start Word sections of their own. + * + * @param opening the margins its first page is written with, {@code null} with no layout + * @param starts each later section, in page order + * @param zoned whether the section has a header or footer, which a narrower margin holds the + * body clear of + */ + record Plan(Margin opening, List starts, boolean zoned) { + + /** + * The bottom margin Word sets the section's last page with, in points. + * + * @return that margin, or {@code NaN} with no layout + */ + double lastPageBottom() { + Margin margin = starts.isEmpty() ? opening : starts.get(starts.size() - 1).margin(); + return margin == null ? Double.NaN : margin.bottom(); + } + } + + private DocxPageMarginSections() { + } + + /** + * Whether any page of a layout is set against other margins than the page before it: only + * then are the body's elements worth telling apart by page. + * + * @param layout the section's layout + * @return whether the margins change from one page to another + */ + static boolean vary(DocxLayoutMetrics layout) { + if (layout.marginOf(0) == null) { + return false; + } + for (int page = 1; page < layout.pageCount(); page++) { + if (!sameTopAndBottom(layout.marginOf(page), layout.marginOf(page - 1))) { + return true; + } + } + return false; + } + + /** + * Decides where a section's pages start Word sections, and names each page set against + * margins it does not write. Run once the section's body is written, before {@link #apply}. + * + * @param document the document, its section's body written + * @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 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 + * @param countsItsPages whether the section's header or footer counts the section's own pages, as + * one of a document of several does + * @param sides the document's own margins, which the body is written against, or + * {@code null} where none are known + * @param zoned whether the section has a header or footer + * @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) { + Margin opening = layout.marginOf(0); + nameTheSides(layout, report, section, sides); + if (!vary(layout)) { + return new Plan(opening, List.of(), zoned); + } + List all = document.getBodyElements(); + List elements = new ArrayList<>(all.subList(Math.min(from, all.size()), all.size())); + List starts = new ArrayList<>(); + Margin inForce = opening; + int inForceFrom = 0; + DocumentNode lastNamed = null; + for (int page = 1; page < layout.pageCount(); page++) { + Margin margin = layout.marginOf(page); + if (sameTopAndBottom(margin, inForce)) { + continue; + } + String differ = "page " + (page + 1) + "'s top and bottom margins differ from page " + (inForceFrom + 1) + + "'s"; + String notWritten = differ + ", which Word sets it with: "; + if (countsItsPages) { + // Word counts each section's own pages, and this section's page count would split. + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, notWritten + + "a Word section would count its own pages, where this section's header or footer counts " + + "all of them"); + return new Plan(opening, List.of(), zoned); + } + Owner inside = spanning(page, elements, owners); + int at = inside != null ? -1 : firstOn(page, elements, owners); + if (at >= 0 && owners.get(elements.get(at)).first() != page) { + // The first block after it starts on a later page: none starts this one. + at = -1; + } + if (at < 0) { + if (inside == null || inside.node() != lastNamed) { + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, + inside != null ? layout.pathOf(inside.node()) : section, + notWritten + (inside != null ? "page " + (page + 1) + " starts inside this block" + : "no block of the body starts page " + (page + 1)) + + ", where the export starts no Word section"); + lastNamed = inside != null ? inside.node() : null; + } + continue; + } + Carrier carrier = carrierBefore(at, elements, owners, separators); + // 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) { + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, notWritten + "page " + page + + "'s content reaches its foot, where the paragraph that would end a Word section before page " + + (page + 1) + " would open a page of its own"); + continue; + } + starts.add(new Start(page, margin, carrier)); + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, differ + ": written as a Word " + + "section that starts where page " + (page + 1) + " does, its page break fixed there, so text an " + + "edit adds above it no longer runs onto that page"); + inForce = margin; + inForceFrom = page; + } + return new Plan(opening, starts, zoned); + } + + /** + * Names each run of pages whose side margins differ from the document's, once: they are not + * written, as the body is written against the document's width. + */ + private static void nameTheSides(DocxLayoutMetrics layout, DocxExportReport.Builder report, String section, + Margin sides) { + if (sides == null || layout.marginOf(0) == null) { + return; + } + for (int page = 0; page < layout.pageCount(); page++) { + Margin margin = layout.marginOf(page); + boolean differs = !sameSides(margin, sides); + boolean opensARun = page == 0 || !sameSides(margin, layout.marginOf(page - 1)); + if (differs && opensARun) { + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, "page " + (page + 1) + + "'s side margins differ from the document's and are not written: the body is written " + + "against the document's width, and Word sets its text between the document's side margins"); + } + } + } + + /** + * 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. + * + * @param document the document, its section written + * @param plan where its pages start sections + */ + static void apply(XWPFDocument document, Plan plan) { + if (plan.starts().isEmpty()) { + return; + } + CTSectPr body = bodySectPr(document); + CTSectPr opening = (CTSectPr) body.copy(); + Margin margin = plan.opening(); + boolean first = true; + for (Start start : plan.starts()) { + CTSectPr ending = (CTSectPr) opening.copy(); + if (!first) { + asALaterSection(ending, opening, margin, plan.zoned()); + } + XWPFParagraph carrier = carrierOf(document, start.carrier()); + 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); + margin = start.margin(); + first = false; + } + asALaterSection(body, opening, margin, plan.zoned()); + } + + /** The last block written before a page that runs onto it, if one does. */ + private static Owner spanning(int page, List elements, Map owners) { + Owner spanning = null; + for (IBodyElement element : elements) { + Owner owner = owners.get(element); + if (owner == null) { + continue; + } + if (owner.first() >= page) { + break; + } + spanning = owner.last() >= page ? owner : null; + } + return spanning; + } + + /** The index of the first element written for a page or after it, or -1 where none is. */ + private static int firstOn(int page, List elements, Map owners) { + for (int index = 0; index < elements.size(); index++) { + Owner owner = owners.get(elements.get(index)); + if (owner != null && owner.first() >= page) { + return index; + } + } + return -1; + } + + /** + * What ends the section before the element at a start: a page break written there, which the + * section's own break replaces — kept, it would open a blank page; the hairline between two + * tables; or else the paragraph before it, or a hairline put before it. + */ + private static Carrier carrierBefore(int at, List elements, Map owners, + Set separators) { + IBodyElement element = elements.get(at); + IBodyElement before = at > 0 ? elements.get(at - 1) : null; + if (element instanceof XWPFParagraph breaking && owners.get(breaking).node() instanceof PageBreakNode) { + return new Carrier(breaking, null, null, false); + } + if (element instanceof XWPFParagraph separator && separators.contains(separator)) { + return new Carrier(null, separator, null, true); + } + if (before instanceof XWPFParagraph last) { + return new Carrier(null, last, null, false); + } + return new Carrier(null, null, element, true); + } + + private static XWPFParagraph carrierOf(XWPFDocument document, Carrier carrier) { + if (carrier.pageBreak() != null) { + for (XWPFRun run : carrier.pageBreak().getRuns()) { + List breaks = run.getCTR().getBrList(); + for (int index = breaks.size() - 1; index >= 0; index--) { + if (breaks.get(index).getType() == STBrType.PAGE) { + run.getCTR().removeBr(index); + } + } + } + return hairline(carrier.pageBreak()); + } + if (carrier.paragraph() != null) { + return carrier.paragraph(); + } + XmlObject at = carrier.at() instanceof XWPFTable table ? table.getCTTbl() : ((XWPFParagraph) carrier.at()).getCTP(); + try (XmlCursor cursor = at.newCursor()) { + return hairline(document.insertNewParagraph(cursor)); + } + } + + /** + * Turns a copy of the section's properties into a later section's: its top and bottom margins, + * the first page its section opens with not its own, and its pages counted on rather than from + * 1. A top or bottom margin the section holds the body at — written negative, past a header or + * footer that reaches over it — is held here too, and so, in a section with a header or footer, + * is one narrower than the section's: held where nothing reaches over it, it holds the body + * where it stands. + */ + private static void asALaterSection(CTSectPr later, CTSectPr opening, Margin margin, boolean zoned) { + CTPageMar written = later.isSetPgMar() ? later.getPgMar() : later.addNewPgMar(); + CTPageMar first = opening.isSetPgMar() ? opening.getPgMar() : null; + written.setTop(BigInteger.valueOf(held(first == null ? null : first.getTop(), margin.top(), zoned))); + written.setBottom(BigInteger.valueOf(held(first == null ? null : first.getBottom(), margin.bottom(), zoned))); + if (later.isSetTitlePg()) { + later.unsetTitlePg(); + } + if (later.isSetPgNumType() && later.getPgNumType().isSetStart()) { + later.getPgNumType().unsetStart(); + } + } + + /** + * A later section's top or bottom margin in twips: negative where the opening section's is, or, + * with a header or footer, where it is narrower than that. + * + * @param opening the opening section's margin on that side, in twips as written + * @param points the later section's margin + * @param zoned whether the section has a header or footer + */ + static long held(Object opening, double points, boolean zoned) { + long twips = toTwips(points); + long side = opening instanceof Number number ? number.longValue() : twips; + return side < 0 || zoned && twips < side ? -Math.max(1, twips) : twips; + } + + private static XWPFParagraph hairline(XWPFParagraph paragraph) { + CTPPr properties = paragraph.getCTP().isSetPPr() ? paragraph.getCTP().getPPr() : paragraph.getCTP().addNewPPr(); + CTSpacing spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing(); + spacing.setBefore(BigInteger.ZERO); + spacing.setAfter(BigInteger.ZERO); + spacing.setLineRule(STLineSpacingRule.EXACT); + spacing.setLine(BigInteger.valueOf(2)); + return paragraph; + } + + private static boolean sameTopAndBottom(Margin a, Margin b) { + return toTwips(a.top()) == toTwips(b.top()) && toTwips(a.bottom()) == toTwips(b.bottom()); + } + + private static boolean sameSides(Margin a, Margin b) { + return toTwips(a.right()) == toTwips(b.right()) && toTwips(a.left()) == toTwips(b.left()); + } + + private static CTSectPr bodySectPr(XWPFDocument document) { + var body = document.getDocument().getBody(); + return body.isSetSectPr() ? body.getSectPr() : body.addNewSectPr(); + } + + private static long toTwips(double points) { + return Math.max(0, Math.round(points * POINT_TO_TWIP)); + } +} 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 ab4e808f8..228fb6a52 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 @@ -468,6 +468,18 @@ public final class DocxSemanticBackend implements SemanticBackend { // The note what is drawn now is drawn for: one the section's end finds nothing to carry is // named there instead (endSection). Null where no note names it. private DocxDrawingAnchors.Owner drawingFor; + // The pages and node of each body element the section writes, where a page with other + // margins starts (DocxPageMarginSections). + private final java.util.Map bodyOwners = + new java.util.IdentityHashMap<>(); + // Whether the section's pages are laid out against more than one set of margins. + private boolean marginsVary; + // Whether the section's header or footer counts its own pages (SECTIONPAGES), which a Word + // section of its own for a page with other margins would count apart. + private boolean countsItsSectionPages; + // The hairlines the body sets between two tables, which a section may end in. + private final java.util.Set tableSeparators = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); // Where the section being written starts in the report: its notes' paths are its own. private int sectionNotesFrom; // Those of them a rule composed in a cell took, written as its border instead (takeTheDrawingOf). @@ -1285,11 +1297,19 @@ private byte[] write(List sections, Path outputFile) throws Exc settleTheLineAbove(); raiseRows(); lowerTheLinesSetHigh(); + // Where its pages start Word sections of their own, which sets the margins of its + // last page, under which what closes it must fit. + 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); // 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, - dropTheSpaceAtTheEnd(document, roomBelowTheFlow(context.layoutGraph()), + dropTheSpaceAtTheEnd(document, roomBelowTheFlow(margins.lastPageBottom()), index == sections.size() - 1)); + DocxPageMarginSections.apply(document, margins); } // Every bookmark is written now, those a reference on an earlier page reads included. nameTheBlocksNoBookmarkMarks(); @@ -1383,6 +1403,10 @@ private void beginSection(SemanticSection section, int index) { forgetTheHang(); // The layout counts a section's pages from its own first. lastEndPage = -1; + bodyOwners.clear(); + marginsVary = DocxPageMarginSections.vary(layout); + countsItsSectionPages = false; + tableSeparators.clear(); lastBodyParagraph = null; lastWrittenNode = null; lastWrittenParagraph = null; @@ -2364,8 +2388,11 @@ private void appendBandSlot(XWPFParagraph para, String text, DocumentTextStyle s run.setText(segment.text()); } case PAGE -> appendField(para, " PAGE" + format + " ", numerals.format(1), style); - case PAGES -> appendField(para, (sectioned ? " SECTIONPAGES" : " NUMPAGES") + format + " ", - numerals.format(Math.max(1, layout.pageCount())), style); + case PAGES -> { + countsItsSectionPages |= sectioned; + appendField(para, (sectioned ? " SECTIONPAGES" : " NUMPAGES") + format + " ", + numerals.format(Math.max(1, layout.pageCount())), style); + } case DATE -> { XWPFRun run = para.createRun(); applyStyle(run, style); @@ -3529,6 +3556,7 @@ private void appendPageField(XWPFParagraph para, PageFieldNode field) { // A multi-section document numbers each section from 1, so the total a zone states is // its section's; in a document of one section the two are the same count. String total = sectioned ? " SECTIONPAGES " : " NUMPAGES "; + countsItsSectionPages |= sectioned && field.kind() == PageFieldKind.TOTAL; appendField(para, field.kind() == PageFieldKind.TOTAL ? total : " PAGE ", fieldPlaceholder(field.kind()), field.textStyle()); } @@ -3609,6 +3637,7 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio currentPage = placed.startPage(); } boolean inTheBody = currentCell == null; + int bodyElements = inTheBody && marginsVary ? document.getBodyElements().size() : -1; DocumentNode outer = writing; writing = node; try { @@ -3617,6 +3646,12 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio writing = outer; if (placed != null && inTheBody) { lastEndPage = Math.max(lastEndPage, placed.endPage()); + // What it wrote in the body and no node in it claimed is its own. + List elements = document.getBodyElements(); + for (int index = bodyElements; index >= 0 && index < elements.size(); index++) { + bodyOwners.putIfAbsent(elements.get(index), + new DocxPageMarginSections.Owner(placed.startPage(), placed.endPage(), node)); + } } if (placed != null && panelCell != null && currentCell == panelCell) { panelLastEndPage = Math.max(panelLastEndPage, placed.endPage()); @@ -9222,26 +9257,18 @@ private double roomForAClosingLine() { private static final double EDITOR_LINE_SHARE = 1.25; /** - * How far above its bottom margin a section's content ends on its last page, in points; 0 - * where its last page holds no content, and NaN where there is no layout to say. Only the - * content counts: a page's backgrounds, its zones - * with the fields in them and a timeline's rail — fragments under a path of the layout's - * own, starting {@code @} — are drawn elsewhere and take no room in the flow. + * How far above the bottom margin Word sets its last page with a section's content ends there, + * in points; 0 where its last page holds no content, and NaN where there is no layout to say. + * Only the content counts ({@link DocxLayoutMetrics#lowestContentOn}). + * + * @param bottom the bottom margin of the section's last page as written */ - private static double roomBelowTheFlow(com.demcha.compose.document.layout.LayoutGraph graph) { - if (graph == null || graph.totalPages() <= 0) { + private double roomBelowTheFlow(double bottom) { + if (layout.pageCount() <= 0) { return Double.NaN; } - int lastPage = graph.totalPages() - 1; - double lowest = Double.NaN; - for (com.demcha.compose.document.layout.PlacedFragment fragment : graph.fragments()) { - if (fragment.pageIndex() != lastPage || fragment.path() == null || fragment.path().startsWith("@")) { - continue; - } - // A fragment's y is its bottom edge, measured up from the page's foot. - lowest = Double.isNaN(lowest) ? fragment.y() : Math.min(lowest, fragment.y()); - } - return Double.isNaN(lowest) ? 0 : lowest - graph.canvas().margin().bottom(); + double lowest = layout.lowestContentOn(layout.pageCount() - 1); + return Double.isNaN(lowest) ? 0 : lowest - bottom; } /** @@ -19857,6 +19884,7 @@ private void separateFromTheTableAbove(XWPFDocument document) { } else { separator = newBodyParagraph(document); holdToHairline(separator); + tableSeparators.add(separator); } CTPPr properties = separator.getCTP().isSetPPr() ? separator.getCTP().getPPr() @@ -20061,7 +20089,10 @@ private double spaceAboveOnItsPage(DocumentNode node) { || Double.isNaN(canvasHeight) || Double.isNaN(canvasTopMargin)) { return Double.NaN; } - return canvasHeight - canvasTopMargin - placed.placementY() - placed.placementHeight(); + // The top margin of its page as the layout set it, a per-page margin's where one is. + double top = layout.marginOf(placed.startPage()) != null ? layout.marginOf(placed.startPage()).top() + : canvasTopMargin; + return canvasHeight - top - placed.placementY() - placed.placementHeight(); } /** @@ -20740,9 +20771,15 @@ private void applyPageGeometry(XWPFDocument document, LayoutCanvas canvas) { ? STPageOrientation.LANDSCAPE : STPageOrientation.PORTRAIT); + // The top and bottom margins its first page is laid out against: the canvas's, or a + // per-page margin's, which a later page's differing from them start a section of their own + // with (DocxPageMarginSections). The sides stay the canvas's, which the body is written + // against. + com.demcha.compose.engine.components.style.Margin first = + layout.marginOf(0) != null ? layout.marginOf(0) : canvas.margin(); CTPageMar margin = sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar(); - margin.setTop(BigInteger.valueOf(toTwips(canvas.margin().top()))); - margin.setBottom(BigInteger.valueOf(toTwips(canvas.margin().bottom()))); + margin.setTop(BigInteger.valueOf(toTwips(first.top()))); + margin.setBottom(BigInteger.valueOf(toTwips(first.bottom()))); margin.setLeft(BigInteger.valueOf(toTwips(canvas.margin().left()))); margin.setRight(BigInteger.valueOf(toTwips(canvas.margin().right()))); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index 4c0dbdee9..806948931 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -268,6 +268,28 @@ record Entry(Fate fate, String note) { + "lines placed from the edge, in a frame at its height where a measured zone shares its kind " + "with another page zone; its pictures are written in the line at the size the page draws them"); + // What a session hands a semantic export beside the tree and its output options, and what + // the layout it hands carries: a session's input reaches the export through these. + private static final Map EXPORT_CONTEXT = fields( + "canvas:WRITTEN:the page's size, its margins with no layout, and its side margins on every page, which " + + "the body is written against, a page's that differ from them named", + "customFontFamilies:REPORTED:a face that may not travel or cannot be read, named as an embedded " + + "font; any other is embedded", + "outputFile:INERT:where the bytes go, nothing in them", + "outputOptions:WRITTEN:as its own entries", + "layoutGraph:WRITTEN:as its own entries"); + private static final Map LAYOUT = fields( + "canvas:WRITTEN:the page's size", + "totalPages:WRITTEN:as a page total field's placeholder, which Word counts again and LibreOffice " + + "shows as it is", + "nodes:WRITTEN:where the nodes' entries say", + "fragments:WRITTEN:where the nodes' entries say", + "pageGeometry:REPORTED:a page's side margins, written as the document's, named where they differ; " + + "top and bottom margins that differ from those Word sets a page with, where it starts inside a " + + "block, after a page whose content reaches its foot under a table, or in a document of sections " + + "whose header or footer counts a section's pages, named; any other is written, a later page's top " + + "and bottom margins as a Word section starting where it does"); + static { node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:REPORTED:composed in a table cell where the layout does not say where its child stands, " @@ -571,6 +593,16 @@ void everyOutputOptionHasAnEntry() { .containsExactlyInAnyOrderElementsOf(OUTPUT_OPTIONS.keySet()); } + @Test + void everyPartOfWhatASessionHandsAnExportHasAnEntry() { + assertThat(components(com.demcha.compose.document.backend.semantic.SemanticExportContext.class)) + .as("the context a session hands a semantic export") + .containsExactlyInAnyOrderElementsOf(EXPORT_CONTEXT.keySet()); + assertThat(components(com.demcha.compose.document.layout.LayoutGraph.class)) + .as("the layout it hands") + .containsExactlyInAnyOrderElementsOf(LAYOUT.keySet()); + } + private static void node(Class kind, String... specs) { NODES.put(kind, fields(specs)); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java new file mode 100644 index 000000000..ea672d526 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java @@ -0,0 +1,489 @@ +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.PageMarginRule; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.output.DocumentPageZone; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; + +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 page the layout sets against other margins than the page before it starts a Word section with + * them, where it starts between blocks of the body, and is named where it does not. + * + *

Measured in Word and LibreOffice on Windows: one set of margins for the whole document ran a + * page with a larger bottom margin on past its foot, 7 lines of 33 further; a section starting + * where the page does set every page where the layout does, line for line.

+ */ +class DocxPageMarginSectionsTest { + + // From page 2, a bottom margin 100pt larger than the first page's. + private static final DocumentInsets FIRST = DocumentInsets.of(36); + private static final DocumentInsets LATER = new DocumentInsets(36, 36, 136, 36); + + @Test + void aPageWithOtherMarginsStartingBetweenBlocksStartsASectionWithThem() throws Exception { + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> lines(page, "Line", 60)); + + List ending = exported.paragraphsEndingASection(); + assertThat(ending).singleElement().satisfies(paragraph -> { + assertThat(paragraph.getText()).as("the last line of page 1").isEqualTo(exported.lastLineOf(0)); + assertThat(bottom(paragraph.getCTP().getPPr().getSectPr())).isEqualTo(36 * 20); + }); + assertThat(bottom(exported.body())).as("page 2 on").isEqualTo(136 * 20); + assertThat(exported.notes()).singleElement().asString() + .startsWith("page 2's top and bottom margins differ from page 1's: written as a Word section that starts where page 2 does") + .as("no header to stand off").doesNotContain("header"); + } + + @Test + void aPageBreakIntoOtherMarginsBecomesTheSectionsBreak() throws Exception { + // Kept, the page break would open a blank page before the section's own. + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> { + lines(page, "Cover", 5); + page.addPageBreak(b -> { }); + lines(page, "Line", 30); + }); + + assertThat(exported.document().getDocument().xmlText()).doesNotContain("w:type=\"page\""); + assertThat(exported.paragraphsEndingASection()).singleElement() + .satisfies(paragraph -> assertThat(paragraph.getText()).isEmpty()); + assertThat(bottom(exported.body())).isEqualTo(136 * 20); + } + + @Test + void aPageStartingInsideATableIsNamed() throws Exception { + // The total after the table starts on page 2 too: a section starting there would break + // the page under the table's last rows. + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page + .addParagraph("Items") + .addTable(table -> { + table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100)); + for (int row = 0; row < 40; row++) { + table.row("Item " + row, "$" + row); + } + }) + .addParagraph("Total")); + + assertThat(exported.paragraphsEndingASection()).isEmpty(); + assertThat(bottom(exported.body())).as("page 1's, the only ones written").isEqualTo(36 * 20); + assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("page margin")) + .singleElement().satisfies(note -> { + assertThat(note.path()).endsWith("TableNode[1]"); + assertThat(note.detail()).isEqualTo("page 2's top and bottom margins differ from page 1's, which Word sets it " + + "with: page 2 starts inside this block, where the export " + + "starts no Word section"); + }); + } + + @Test + void aOnePageDocumentIsWrittenWithItsPagesOwnTopAndBottomMargins() throws Exception { + // Its sides stay the document's, which the body is written against, and are named. + Exported exported = export(List.of(PageMarginRule.page(1, new DocumentInsets(50, 40, 30, 20))), + page -> page.addParagraph("Only page")); + + assertThat(exported.paragraphsEndingASection()).isEmpty(); + assertThat(exported.body().getPgMar().getTop()).isEqualTo(java.math.BigInteger.valueOf(50 * 20)); + assertThat(bottom(exported.body())).isEqualTo(30 * 20); + assertThat(exported.body().getPgMar().getLeft()).isEqualTo(java.math.BigInteger.valueOf(36 * 20)); + assertThat(exported.notes()).singleElement().asString().startsWith("page 1's side margins differ from the " + + "document's and are not written"); + } + + @Test + void aLaterSectionOpensWithNoTitlePageOfItsOwn() throws Exception { + // The first page's zone is the title page's part, written for page 1 alone: a later + // section with it would set it on its own first page too. + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), session -> session.chrome().zone( + DocumentPageZone.header(18, page -> new ParagraphBuilder().text(page.isFirst() ? "Cover" : "Running") + .build())), page -> lines(page, "Line", 60)); + + assertThat(exported.paragraphsEndingASection()).singleElement().satisfies(paragraph -> + assertThat(paragraph.getCTP().getPPr().getSectPr().isSetTitlePg()).isTrue()); + assertThat(exported.body().isSetTitlePg()).isFalse(); + } + + @Test + void aSideHeldAtItsMarginIsHeldInALaterSectionAndSoIsOneNarrowerUnderAHeaderOrFooter() { + assertThat(DocxPageMarginSections.held(-720, 40, false)).as("held in the first").isEqualTo(-800); + assertThat(DocxPageMarginSections.held(720, 40, true)).as("wider, nothing reaching over it").isEqualTo(800); + assertThat(DocxPageMarginSections.held(720, 20, true)).as("narrower, under a header").isEqualTo(-400); + assertThat(DocxPageMarginSections.held(720, 20, false)).as("narrower, with none to reach over it") + .isEqualTo(400); + } + + @Test + void aLaterSectionsNarrowerTopMarginUnderAHeaderIsHeld() throws Exception { + // Narrower than page 1's, the header could reach past it; held, the body stays at it. + Exported exported = export(List.of(PageMarginRule.from(2, new DocumentInsets(20, 36, 36, 36))), + session -> session.chrome().zone(DocumentPageZone.header(18, + page -> new ParagraphBuilder().text("Running").build())), + page -> lines(page, "Line", 60)); + + assertThat(exported.paragraphsEndingASection()).singleElement(); + assertThat(((java.math.BigInteger) exported.body().getPgMar().getTop()).longValue()).isNegative(); + } + + @Test + void eachChangeOfMarginsStartsASectionOfItsOwn() throws Exception { + DocumentInsets middle = new DocumentInsets(36, 36, 86, 36); + Exported exported = export(List.of(PageMarginRule.page(2, middle), PageMarginRule.from(3, LATER)), + page -> lines(page, "Line", 90)); + + List ending = exported.paragraphsEndingASection(); + assertThat(ending).hasSize(2); + assertThat(bottom(ending.get(0).getCTP().getPPr().getSectPr())).isEqualTo(36 * 20); + CTSectPr second = ending.get(1).getCTP().getPPr().getSectPr(); + assertThat(bottom(second)).as("page 2's").isEqualTo(86 * 20); + assertThat(second.isSetTitlePg()).isFalse(); + assertThat(bottom(exported.body())).as("page 3 on").isEqualTo(136 * 20); + } + + @Test + void aPageAfterOneStartingInsideABlockStartsTheSectionItsMarginsNeed() throws Exception { + // Page 2 starts inside the table, and Word sets it with page 1's margins; page 3, which + // starts between paragraphs, has margins Word has not been given yet, and starts a section. + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> { + lines(page, "Intro", 10); + page.addTable(table -> { + table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100)); + for (int row = 0; row < 15; row++) { + table.row("Item " + row, "$" + row); + } + }); + lines(page, "Line", 60); + }); + + assertThat(exported.notes()).hasSize(2); + assertThat(exported.notes().get(0)).startsWith("page 2's top and bottom margins differ from page 1's, which Word sets it " + + "with: page 2 starts inside this block"); + assertThat(exported.notes().get(1)).startsWith("page 3's top and bottom margins differ from page 1's: written as a Word " + + "section that starts where page 3 does"); + assertThat(exported.paragraphsEndingASection()).singleElement().satisfies(paragraph -> + assertThat(bottom(paragraph.getCTP().getPPr().getSectPr())).isEqualTo(36 * 20)); + assertThat(bottom(exported.body())).isEqualTo(136 * 20); + } + + @Test + void aSectionOfADocumentWhoseFooterCountsItsPagesKeepsOneSetOfMargins() throws Exception { + // Word counts a section's own pages: two Word sections would count this one's apart. + DocumentSession body = GraphCompose.document().pageSize(400, 400).margin(FIRST).create(); + body.pageMargins(List.of(PageMarginRule.from(2, LATER))); + body.chrome().zone(DocumentPageZone.footer(30, page -> new com.demcha.compose.document.dsl.RowBuilder() + .addParagraph(p -> p.text("Body")).flexSpacer().add(page.pageNumber()).add(page.pageTotal()) + .build())); + body.pageFlow(page -> lines(page, "Line", 60)); + DocumentSession cover = GraphCompose.document().pageSize(400, 400).margin(FIRST).create(); + cover.pageFlow(page -> page.addParagraph("Cover")); + AtomicReference report = new AtomicReference<>(); + byte[] bytes; + try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents() + .section(cover).section(body).create()) { + bytes = document.export(new DocxSemanticBackend(report::set)); + } + + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(bytes))) { + assertThat(document.getParagraphs()).as("the cover's section ends; the body's does not split") + .filteredOn(p -> p.getCTP().isSetPPr() && p.getCTP().getPPr().isSetSectPr()).hasSize(1); + } + assertThat(report.get().notes()).filteredOn(note -> note.subject().equals("page margin")).singleElement() + .satisfies(note -> assertThat(note.detail()).endsWith("a Word section would count its own pages, " + + "where this section's header or footer counts all of them")); + } + + @Test + void theRoomForAClosingLineIsReckonedAgainstTheMarginsWordSetsTheLastPageWith() throws Exception { + // Page 2, a section of its own, a table fills against a bottom margin 64pt over page 1's: + // reckoned against page 1's, the room under the table took in those 64pt, and a closing line + // written there would not fit and open a page of its own. + DocumentInsets later = new DocumentInsets(36, 36, 100, 36); + int filling = rowsFilling(List.of(PageMarginRule.from(1, later)), 1); + Exported exported = export(List.of(PageMarginRule.from(2, later)), page -> { + page.addParagraph("Cover"); + page.addPageBreak(b -> { }); + page.addTable(table -> rows(table, "Item", filling)); + }); + + assertThat(exported.paragraphsEndingASection()).as("page 2 its own section").singleElement(); + assertThat(exported.report().notes()).anyMatch(note -> note.subject().equals("closing paragraph")); + } + + @Test + void theSeparatorBetweenTwoTablesEndsTheSectionAtTheirPageBreak() throws Exception { + // Written while the second table is, it stands at the foot of the first's page: the end of + // a section put before it, it would stand at the top of the next, and hold its space there. + int filling = rowsFilling(List.of(), 1); + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page + .addTable(table -> rows(table, "First", filling)) + .addTable(table -> rows(table, "Second", 5))); + + List body = exported.document().getBodyElements(); + assertThat(body.get(0)).isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFTable.class); + assertThat(body.get(1)).isInstanceOfSatisfying(XWPFParagraph.class, separator -> { + assertThat(separator.getCTP().getPPr().isSetSectPr()).isTrue(); + assertThat(separator.getCTP().getPPr().isSetKeepNext()).as("kept with the next, it would follow it") + .isFalse(); + }); + assertThat(body.get(2)).isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFTable.class); + } + + @Test + void aPageFullToItsFootUnderATableStartsNoSectionAfterIt() throws Exception { + // The panel, a table in Word, ends at the page's bottom margin: a hairline after it, to end + // a section in, would not fit, and open a page of its own before the section's own. + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> { + page.addSection(section -> section.fillColor(com.demcha.compose.document.style.DocumentColor.rgb(238, 242, 247)) + .spacer(10, 400 - 36 - 36)); + lines(page, "Line", 10); + }); + + assertThat(exported.paragraphsEndingASection()).isEmpty(); + assertThat(exported.notes()).singleElement().asString().endsWith("page 1's content reaches its foot, where " + + "the paragraph that would end a Word section before page 2 would open a page of its own"); + } + + @Test + void theSeparatorBetweenTwoTablesNeedsRoomOnItsPageToo() throws Exception { + // The hairline between the panel, a table in Word, and the table after it ends the section + // where the panel ends at the page's foot: Word would move it, and the section, a page on. + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> { + page.addSection(section -> section.fillColor(com.demcha.compose.document.style.DocumentColor.rgb(238, 242, 247)) + .spacer(10, 400 - 36 - 36)); + page.addTable(table -> rows(table, "Item", 5)); + }); + + assertThat(exported.paragraphsEndingASection()).isEmpty(); + assertThat(exported.notes()).singleElement().asString().contains("page 1's content reaches its foot"); + } + + @Test + void theLineHoldingTheSpaceAboveACardMovedOntoThePageStaysOnIt() throws Exception { + // The card the layout moves onto page 2 after a table holds its 12pt margin in a line above + // it on that page. Ending the section, that line stood at the foot of page 1 instead. + int filling = rowsFilling(List.of(), 1); + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> { + page.addTable(table -> rows(table, "Item", filling - 3)); + page.addSection(card -> card.fillColor(com.demcha.compose.document.style.DocumentColor.rgb(238, 242, 247)) + .keepTogether().margin(new DocumentInsets(12, 0, 0, 0)) + .addParagraph("Card one").addParagraph("Card two").addParagraph("Card three") + .addParagraph("Card four").addParagraph("Card five").addParagraph("Card six")); + }); + + List ending = exported.paragraphsEndingASection(); + assertThat(ending).singleElement().satisfies(paragraph -> assertThat(((java.math.BigInteger) paragraph.getCTP() + .getPPr().getSpacing().getLine()).longValue()).as("a hairline of its own").isEqualTo(2)); + List body = exported.document().getBodyElements(); + int at = body.indexOf(ending.get(0)); + assertThat(body.get(at + 1)).as("the held line, on page 2").isInstanceOfSatisfying(XWPFParagraph.class, + held -> assertThat(((java.math.BigInteger) held.getCTP().getPPr().getSpacing().getLine()).longValue()) + .isEqualTo(12 * 20)); + } + + @Test + void otherSideMarginsFromAPageOnAreNamedNotWritten() throws Exception { + // The body is written against the document's width — a rule's length, a panel's room — + // and Word sets its text between the section's side margins: written, they would set a + // full-width rule 108pt short. Named once for the pages that share them. + Exported exported = export(List.of(PageMarginRule.from(2, DocumentInsets.symmetric(36, 90))), + page -> lines(page, "Line", 60)); + + assertThat(exported.paragraphsEndingASection()).as("its top and bottom are page 1's").isEmpty(); + assertThat(exported.body().getPgMar().getLeft()).isEqualTo(java.math.BigInteger.valueOf(36 * 20)); + assertThat(exported.notes()).singleElement().asString().isEqualTo("page 2's side margins differ from the " + + "document's and are not written: the body is written against the document's width, and Word " + + "sets its text between the document's side margins"); + } + + @Test + void aPageWithOtherTopAndBottomAndSideMarginsWritesTheFirstAndNamesTheSides() throws Exception { + Exported exported = export(List.of(PageMarginRule.from(2, new DocumentInsets(36, 90, 136, 90))), + page -> lines(page, "Line", 60)); + + assertThat(exported.paragraphsEndingASection()).singleElement(); + assertThat(bottom(exported.body())).isEqualTo(136 * 20); + assertThat(exported.body().getPgMar().getRight()).isEqualTo(java.math.BigInteger.valueOf(36 * 20)); + assertThat(exported.notes()).hasSize(2); + } + + @Test + void aSectionOfADocumentWhoseTextFooterCountsItsPagesKeepsOneSetOfMargins() throws Exception { + DocumentSession body = GraphCompose.document().pageSize(400, 400).margin(FIRST).create(); + body.pageMargins(List.of(PageMarginRule.from(2, LATER))); + body.footer(com.demcha.compose.document.output.DocumentHeaderFooter.builder() + .rightText("{page} of {pages}").build()); + body.pageFlow(page -> lines(page, "Line", 60)); + List notes = sectionedNotes(body); + + assertThat(notes).singleElement().asString().endsWith("a Word section would count its own pages, where this " + + "section's header or footer counts all of them"); + } + + @Test + void aLaterPartOfASectionOfSeveralCountsItsPagesOnFromTheFirst() throws Exception { + // Each section of a document of several counts its pages from 1: the part a page with other + // margins starts counts on. + DocumentSession body = GraphCompose.document().pageSize(400, 400).margin(FIRST).create(); + body.pageMargins(List.of(PageMarginRule.from(2, LATER))); + body.pageFlow(page -> lines(page, "Line", 60)); + DocumentSession cover = GraphCompose.document().pageSize(400, 400).margin(FIRST).create(); + cover.pageFlow(page -> page.addParagraph("Cover")); + byte[] bytes; + try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents() + .section(cover).section(body).create()) { + bytes = document.toDocxBytes(); + } + + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(bytes))) { + List ending = document.getParagraphs().stream() + .filter(p -> p.getCTP().isSetPPr() && p.getCTP().getPPr().isSetSectPr()) + .map(p -> p.getCTP().getPPr().getSectPr()).toList(); + assertThat(ending).as("the cover's, then the body's first part").hasSize(2); + assertThat(ending.get(1).getPgNumType().isSetStart()).as("the body counts from 1").isTrue(); + CTSectPr later = document.getDocument().getBody().getSectPr(); + assertThat(later.isSetPgNumType() && later.getPgNumType().isSetStart()).as("and on").isFalse(); + } + } + + private static List sectionedNotes(DocumentSession body) throws Exception { + DocumentSession cover = GraphCompose.document().pageSize(400, 400).margin(FIRST).create(); + cover.pageFlow(page -> page.addParagraph("Cover")); + AtomicReference report = new AtomicReference<>(); + try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents() + .section(cover).section(body).create()) { + document.export(new DocxSemanticBackend(report::set)); + } + return report.get().notes().stream().filter(note -> note.subject().equals("page margin")) + .map(DocxExportReport.Note::detail).toList(); + } + + @Test + void theSpaceAboveAPictureOpeningAPageIsReckonedFromThatPagesTopMargin() throws Exception { + // A picture the layout moves onto a page holds the space the page leaves above it there, + // read from where the layout places it. Page 2's top margin is 26pt under page 1's: read from + // page 1's, the space above the picture came to less than none, and its 20pt margin was lost. + Exported exported = export(List.of(PageMarginRule.from(2, new DocumentInsets(10, 36, 36, 36))), page -> { + lines(page, "Line", 24); + page.addImage(image -> image.source(png()).size(40, 40).margin(new DocumentInsets(20, 0, 0, 0))); + }); + + List paragraphs = exported.document().getParagraphs(); + int picture = paragraphs.indexOf(paragraphs.stream() + .filter(paragraph -> !paragraph.getRuns().isEmpty() && !paragraph.getRuns().get(0) + .getEmbeddedPictures().isEmpty()).findFirst().orElseThrow()); + var held = paragraphs.get(picture - 1).getCTP().getPPr().getSpacing(); + assertThat(paragraphs.get(picture - 1).getText()).as("a line held above it").isEmpty(); + assertThat(((java.math.BigInteger) held.getLine()).longValue()).isEqualTo(20 * 20); + } + + private static byte[] png() { + try (java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream()) { + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(16, 16, + java.awt.image.BufferedImage.TYPE_INT_RGB), "png", bytes); + return bytes.toByteArray(); + } catch (java.io.IOException failure) { + throw new IllegalStateException(failure); + } + } + + private static void rows(com.demcha.compose.document.dsl.TableBuilder table, String text, int count) { + table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100)); + for (int row = 0; row < count; row++) { + table.row(text + " " + row, "$" + row); + } + } + + /** The most rows one table holds on a page, laid out alone. */ + private static int rowsFilling(List rules, int pages) throws Exception { + int rows = 1; + while (pagesOf(rules, rows + 1) == pages) { + rows++; + } + return rows; + } + + private static int pagesOf(List rules, int rows) throws Exception { + try (DocumentSession session = GraphCompose.document().pageSize(400, 400).margin(FIRST).create()) { + session.pageMargins(rules); + session.pageFlow(page -> page.addTable(table -> { + table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100)); + for (int row = 0; row < rows; row++) { + table.row("Item " + row, "$" + row); + } + })); + return session.layoutGraph().totalPages(); + } + } + + private static void lines(PageFlowBuilder page, String text, int count) { + for (int line = 0; line < count; line++) { + int at = line; + page.addParagraph(p -> p.name(text + "_" + at).text(text + " " + at)); + } + } + + private static long bottom(CTSectPr section) { + return ((java.math.BigInteger) section.getPgMar().getBottom()).longValue(); + } + + private static Exported export(List rules, Consumer content) throws Exception { + return export(rules, session -> { }, content); + } + + private static Exported export(List rules, Consumer chrome, + Consumer content) throws Exception { + AtomicReference report = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document().pageSize(400, 400).margin(FIRST).create()) { + session.pageMargins(rules); + chrome.accept(session); + session.pageFlow(content::accept); + byte[] bytes = session.export(new DocxSemanticBackend(report::set)); + List lastLines = new ArrayList<>(); + for (int page = 0; page < session.layoutGraph().totalPages(); page++) { + int at = page; + lastLines.add(session.layoutGraph().nodes().stream() + .filter(node -> node.nodeKind().equals("ParagraphNode") && node.endPage() == at) + .reduce((before, after) -> after) + .map(node -> node.semanticName()) + .orElse("")); + } + return new Exported(new XWPFDocument(new ByteArrayInputStream(bytes)), report.get(), lastLines); + } + } + + private record Exported(XWPFDocument document, DocxExportReport report, List lastNames) { + + CTSectPr body() { + return document.getDocument().getBody().getSectPr(); + } + + List paragraphsEndingASection() { + return document.getParagraphs().stream() + .filter(paragraph -> paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetSectPr()) + .toList(); + } + + /** The text of the last paragraph the layout ends on a page: each is named for its text. */ + String lastLineOf(int page) { + return lastNames.get(page).replace('_', ' '); + } + + List notes() { + return report.notes().stream().filter(note -> note.subject().equals("page margin")) + .map(DocxExportReport.Note::detail).toList(); + } + } +}