From 21c9fbf3b3a54ce8d1a50c248333d15888eb8a11 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 19 Oct 2026 10:01:41 +0100 Subject: [PATCH] fix(docx): name a section Word starts on a page of its own Every section of a multi-section document numbers its pages from 1, and once the document states different even and odd pages, as it does for a page zone on some of them, Word starts every such section on an odd page. It adds a blank page before a section whose section before it runs to an odd number of pages, and the report said nothing. Measured in Word and LibreOffice on Windows, sections of one, one and one page came out five pages in Word and three in LibreOffice; two, one and one five in Word; one, three and two eight. LibreOffice adds none. With the numbers not restarted, or restarted at 2, or with even and odd pages not stated, Word adds none either, but each of those changes what the page numbers read or where the zones stand. The report now names each section Word adds a page before (page zone), from the page count the layout gives the section before it, and where that section has no layout, says where it would. The DOCX is written as before. --- CHANGELOG.md | 11 ++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 7 + .../semantic/docx/DocxSemanticBackend.java | 30 ++++ .../docx/DocxBlankPageBeforeSectionTest.java | 165 ++++++++++++++++++ 5 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlankPageBeforeSectionTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index a532890df..47819e3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export names the blank page Word adds before a section.** Every section of a + multi-section document numbers its pages from 1. Once the document states different even and + odd pages, as it does for a page zone on some of them, Word starts every section on an odd + page. It adds a blank page before a section whose section before it runs to an odd number of + pages, with nothing said. Measured in Word and LibreOffice on Windows, sections of one, one and + one page came out five pages in Word and three in LibreOffice. Sections of two, one and one + page came out five in Word, and of one, three and two pages eight; LibreOffice adds none. In + both editors a footer on even pages stood on each section's second page, where the page draws + it: they file a section's even and odd pages by its own numbers. The report now names each + section Word adds a page before (`page zone`), and with no layout behind the section before it, + says where it would. - **A DOCX export writes a page zone whose predicate reads the page total on the pages the page draws it on.** Word paginates for itself, so a zone's `appliesTo` predicate is asked over sample pages, the document's own and more up to the sixth, and sorted into Word's first, even diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index e5e940eb9..bc860d822 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -132,7 +132,7 @@ honour an option ignores it (documented contract). | Render to bytes / stream / file (`FixedLayoutRenderer`) | ✅ `PdfFixedLayoutBackend` | ✅ `PptxFixedLayoutBackend` | ✅ `DocxSemanticBackend` (`SemanticBackend`) | | 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 | +| 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) | | 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 d89060e8f..fecc6245d 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -158,6 +158,13 @@ would behave differently left to itself, the export tells it what the PDF does: otherwise repeat the previous section's; - the metadata is the first section's that states any. +Word starts every section on an odd page once the document states different even and odd +pages, as it does for a page zone on some of them. So it adds a blank page before a section +whose section before it runs to an odd number of pages. Measured in Word on Windows, three +sections of one page each came out five pages; LibreOffice adds none. The report names each +section Word adds a page before (`page zone`), from the page count the layout gives the section +before it. + Styles, fonts and bookmark names are shared across the document, so a link in the cover reaches an anchor in the body. `export(backend)` takes any semantic backend; one that cannot combine sections refuses more than one. 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 cfe329b55..b36fb75fa 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 @@ -1193,6 +1193,9 @@ private byte[] write(List sections, Path outputFile) throws Exc endSection(document); } beginSection(section, index); + if (evenAndOdd && index > 0) { + reportTheBlankPageBefore(sections.get(index - 1)); + } sectionFirstElement = document.getBodyElements().size(); backgroundsInBody = List.of(); applyPageGeometry(document, context.canvas()); @@ -1269,6 +1272,33 @@ private byte[] write(List sections, Path outputFile) throws Exc } } + /** + * Names the blank page Word adds before the section being written, where the document states + * different even and odd pages. Every section numbers its pages from 1, and Word then starts + * every section on an odd page, adding a blank one where the section before it runs to an odd + * number of pages. Measured in Word on Windows, three sections of a page each ran to five + * pages; LibreOffice adds none. Where the layout does not count the section before, the note + * says where Word would. + * + * @param before the section before the one being written + */ + private void reportTheBlankPageBefore(SemanticSection before) { + com.demcha.compose.document.layout.LayoutGraph graph = before.context().layoutGraph(); + int pages = graph == null ? 0 : graph.totalPages(); + if (pages > 0 && pages % 2 == 0) { + return; + } + report.add(DocxExportReport.Severity.APPROXIMATED, "page zone", "section " + (sectionIndex + 1), + (pages > 0 + ? "Word adds a blank page before this section: the section before it runs to " + + (pages == 1 ? "one page" : pages + " pages") + ", and" + : "Word adds a blank page before this section where the section before it runs to an " + + "odd number of pages:") + + " Word starts every section on an odd page where the document states different even and odd " + + "pages, as it does for a page zone on some of them, each section numbering its pages from 1; " + + "LibreOffice adds none"); + } + /** * Resets what a section starts from: its own measurements, page width and height, and no * spacing carried in from the section before it. diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlankPageBeforeSectionTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlankPageBeforeSectionTest.java new file mode 100644 index 000000000..1e31c47b4 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlankPageBeforeSectionTest.java @@ -0,0 +1,165 @@ +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.backend.semantic.SemanticBackend; +import com.demcha.compose.document.backend.semantic.SemanticExportContext; +import com.demcha.compose.document.backend.semantic.SemanticSection; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.layout.DocumentGraph; +import com.demcha.compose.document.output.DocumentPageZone; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A section Word starts after a blank page of its own is named. + * + *

