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]; + } + } +}