Every section numbers its pages from 1, and where the document states different even and + * odd pages — for a page zone on some of them — Word starts every section on an odd page, adding + * a blank one before a section whose section before it runs to an odd number of pages. Measured + * in Word on Windows: sections of one, one and one page ran to five pages, of two, one and one to + * five, of one, two and one to five, of one, three and two to eight; LibreOffice adds none.

+ * + * @author Artem Demchyshyn + */ +class DocxBlankPageBeforeSectionTest { + + private static final String BLANK_PAGE = "Word adds a blank page before this section"; + + @Test + void aSectionAfterAnOddNumberOfPagesIsNamed() throws Exception { + List notes = notes(1, 1, 1); + + assertThat(blankPagesBefore(notes)).containsExactly("section 2", "section 3"); + assertThat(notes).anySatisfy(note -> assertThat(note.detail()) + .startsWith(BLANK_PAGE + ": the section before it runs to one page, and") + .contains("different even and odd pages") + .endsWith("LibreOffice adds none")); + } + + @Test + void aSectionAfterAnyOddNumberOfPagesIsNamedWithIt() throws Exception { + List notes = notes(1, 3, 2); + + assertThat(blankPagesBefore(notes)).containsExactly("section 2", "section 3"); + assertThat(notes).anySatisfy(note -> assertThat(note.detail()) + .startsWith(BLANK_PAGE + ": the section before it runs to 3 pages, and")); + } + + @Test + void aSectionAfterAnEvenNumberOfPagesIsNot() throws Exception { + assertThat(blankPagesBefore(notes(2, 1, 1))).containsExactly("section 3"); + assertThat(blankPagesBefore(notes(1, 2, 1))).containsExactly("section 2"); + assertThat(blankPagesBefore(notes(2, 2))).isEmpty(); + } + + @Test + void aDocumentWithoutEvenAndOddPagesAddsNone() throws Exception { + List notes = new ArrayList<>(); + try (MultiSectionDocument document = GraphCompose.documents() + .section(section(1, false)) + .section(section(1, false)) + .create()) { + document.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build()); + } + + assertThat(blankPagesBefore(notes)).isEmpty(); + } + + @Test + void aSectionAfterOneTheLayoutDoesNotCountIsNamedWhereItRunsOdd() throws Exception { + Captured captured = new Captured(); + try (MultiSectionDocument document = GraphCompose.documents() + .section(section(2, true)) + .section(section(1, false)) + .create()) { + document.export(captured); + } + SemanticSection first = captured.sections.get(0); + SemanticExportContext laidOut = first.context(); + List sections = List.of( + new SemanticSection(first.graph(), new SemanticExportContext(laidOut.canvas(), + laidOut.customFontFamilies(), laidOut.outputFile(), laidOut.outputOptions(), null)), + captured.sections.get(1)); + List notes = new ArrayList<>(); + DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build() + .exportSections(sections); + + assertThat(blankPagesBefore(notes)).containsExactly("section 2"); + assertThat(notes).anySatisfy(note -> assertThat(note.detail()) + .startsWith(BLANK_PAGE + " where the section before it runs to an odd number of pages:")); + } + + private static List notes(int... pages) throws Exception { + List notes = new ArrayList<>(); + var documents = GraphCompose.documents(); + for (int index = 0; index < pages.length; index++) { + documents = documents.section(section(pages[index], index == 0)); + } + try (MultiSectionDocument document = documents.create()) { + document.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build()); + } + return notes; + } + + /** The sections the report names a blank page before, in order. */ + private static List blankPagesBefore(List notes) { + return notes.stream() + .filter(note -> note.detail().startsWith(BLANK_PAGE)) + .map(note -> { + assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED); + assertThat(note.subject()).isEqualTo("page zone"); + return note.path(); + }) + .toList(); + } + + /** A section of so many pages, with a footer on its even pages only where asked. */ + private static DocumentSession section(int pages, boolean evenFooter) { + DocumentSession session = GraphCompose.document() + .pageSize(300, 200) + .margin(DocumentInsets.of(30)) + .create(); + if (evenFooter) { + session.chrome().zone(DocumentPageZone.footer(20, page -> new ParagraphBuilder().text("Verso").build()) + .toBuilder().appliesTo(page -> page.number() % 2 == 0).build()); + } + session.pageFlow(page -> { + page.addParagraph(p -> p.text("Page 1")); + for (int next = 2; next <= pages; next++) { + int number = next; + page.addPageBreak(pageBreak -> { }); + page.addParagraph(p -> p.text("Page " + number)); + } + }); + return session; + } + + /** Takes the sections a multi-section document hands a backend. */ + private static final class Captured implements SemanticBackend { + + private List sections; + + @Override + public String name() { + return "capture"; + } + + @Override + public byte[] export(DocumentGraph documentGraph, SemanticExportContext context) { + return new byte[0]; + } + + @Override + public byte[] exportSections(List sections) { + this.sections = List.copyOf(sections); + return new byte[0]; + } + } +}