From 293ea41548486f7a317bc36efa10b48ff7a0b413 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 25 Jun 2026 16:57:32 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20GraphCompose.documents()=20?= =?UTF-8?q?=E2=80=94=20multi-section=20documents=20in=20one=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document had a single page geometry: one page size, one margin, one numbering scheme for the whole file. Assembling a book — a full-bleed cover of one size in front of a margined, page-numbered body of another — meant rendering separate documents and merging them with external PDFBox, which re-parses each part and is awkward to keep navigable. GraphCompose.documents() concatenates several independent DocumentSession sections — each with its own page size, margins, fonts, and footer numbering — into one PDF inside the engine, with no external merge. Each section's pages are appended at a growing page offset; anchors, internal links, and the bookmark outline resolve across section boundaries against the combined document, so a link on the cover can jump to a chapter in the body. Each section's footer counts from its own first page, so roman front-matter can precede an arabic-numbered body. Document-level metadata and protection are taken from the first section that declares them. Single-section output is unchanged: the existing renderers delegate to a windowed overload with a zero page offset, and the link/anchor/bookmark page indices are rebased only when a section starts past page one. The full suite passes with no changed visual baselines. Tests: MultiSectionDocumentTest renders a cover + body and asserts each section keeps its page size, a cover link resolves to the body's global page, a bookmark and external link in a later section rebase to their global pages, a duplicate anchor resolves to the last section, and each section is numbered from its own first page. Example: MultiSectionExample (landscape cover + portrait, page-numbered body in one PDF). --- CHANGELOG.md | 11 + .../examples/multi-section-document.pdf | Bin 0 -> 2832 bytes examples/README.md | 23 ++ .../demcha/examples/GenerateAllExamples.java | 2 + .../structure/MultiSectionExample.java | 109 +++++++++ .../java/com/demcha/compose/GraphCompose.java | 27 +++ .../compose/document/api/DocumentSession.java | 18 ++ .../document/api/MultiSectionDocument.java | 171 +++++++++++++++ .../api/MultiSectionDocumentBuilder.java | 84 +++++++ .../fixed/pdf/PdfDocumentPostProcessor.java | 102 +++++++-- .../fixed/pdf/PdfFixedLayoutBackend.java | 192 ++++++++++++++-- .../fixed/pdf/PdfRenderEnvironment.java | 40 +++- .../pdf/helpers/PdfHeaderFooterRenderer.java | 40 +++- .../pdf/helpers/PdfWatermarkRenderer.java | 21 +- .../fixed/pdf/MultiSectionDocumentTest.java | 206 ++++++++++++++++++ 15 files changed, 998 insertions(+), 48 deletions(-) create mode 100644 assets/readme/examples/multi-section-document.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java create mode 100644 src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java create mode 100644 src/main/java/com/demcha/compose/document/api/MultiSectionDocumentBuilder.java create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 84dceee7c..d4401594d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **`GraphCompose.documents()` + `MultiSectionDocumentBuilder` / `MultiSectionDocument`** + (`@since 1.9.0`). Concatenates several independently authored `DocumentSession` + sections — each with its own page size, margins, fonts, and footer numbering — + into one PDF inside the engine, with no external PDF merge. Anchors, internal + links, and the bookmark outline resolve across section boundaries against the + combined document, and each section is numbered from its own first page, so a + full-bleed cover of one page size can precede a margined, page-numbered body of + another. Document-level metadata and protection are taken from the first section + that declares them. Single-section output is unchanged. `MultiSectionDocument` + is `AutoCloseable` and owns its sections. + - **`addTableOfContents(...)` + `TocBuilder` / `DocumentLeader`** (`@since 1.9.0`). A native, clickable table of contents: each `entry(label, anchor)` becomes a row whose label links to the chapter (`linkTo`), a dotted or dashed leader fills the diff --git a/assets/readme/examples/multi-section-document.pdf b/assets/readme/examples/multi-section-document.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6afed1d48c5755747c2b29f9af5f3fad70caa4f7 GIT binary patch literal 2832 zcmcJRdpMMd9>)pcogvwA-8v?hI<7PG&I~4YX3Q;w8Dm^ZWEzvpFf?XeS}jUDWa5;= zA(u8QmUM9`Y`P%jS|VkfLPD3#T47M(jOT36sb}YT>Uqwy|Gj_wzVGvUzn|~#_j}D4 z6q+^4#uhM>j=vuPP;ewXYF{|u=m;QK3DI0Qg3K1OBceh8gb!B`6BfmXBi5=B40Z@N z1`f)~nE)OSaQVS&GW(C@Zd`tdFcc1gvT9mbgpezMBWMw9A(z7CL#Y>^@jZ>r56>d!_;>JN@pv|molp>?JXYXUY}<9oaweHqN^-&GBBi0 z2T|h7JGzOFZc+NTF0Hph>^IO@bLhXH`@t&|R*qR8yK(@Kr?Qo!LZiOG;>dse&*Dth z#vd8o55tGPwX_+{t9EA{QQ}uVzHaZ%tauf^A3eQH*wN=_Y~c9t6nI{&_-niLsM5t| zY4AJowvngrcuE0V#h^)}0u6_r-(JdIC)i=((VVGv;Pz;%!jr+fkTQ)yCCRQgv`o7! zAC|>|`rlWrm}o8#7#QjL&xw+-5{K5unt5k=lHx`l_=AMXQ zRyw4<2B>)QOm@jL?4Qi)sxPE} zPc~ps(X`{ytbB}T5V8e3bb5AcAY7%K9S%QQ z$~k&ejqFkXBz5E|>)3*S$b>wx0mc#M$^f7W&Xz+_> z{}cMo>{MPI;kkR0f|&X&B#i4Cu2E)E;H%lB z*q_~lE^cbgs@L5kn|+%Yrh zc@o$)V*Q?w!Ek$ZSkJehsw?__UA04NglhL_glmW&ZBBj6H@Ty%Sp>Be+D&;cnTDN* z$?(78ScwAkweaPe%igQuh4vhCcFN$zUV@tW&ft~VqHsMg(aYq)p&I=t&-E^&f+Xa3SHEnjneG}Z{?Le|m z3|DWD&xzQR{F9Kh)+QH|tI~yf&KCR~?;DQoNxhxJ&3Q!+yNy z7Y<{uh6s23wUJgOo^D?I@Z|M7D@N^rPs*yIaLZ$zn)p{aT^M>=SI@$MKKi@4pJ4w!9(tT( zJl@mo$9T5?)OY0O9%zzY_w;B@#4Ke(e@v{AaP1iHH`UIzZIj%4t3E0AOzy&7LD*7F zMzLq!&v@|fw6T4;Mxa-4LPd;CyN0yDoO@imK!F3aG}8c z9c__h44H(afFK#9I@zP`NfZhdM+NQeFgThMhU$byVw`YJ7%YgyQAju^Cnp?;BvUD9 znjINM!r9{C|NkojK19}!$=weiyuuE0;cGc|;Rkc$;r>WC8Z9gFh5Msqzltc?<7a;a zbvh*)M20@)Y5vv0IE|m3?I;gSOH4{OP9`n>?QXC_A_XGqU=+ryYo|-36lRC%zeuhE z)r6GaF4L(uQdX2(IRWSkL5?@aukcHAJPsNJ2mdmvTB8q1l;d=akS!3#3uKLh7z_?D KGo#XJz<&T toc.entry(label, anchor))` — a native clickable TOC with dot leaders and auto-resolved page numbers | [PDF](../assets/readme/examples/table-of-contents.pdf) · [Source](src/main/java/com/demcha/examples/features/navigation/TocExample.java) | +| [Multi-section documents](#multi-section-documents) | `GraphCompose.documents()` — concatenate sections with different page sizes / margins / numbering into one PDF, with cross-section links and outline | [PDF](../assets/readme/examples/multi-section-document.pdf) · [Source](src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java) | | [HTTP streaming](#http-streaming) | `writePdf(OutputStream)` for Servlet / S3 / GCS — caller's stream is not closed | [PDF](../assets/readme/examples/invoice-http-stream.pdf) · [Source](src/main/java/com/demcha/examples/features/streaming/HttpStreamingExample.java) | | [Word export (DOCX)](#word-export-docx) | `DocxSemanticBackend` — the same session renders a fixed-layout PDF and an editable Word file; paragraphs / lists / tables / images map 1:1, charts fall back to their data table | [PDF](../assets/readme/examples/word-export-companion.pdf) · [DOCX](../assets/readme/examples/word-export-companion.docx) · [Source](src/main/java/com/demcha/examples/features/docx/WordExportExample.java) | | [Layout snapshot regression](#layout-snapshot-regression) | Deterministic `layoutSnapshot()` workflow with baseline + drift report — production regression-testing pattern | [PDF](../assets/readme/examples/invoice-snapshot-regression.pdf) · [Source](src/main/java/com/demcha/examples/features/snapshots/LayoutSnapshotRegressionExample.java) | @@ -742,6 +743,28 @@ flow.addTableOfContents(toc -> toc.title("Contents") [📄 View PDF](../assets/readme/examples/table-of-contents.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/navigation/TocExample.java) +### Multi-section documents + +`GraphCompose.documents()` concatenates several independently authored sections — +each a full `DocumentSession` with its own page size, margins, fonts, and footer +numbering — into one PDF **inside the engine** (no external merge). Anchors, links, +and the bookmark outline resolve across section boundaries, and each section is +numbered from its own first page, so a full-bleed landscape cover can precede a +portrait, page-numbered body in a single document. + +```java +DocumentSession cover = GraphCompose.document().pageSize(440, 300).margin(DocumentInsets.of(0)).create(); +DocumentSession body = GraphCompose.document().pageSize(300, 440).margin(DocumentInsets.of(40)).create(); +body.footer(PdfHeaderFooterOptions.builder().centerText("{page} / {pages}").build()); + +try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) { + doc.buildPdf(); // cover keeps its geometry; body is numbered 1..N from its own first page +} +``` + +[📄 View PDF](../assets/readme/examples/multi-section-document.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java) + --- ## Production patterns diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 489ab70d1..8148a5087 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -31,6 +31,7 @@ import com.demcha.examples.features.navigation.InPdfNavigationExample; import com.demcha.examples.features.navigation.PageReferenceExample; import com.demcha.examples.features.navigation.TocExample; +import com.demcha.examples.features.structure.MultiSectionExample; import com.demcha.examples.features.text.RichTextShowcaseExample; import com.demcha.examples.features.text.SectionPresetsExample; import com.demcha.examples.features.themes.CustomBusinessThemeExample; @@ -172,6 +173,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + InPdfNavigationExample.generate()); System.out.println("Generated: " + PageReferenceExample.generate()); System.out.println("Generated: " + TocExample.generate()); + System.out.println("Generated: " + MultiSectionExample.generate()); // Theming + chrome System.out.println("Generated: " + CustomBusinessThemeExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java b/examples/src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java new file mode 100644 index 000000000..b522faf03 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java @@ -0,0 +1,109 @@ +package com.demcha.examples.features.structure; + +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.fixed.pdf.options.PdfHeaderFooterOptions; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for v1.9 {@code GraphCompose.documents()}: several sections, + * each a full {@link DocumentSession} with its own page size and chrome, + * concatenated into one PDF — no external merge. Here a full-bleed landscape + * cover precedes a portrait, page-numbered body, and the cover's call to action + * links straight to a chapter in the body. + * + *
{@code
+ * DocumentSession cover = GraphCompose.document().pageSize(440, 300).margin(of(0)).create();
+ * DocumentSession body  = GraphCompose.document().pageSize(300, 440).margin(of(40)).create();
+ * body.footer(PdfHeaderFooterOptions.builder().centerText("{page}").build());
+ *
+ * try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) {
+ *     doc.buildPdf();
+ * }
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class MultiSectionExample { + + private static final DocumentColor COVER_BG = DocumentColor.rgb(28, 39, 64); + private static final DocumentColor COVER_INK = DocumentColor.rgb(244, 247, 252); + private static final DocumentColor COVER_ACCENT = DocumentColor.rgb(126, 170, 255); + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135); + + private static final String[][] CHAPTERS = { + {"1. The opening chapter", "ch1"}, + {"2. The middle chapter", "ch2"}, + {"3. The closing chapter", "ch3"}, + }; + + private MultiSectionExample() { + } + + /** + * Renders a landscape cover and a portrait body into a single PDF via + * {@code GraphCompose.documents()}. + * + * @return path to the generated PDF + * @throws Exception if rendering or file IO fails + */ + public static Path generate() throws Exception { + Path pdfFile = ExampleOutputPaths.prepare("features/structure", "multi-section-document.pdf"); + + // Cover: landscape, full-bleed background, no margin. + DocumentSession cover = GraphCompose.document() + .pageSize(440, 300) + .margin(DocumentInsets.of(0)) + .pageBackground(COVER_BG) + .create(); + cover.pageFlow(page -> page.addSection(s -> s.padding(DocumentInsets.of(46)) + .addParagraph(p -> p.text("The GraphCompose Book") + .textStyle(DocumentTextStyle.DEFAULT.withSize(30).withColor(COVER_INK))) + .addParagraph(p -> p.text("One document, two page geometries") + .textStyle(DocumentTextStyle.DEFAULT.withSize(13).withColor(COVER_INK))) + .addParagraph(p -> p.text("Open the opening chapter") + .textStyle(DocumentTextStyle.DEFAULT.withSize(13).withColor(COVER_ACCENT)) + .linkTo("ch1")))); + + // Body: portrait, margined, page-numbered from its own first page. + DocumentSession body = GraphCompose.document() + .pageSize(300, 440) + .margin(DocumentInsets.of(40)) + .create(); + body.footer(PdfHeaderFooterOptions.builder().centerText("{page} / {pages}").build()); + body.pageFlow(page -> { + for (int i = 0; i < CHAPTERS.length; i++) { + String[] chapter = CHAPTERS[i]; + if (i > 0) { + page.addPageBreak(b -> b.name("to_" + chapter[1])); + } + page.addSection(s -> s.anchor(chapter[1]) + .addParagraph(p -> p.text(chapter[0]) + .textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK))) + .addParagraph(p -> p.text("Body pages carry their own margins and footer page numbers, " + + "independent of the borderless cover in front of them.") + .textStyle(DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED)))); + } + }); + + try (MultiSectionDocument document = GraphCompose.documents(pdfFile) + .section(cover) + .section(body) + .create()) { + document.buildPdf(); + } + + return pdfFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/GraphCompose.java b/src/main/java/com/demcha/compose/GraphCompose.java index a1b5409c6..7579bb9b7 100644 --- a/src/main/java/com/demcha/compose/GraphCompose.java +++ b/src/main/java/com/demcha/compose/GraphCompose.java @@ -6,6 +6,8 @@ import com.demcha.compose.font.DefaultFonts; import com.demcha.compose.document.api.DocumentPageSize; import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.api.MultiSectionDocument; +import com.demcha.compose.document.api.MultiSectionDocumentBuilder; import com.demcha.compose.document.output.DocumentDebugOptions; import com.demcha.compose.document.style.DocumentInsets; @@ -92,6 +94,31 @@ public static DocumentBuilder document(Path outputFile) { return new DocumentBuilder(outputFile); } + /** + * Starts a multi-section document: several independently authored + * {@link DocumentSession} sections — each with its own page size, margins, + * fonts, and page numbering — concatenated into one PDF with cross-section + * anchors, links, and bookmark outline. + * + * @return builder for assembling a multi-section document + * @since 1.9.0 + */ + public static MultiSectionDocumentBuilder documents() { + return new MultiSectionDocumentBuilder(null); + } + + /** + * Starts a multi-section document with a default output target used by + * {@link MultiSectionDocument#buildPdf()}. + * + * @param outputFile default PDF output path for {@link MultiSectionDocument#buildPdf()} + * @return builder for assembling a multi-section document + * @since 1.9.0 + */ + public static MultiSectionDocumentBuilder documents(Path outputFile) { + return new MultiSectionDocumentBuilder(outputFile); + } + /** * Returns the logical font families bundled with GraphCompose out of the box. * diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java index cc884f87c..0103acc86 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java @@ -1198,6 +1198,24 @@ public NodeRegistry register(NodeDefinition definiti } } + /** + * Captures this session as one section of a {@link MultiSectionDocument}: its + * resolved layout graph, page geometry, custom fonts, and chrome (header / + * footer / watermark / metadata). Package-private — used only by + * {@link MultiSectionDocument} to concatenate sessions into one PDF. + * + * @return a render unit for this section + */ + com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend.Section toSectionRenderUnit() { + ensureOpen(); + ensureRenderable(); + return new com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend.Section( + layoutGraph(), + canvas, + List.copyOf(customFontFamilies), + chromeOptions.toConveniencePdfBackend(debug)); + } + /** * Inner adapter exposing session-private state to {@link DocumentRenderingFacade} * without making the corresponding session methods public. diff --git a/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java b/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java new file mode 100644 index 000000000..33e535f04 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java @@ -0,0 +1,171 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend; +import com.demcha.compose.document.exceptions.DocumentRenderingException; +import com.demcha.compose.document.snapshot.LayoutSnapshot; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +/** + * A single PDF assembled from several independent {@link DocumentSession} + * sections, each with its own page size, margins, fonts, and chrome (header / + * footer / page numbering / watermark). + * + *

The sections are concatenated inside the engine — there is no + * external PDF merge — so anchors, internal links, and the bookmark outline + * resolve across section boundaries against the combined document. This lets a + * full-bleed cover (one page size) sit in front of a margined body (another + * page size) with continuous, clickable navigation, which a single + * {@link DocumentSession} cannot express because its geometry is document-wide.

+ * + *

Per-section page numbering follows each section's own footer configuration: + * the {@code {page}} / {@code {pages}} tokens count from that section's first + * page, so a roman-numbered front matter can precede an arabic-numbered body. + * Document-global concerns — PDF metadata and protection — are taken from the + * first section that declares them. Anchor names must be unique across sections; + * a collision keeps the last section's anchor (and logs a warning). Custom font + * families are merged across sections by name; if two sections define the same + * family differently, the first definition wins.

+ * + *

This document owns its sections: {@link #close()} closes every section. + * Build it with {@link com.demcha.compose.GraphCompose#documents()}.

+ * + *

Thread-safety: single-threaded, like {@link DocumentSession}.

+ * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public final class MultiSectionDocument implements AutoCloseable { + + private final Path defaultOutputFile; + private final List sections; + private final PdfFixedLayoutBackend backend = new PdfFixedLayoutBackend(); + private boolean closed; + + MultiSectionDocument(Path defaultOutputFile, List sections) { + this.defaultOutputFile = defaultOutputFile; + this.sections = List.copyOf(sections); + } + + /** + * Renders the combined multi-section document to PDF bytes. + * + * @return rendered PDF bytes + * @throws DocumentRenderingException if rendering fails + */ + public byte[] toPdfBytes() throws DocumentRenderingException { + return render("render PDF bytes", () -> backend.renderSections(renderUnits())); + } + + /** + * Streams the combined document to the caller-owned stream (not closed here). + * + * @param output destination stream that receives the rendered PDF bytes + * @throws DocumentRenderingException if rendering fails + */ + public void writePdf(OutputStream output) throws DocumentRenderingException { + Objects.requireNonNull(output, "output"); + render("write PDF to stream", () -> { + backend.writeSections(renderUnits(), output); + return null; + }); + } + + /** + * Builds the combined document into the default output file configured on the + * builder. + * + * @throws IllegalStateException if no default output file was configured + * @throws DocumentRenderingException if rendering fails + */ + public void buildPdf() throws DocumentRenderingException { + ensureOpen(); + if (defaultOutputFile == null) { + throw new IllegalStateException( + "No default output file was configured for this multi-section document."); + } + buildPdf(defaultOutputFile); + } + + /** + * Builds the combined document into the supplied output file. + * + * @param outputFile destination PDF path + * @throws DocumentRenderingException if rendering fails + */ + public void buildPdf(Path outputFile) throws DocumentRenderingException { + Objects.requireNonNull(outputFile, "outputFile"); + render("build PDF at '" + outputFile + "'", () -> { + try (OutputStream output = Files.newOutputStream(outputFile)) { + backend.writeSections(renderUnits(), output); + } + return null; + }); + } + + /** + * Returns one {@link LayoutSnapshot} per section, in document order, for + * layout regression testing. + * + * @return per-section layout snapshots + */ + public List sectionSnapshots() { + ensureOpen(); + return sections.stream().map(DocumentSession::layoutSnapshot).toList(); + } + + /** + * Closes every section. Idempotent; the first failure is rethrown after all + * sections have been attempted. + */ + @Override + public void close() { + if (closed) { + return; + } + closed = true; + RuntimeException failure = null; + for (DocumentSession section : sections) { + try { + section.close(); + } catch (RuntimeException ex) { + if (failure == null) { + failure = ex; + } + } + } + if (failure != null) { + throw failure; + } + } + + private List renderUnits() { + ensureOpen(); + return sections.stream().map(DocumentSession::toSectionRenderUnit).toList(); + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("This multi-section document has been closed."); + } + } + + private static R render(String action, RenderingBody body) throws DocumentRenderingException { + try { + return body.run(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new DocumentRenderingException("Failed to " + action + ": " + e.getMessage(), e); + } + } + + @FunctionalInterface + private interface RenderingBody { + R run() throws Exception; + } +} diff --git a/src/main/java/com/demcha/compose/document/api/MultiSectionDocumentBuilder.java b/src/main/java/com/demcha/compose/document/api/MultiSectionDocumentBuilder.java new file mode 100644 index 000000000..05a004574 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/api/MultiSectionDocumentBuilder.java @@ -0,0 +1,84 @@ +package com.demcha.compose.document.api; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Builds a {@link MultiSectionDocument} from independently authored + * {@link DocumentSession} sections, in the order they are added. + * + *

Each section is a fully configured session — its own page size, margins, + * fonts, footer / page numbering, and other chrome. Author each section as a + * normal document, then add it here; the resulting {@link MultiSectionDocument} + * concatenates them into one PDF with cross-section navigation.

+ * + *
{@code
+ * DocumentSession cover = GraphCompose.document().pageSize(612, 792).margin(DocumentInsets.of(0)).create();
+ * cover.pageFlow(page -> page.addSection(s -> s.anchor("cover").addParagraph("Title")));
+ *
+ * DocumentSession body = GraphCompose.document().pageSize(420, 595).margin(DocumentInsets.of(36)).create();
+ * body.footer(DocumentHeaderFooter.builder().centerText("{page}").build());
+ * body.pageFlow(page -> page.addSection(s -> s.anchor("intro").addParagraph("Chapter 1")));
+ *
+ * try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) {
+ *     doc.buildPdf();
+ * }
+ * }
+ * + *

Obtain a builder from {@link com.demcha.compose.GraphCompose#documents()}.

+ * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public final class MultiSectionDocumentBuilder { + + private final Path outputFile; + private final List sections = new ArrayList<>(); + + /** + * Creates a builder with no default output file. Prefer + * {@link com.demcha.compose.GraphCompose#documents()}. + */ + public MultiSectionDocumentBuilder() { + this(null); + } + + /** + * Creates a builder with a default output file used by + * {@link MultiSectionDocument#buildPdf()}. Prefer + * {@link com.demcha.compose.GraphCompose#documents(Path)}. + * + * @param outputFile default PDF output path, or {@code null} + */ + public MultiSectionDocumentBuilder(Path outputFile) { + this.outputFile = outputFile; + } + + /** + * Appends a fully authored section. Ownership transfers to the resulting + * {@link MultiSectionDocument}, which closes the section in its own + * {@link MultiSectionDocument#close()}. + * + * @param section the section's document session + * @return this builder + */ + public MultiSectionDocumentBuilder section(DocumentSession section) { + sections.add(Objects.requireNonNull(section, "section")); + return this; + } + + /** + * Creates the multi-section document. + * + * @return the assembled document + * @throws IllegalStateException if no sections were added + */ + public MultiSectionDocument create() { + if (sections.isEmpty()) { + throw new IllegalStateException("A multi-section document needs at least one section."); + } + return new MultiSectionDocument(outputFile, sections); + } +} diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDocumentPostProcessor.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDocumentPostProcessor.java index 53d95700f..f1705dc50 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDocumentPostProcessor.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDocumentPostProcessor.java @@ -64,32 +64,96 @@ public static void apply(PDDocument document, } if (metadataOptions != null) { - PDDocumentInformation info = document.getDocumentInformation(); - if (metadataOptions.getTitle() != null) { - info.setTitle(metadataOptions.getTitle()); - } - if (metadataOptions.getAuthor() != null) { - info.setAuthor(metadataOptions.getAuthor()); - } - if (metadataOptions.getSubject() != null) { - info.setSubject(metadataOptions.getSubject()); - } - if (metadataOptions.getKeywords() != null) { - info.setKeywords(metadataOptions.getKeywords()); - } - if (metadataOptions.getCreator() != null) { - info.setCreator(metadataOptions.getCreator()); - } - if (metadataOptions.getProducer() != null) { - info.setProducer(metadataOptions.getProducer()); - } + applyMetadata(document, metadataOptions); + } + + if (protectionOptions != null) { + applyProtection(document, protectionOptions); + } + } + + /** + * Applies the per-section visible chrome — watermark and repeating + * header/footer — to one contiguous window of pages in a combined + * multi-section document, numbering each section from its own first page. + * + *

Metadata and protection are document-global in PDF and are NOT applied + * here; see {@link #applyDocumentMetadataAndProtection}.

+ * + * @param document target combined PDFBox document + * @param canvas section layout canvas used to derive content margins + * @param watermarkOptions section watermark options, or {@code null} + * @param headerFooterOptions section repeating header/footer options + * @param basePageOffset zero-based index of the section's first page + * @param sectionPageCount number of pages in the section + * @throws IOException if PDFBox post-processing fails + */ + public static void applySectionChrome(PDDocument document, + LayoutCanvas canvas, + PdfWatermarkOptions watermarkOptions, + Collection headerFooterOptions, + int basePageOffset, + int sectionPageCount) throws IOException { + if (watermarkOptions != null) { + PdfWatermarkRenderer.apply( + document, PdfOptionsAdapter.toEngine(watermarkOptions), basePageOffset, sectionPageCount); + } + + if (headerFooterOptions != null && !headerFooterOptions.isEmpty()) { + Margin canvasMargin = canvas.margin(); + float marginLeft = canvasMargin != null ? (float) canvasMargin.left() : 24f; + float marginRight = canvasMargin != null ? (float) canvasMargin.right() : 24f; + List configs = + headerFooterOptions.stream() + .map(PdfOptionsAdapter::toEngine) + .toList(); + PdfHeaderFooterRenderer.apply(document, configs, marginLeft, marginRight, basePageOffset, sectionPageCount); } + } + /** + * Applies document-global metadata and protection to a combined document. + * Unlike watermark and header/footer, these cannot be scoped per section, so + * a multi-section document applies them once. + * + * @param document target PDFBox document + * @param metadataOptions metadata options, or {@code null} + * @param protectionOptions protection options, or {@code null} + * @throws IOException if PDFBox post-processing fails + */ + public static void applyDocumentMetadataAndProtection(PDDocument document, + PdfMetadataOptions metadataOptions, + PdfProtectionOptions protectionOptions) throws IOException { + if (metadataOptions != null) { + applyMetadata(document, metadataOptions); + } if (protectionOptions != null) { applyProtection(document, protectionOptions); } } + private static void applyMetadata(PDDocument document, PdfMetadataOptions metadataOptions) { + PDDocumentInformation info = document.getDocumentInformation(); + if (metadataOptions.getTitle() != null) { + info.setTitle(metadataOptions.getTitle()); + } + if (metadataOptions.getAuthor() != null) { + info.setAuthor(metadataOptions.getAuthor()); + } + if (metadataOptions.getSubject() != null) { + info.setSubject(metadataOptions.getSubject()); + } + if (metadataOptions.getKeywords() != null) { + info.setKeywords(metadataOptions.getKeywords()); + } + if (metadataOptions.getCreator() != null) { + info.setCreator(metadataOptions.getCreator()); + } + if (metadataOptions.getProducer() != null) { + info.setProducer(metadataOptions.getProducer()); + } + } + /** * Applies canonical document-level PDF options to already rendered PDF bytes * and returns a new byte array. diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java index ee46170f4..86273f096 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java @@ -5,8 +5,11 @@ import com.demcha.compose.document.backend.fixed.pdf.handlers.*; import com.demcha.compose.document.backend.fixed.pdf.options.*; import com.demcha.compose.document.exceptions.UnsupportedNodeCapabilityException; +import com.demcha.compose.document.layout.LayoutCanvas; import com.demcha.compose.document.layout.LayoutGraph; import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.font.FontFamilyDefinition; +import com.demcha.compose.font.FontName; import com.demcha.compose.document.layout.payloads.*; import com.demcha.compose.document.node.DocumentLinkTarget; import com.demcha.compose.document.node.ExternalLinkTarget; @@ -351,24 +354,7 @@ private PDDocument buildDocument(LayoutGraph graph, FixedLayoutRenderContext con try (PdfRenderSession session = new PdfRenderSession(document, pages)) { PdfRenderEnvironment environment = new PdfRenderEnvironment(document, fonts, session); - Map> ownerBounds = debug.enabled() - ? PdfGuideLinesRenderer.computeOwnerBounds(graph.fragments()) - : Map.of(); - PdfFragmentRenderHandler tableRowHandler = handlers.get(TableRowFragmentPayload.class); - for (int index = 0; index < graph.fragments().size(); index++) { - PlacedFragment fragment = graph.fragments().get(index); - if (fragment.payload() instanceof TableRowFragmentPayload - && tableRowHandler instanceof PdfTableRowFragmentRenderHandler tableHandler) { - index = renderTableRowGroup(graph.fragments(), index, tableHandler, environment, ownerBounds); - continue; - } - renderFragment(fragment, environment, ownerBounds); - } - // Node labels paint as one post-pass so badges always land on - // top of the content they annotate, in deterministic order. - if (debug.showNodeLabels()) { - PdfNodeLabelRenderer.drawAll(ownerBounds, environment, debug.labelText()); - } + renderGraph(graph, environment); PdfBookmarkOutlineWriter.apply(document, environment.bookmarkRecords()); // Pass B of internal-link resolution: every anchor is now placed, // so deferred go-to links (incl. forward references) can resolve. @@ -393,6 +379,174 @@ private PDDocument buildDocument(LayoutGraph graph, FixedLayoutRenderContext con } } + /** + * Paints every fragment of one graph onto the current render environment's + * pages, in fragment order, grouping table rows so fills land beneath + * borders and text. Shared by the single-section and multi-section paths. + */ + private void renderGraph(LayoutGraph graph, PdfRenderEnvironment environment) throws Exception { + Map> ownerBounds = debug.enabled() + ? PdfGuideLinesRenderer.computeOwnerBounds(graph.fragments()) + : Map.of(); + PdfFragmentRenderHandler tableRowHandler = handlers.get(TableRowFragmentPayload.class); + for (int index = 0; index < graph.fragments().size(); index++) { + PlacedFragment fragment = graph.fragments().get(index); + if (fragment.payload() instanceof TableRowFragmentPayload + && tableRowHandler instanceof PdfTableRowFragmentRenderHandler tableHandler) { + index = renderTableRowGroup(graph.fragments(), index, tableHandler, environment, ownerBounds); + continue; + } + renderFragment(fragment, environment, ownerBounds); + } + // Node labels paint as one post-pass so badges always land on + // top of the content they annotate, in deterministic order. + if (debug.showNodeLabels()) { + PdfNodeLabelRenderer.drawAll(ownerBounds, environment, debug.labelText()); + } + } + + /** + * Concatenates several {@link Section sections} into one PDF and returns its + * bytes. Each section keeps its own page geometry, fonts, and chrome + * (watermark, header/footer); anchors, links, and bookmarks resolve across + * section boundaries against the combined document. + * + *

This is the low-level assembly seam; + * {@link com.demcha.compose.document.api.MultiSectionDocument} (via + * {@link com.demcha.compose.GraphCompose#documents()}) is the public entry + * point that builds the {@link Section}s for you.

+ * + * @param sections ordered, non-empty list of sections + * @return rendered combined-document bytes + * @throws Exception if PDF creation, rendering, or saving fails + * @since 1.9.0 + */ + public byte[] renderSections(List
sections) throws Exception { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + writeSections(sections, output); + return output.toByteArray(); + } + } + + /** + * Concatenates several {@link Section sections} into one PDF written to the + * caller-owned stream (never closed by the backend). + * + * @param sections ordered, non-empty list of sections + * @param output caller-owned output stream + * @throws Exception if PDF creation, rendering, or saving fails + * @since 1.9.0 + */ + public void writeSections(List
sections, OutputStream output) throws Exception { + Objects.requireNonNull(output, "output"); + try (PDDocument document = buildSectionsDocument(sections)) { + document.save(output); + } + } + + private PDDocument buildSectionsDocument(List
sections) throws Exception { + Objects.requireNonNull(sections, "sections"); + if (sections.isEmpty()) { + throw new IllegalArgumentException("A multi-section document needs at least one section."); + } + PDDocument document = new PDDocument(); + try { + FontLibrary fonts = PdfFontLibraryFactory.library(document, unionCustomFonts(sections)); + Map anchors = new LinkedHashMap<>(); + List links = new ArrayList<>(); + List bookmarks = new ArrayList<>(); + int pageOffset = 0; + for (Section section : sections) { + LayoutGraph graph = section.graph(); + List pages = createPages(document, graph); + try (PdfRenderSession renderSession = new PdfRenderSession(document, pages)) { + // Each section renders with its OWN backend's handlers/debug, but + // records navigation against the combined document via the page offset. + PdfRenderEnvironment environment = + new PdfRenderEnvironment(document, fonts, renderSession, pageOffset); + section.chrome().renderGraph(graph, environment); + bookmarks.addAll(environment.bookmarkRecords()); + links.addAll(environment.deferredInternalLinks()); + environment.anchorDestinations().forEach((name, destination) -> { + if (anchors.put(name, destination) != null) { + RENDER_LOG.warn( + "render.pdf.multisection.anchor.duplicate name={} — last section wins", name); + } + }); + } + PdfFixedLayoutBackend chrome = section.chrome(); + PdfDocumentPostProcessor.applySectionChrome( + document, + section.canvas(), + chrome.watermarkOptions, + chrome.headerFooterOptions, + pageOffset, + pages.size()); + pageOffset += pages.size(); + } + // Every anchor is now placed, so cross-section go-to links and the + // combined outline resolve in a single pass over the merged maps. + PdfBookmarkOutlineWriter.apply(document, List.copyOf(bookmarks)); + PdfInternalLinkWriter.apply(document, Map.copyOf(anchors), List.copyOf(links)); + applyDocumentMetadataAndProtection(document, sections); + return document; + } catch (Exception ex) { + document.close(); + throw ex; + } + } + + private static void applyDocumentMetadataAndProtection(PDDocument document, List
sections) + throws IOException { + // Metadata and protection are document-global in PDF; the first section + // that declares each wins for the combined document. + PdfMetadataOptions metadata = null; + PdfProtectionOptions protection = null; + for (Section section : sections) { + if (metadata == null) { + metadata = section.chrome().metadataOptions; + } + if (protection == null) { + protection = section.chrome().protectionOptions; + } + } + PdfDocumentPostProcessor.applyDocumentMetadataAndProtection(document, metadata, protection); + } + + private static List unionCustomFonts(List
sections) { + Map byName = new LinkedHashMap<>(); + for (Section section : sections) { + for (FontFamilyDefinition family : section.customFonts()) { + byName.putIfAbsent(family.name(), family); + } + } + return List.copyOf(byName.values()); + } + + /** + * One section of a multi-section document: a fully laid-out graph plus the + * geometry, fonts, and chrome of the {@link com.demcha.compose.document.api.DocumentSession} + * that produced it. The {@code chrome} backend carries that section's + * watermark, header/footer, metadata, and protection options. + * + * @param graph the section's resolved layout graph + * @param canvas the section's page canvas (size + margins) + * @param customFonts the section's custom font families + * @param chrome the section's configured backend + * @since 1.9.0 + */ + public record Section(LayoutGraph graph, + LayoutCanvas canvas, + List customFonts, + PdfFixedLayoutBackend chrome) { + public Section { + Objects.requireNonNull(graph, "graph"); + Objects.requireNonNull(canvas, "canvas"); + Objects.requireNonNull(chrome, "chrome"); + customFonts = customFonts == null ? List.of() : List.copyOf(customFonts); + } + } + private int renderTableRowGroup(List fragments, int startIndex, PdfTableRowFragmentRenderHandler handler, @@ -484,7 +638,7 @@ private void emitLinkTarget(PdfRenderEnvironment environment, DocumentLinkTarget target) throws IOException { if (target instanceof ExternalLinkTarget external) { PdfLinkAnnotationWriter.addUriLink( - environment.document().getPage(pageIndex), + environment.documentPage(pageIndex), rectangle, external.options()); } else if (target instanceof InternalLinkTarget internal) { diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfRenderEnvironment.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfRenderEnvironment.java index 81190b4f7..808edcd27 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfRenderEnvironment.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfRenderEnvironment.java @@ -33,15 +33,36 @@ public final class PdfRenderEnvironment { private final PDDocument document; private final FontLibrary fonts; private final PdfRenderSession session; + private final int pageIndexOffset; private final Map imageCache = new HashMap<>(); private final List bookmarkRecords = new ArrayList<>(); private final Map anchorDestinations = new LinkedHashMap<>(); private final List deferredInternalLinks = new ArrayList<>(); PdfRenderEnvironment(PDDocument document, FontLibrary fonts, PdfRenderSession session) { + this(document, fonts, session, 0); + } + + /** + * Creates an environment whose recorded anchors, links, and bookmarks are + * stamped with a page index shifted by {@code pageIndexOffset}. + * + *

Drawing stays local: {@link #pageSurface(int)} indexes the section's own + * page list (offset {@code 0}). Only the destinations that resolve against the + * combined document — anchors, deferred links, and bookmarks — are rebased by + * the offset, so a section appended at page {@code N} navigates correctly. A + * single-section render uses offset {@code 0} and is unaffected.

+ * + * @param document live combined document + * @param fonts shared font library + * @param session page-scoped drawing surface for this section + * @param pageIndexOffset number of pages already placed before this section + */ + PdfRenderEnvironment(PDDocument document, FontLibrary fonts, PdfRenderSession session, int pageIndexOffset) { this.document = document; this.fonts = fonts; this.session = session; + this.pageIndexOffset = pageIndexOffset; } /** @@ -73,6 +94,19 @@ public PDPageContentStream pageSurface(int pageIndex) throws IOException { return session.pageSurface(pageIndex); } + /** + * Resolves a section-local page index to the physical page in the combined + * document, applying the section's page-index offset. Used by link + * annotations, which attach to the document page rather than the section's + * drawing surface. + * + * @param localPageIndex zero-based page index within the current section + * @return the physical page in the combined document + */ + public org.apache.pdfbox.pdmodel.PDPage documentPage(int localPageIndex) { + return document.getPage(localPageIndex + pageIndexOffset); + } + /** * Resolves an image XObject through the render-pass image cache. * @@ -97,7 +131,7 @@ void registerBookmark(PlacedFragment fragment, DocumentBookmarkOptions bookmarkO bookmarkRecords.add(new BookmarkRecord( bookmarkOptions.title(), bookmarkOptions.level(), - fragment.pageIndex(), + fragment.pageIndex() + pageIndexOffset, fragment.y() + fragment.height())); } @@ -118,7 +152,7 @@ public void registerAnchor(PlacedFragment fragment, String anchor) { return; } AnchorDestination destination = new AnchorDestination( - fragment.pageIndex(), + fragment.pageIndex() + pageIndexOffset, fragment.x(), fragment.y() + fragment.height()); AnchorDestination previous = anchorDestinations.put(anchor, destination); @@ -136,7 +170,7 @@ public void registerAnchor(PlacedFragment fragment, String anchor) { * @param anchor target anchor name */ void deferInternalLink(int pageIndex, PdfLinkAnnotationWriter.PlacedPdfRect rectangle, String anchor) { - deferredInternalLinks.add(new DeferredInternalLink(pageIndex, rectangle, anchor)); + deferredInternalLinks.add(new DeferredInternalLink(pageIndex + pageIndexOffset, rectangle, anchor)); } Map anchorDestinations() { diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java index c26063077..99fbdb33b 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java @@ -44,22 +44,50 @@ public static void apply(PDDocument doc, List configs, float marginLeft, float marginRight) throws IOException { - if (configs == null || configs.isEmpty()) return; + if (doc == null) return; + apply(doc, configs, marginLeft, marginRight, 0, doc.getNumberOfPages()); + } - int totalPages = doc.getNumberOfPages(); + /** + * Applies the given header/footer configurations to one contiguous window of + * pages, numbering them relative to that window. + * + *

This is the multi-section entry point: each section of a combined + * document gets its own header/footer with section-local {@code {page}} / + * {@code {pages}} tokens. {@code currentPage} runs {@code 1..sectionPageCount} + * within the window (so {@link HeaderFooterConfig#appliesTo} and the page + * counter restart per section) and {@code totalPages} is the section's own + * page count rather than the whole document's.

+ * + * @param doc the target PDF document + * @param configs list of header/footer configurations + * @param marginLeft left margin of the section canvas + * @param marginRight right margin of the section canvas + * @param basePageOffset zero-based index of the window's first physical page + * @param sectionPageCount number of pages in the window + * @throws IOException if writing to the content stream fails + */ + public static void apply(PDDocument doc, + List configs, + float marginLeft, + float marginRight, + int basePageOffset, + int sectionPageCount) throws IOException { + if (configs == null || configs.isEmpty()) return; - for (int i = 0; i < totalPages; i++) { - PDPage page = doc.getPage(i); + for (int local = 0; local < sectionPageCount; local++) { + int physical = basePageOffset + local; + PDPage page = doc.getPage(physical); PDRectangle mediaBox = page.getMediaBox(); try (PDPageContentStream cs = new PDPageContentStream( doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { for (HeaderFooterConfig config : configs) { - if (!config.appliesTo(i + 1)) { + if (!config.appliesTo(local + 1)) { continue; } - renderZone(cs, config, mediaBox, i + 1, totalPages, marginLeft, marginRight); + renderZone(cs, config, mediaBox, local + 1, sectionPageCount, marginLeft, marginRight); } } } diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java index 5a288e9c2..0581d216c 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java @@ -41,9 +41,28 @@ private PdfWatermarkRenderer() { * @throws IOException if writing to the content stream fails */ public static void apply(PDDocument doc, WatermarkConfig config) throws IOException { + if (doc == null) return; + apply(doc, config, 0, doc.getNumberOfPages()); + } + + /** + * Applies the given watermark configuration to one contiguous window of + * pages. This is the multi-section entry point: each section's watermark is + * confined to that section's pages, which can have a different page size from + * the rest of the combined document. + * + * @param doc the target PDF document + * @param config the watermark configuration + * @param basePageOffset zero-based index of the window's first page + * @param sectionPageCount number of pages in the window + * @throws IOException if writing to the content stream fails + */ + public static void apply(PDDocument doc, WatermarkConfig config, int basePageOffset, int sectionPageCount) + throws IOException { if (config == null) return; - for (int i = 0; i < doc.getNumberOfPages(); i++) { + for (int local = 0; local < sectionPageCount; local++) { + int i = basePageOffset + local; PDPage page = doc.getPage(i); PDRectangle mediaBox = page.getMediaBox(); diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java new file mode 100644 index 000000000..c69d9d4ca --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java @@ -0,0 +1,206 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +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.fixed.pdf.options.PdfHeaderFooterOptions; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * End-to-end proof for {@code GraphCompose.documents()}: several sections with + * their own page geometry and chrome are concatenated into one PDF, navigation + * resolves across section boundaries, and each section is numbered from its own + * first page. + */ +class MultiSectionDocumentTest { + + @TempDir + Path tempDir; + + private static DocumentSession cover() { + DocumentSession cover = GraphCompose.document() + .pageSize(300, 400) + .margin(DocumentInsets.of(24)) + .create(); + // A clickable jump to an anchor declared in a LATER section. + cover.pageFlow(page -> page.addParagraph(p -> p.text("Go to the introduction").linkTo("intro"))); + return cover; + } + + private static DocumentSession body() { + DocumentSession body = GraphCompose.document() + .pageSize(240, 360) + .margin(DocumentInsets.of(24)) + .create(); + body.footer(PdfHeaderFooterOptions.builder().centerText("Page {page} of {pages}").build()); + body.pageFlow(page -> { + page.addSection(s -> s.anchor("intro").addParagraph("Introduction")); + page.addPageBreak(b -> b.name("body-break")); + page.addSection(s -> s.addParagraph("More body")); + }); + return body; + } + + private static String pageText(PDDocument doc, int oneBasedPage) throws IOException { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(oneBasedPage); + stripper.setEndPage(oneBasedPage); + return stripper.getText(doc); + } + + private static List goToTargetPages(PDDocument doc, int zeroBasedPage) throws IOException { + List targets = new ArrayList<>(); + for (PDAnnotation annotation : doc.getPage(zeroBasedPage).getAnnotations()) { + if (annotation instanceof PDAnnotationLink link && link.getAction() instanceof PDActionGoTo goTo + && goTo.getDestination() instanceof PDPageDestination dest) { + targets.add(dest.retrievePageNumber()); + } + } + return targets; + } + + @Test + void concatenatesSectionsKeepingEachSectionsPageSize() throws Exception { + Path out = tempDir.resolve("mixed.pdf"); + try (MultiSectionDocument doc = GraphCompose.documents(out) + .section(cover()) + .section(body()) + .create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + // cover (1 page) + body (2 pages) + assertThat(pdf.getNumberOfPages()).isEqualTo(3); + assertThat(pdf.getPage(0).getMediaBox().getWidth()).isEqualTo(300f); + assertThat(pdf.getPage(1).getMediaBox().getWidth()).isEqualTo(240f); + assertThat(pdf.getPage(2).getMediaBox().getWidth()).isEqualTo(240f); + } + } + + @Test + void linksResolveAcrossSections() throws Exception { + Path out = tempDir.resolve("cross-link.pdf"); + try (MultiSectionDocument doc = GraphCompose.documents(out) + .section(cover()) + .section(body()) + .create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + // The cover's link targets "intro", which lives on the body's first + // page — global (zero-based) page index 1 in the combined document. + assertThat(goToTargetPages(pdf, 0)).contains(1); + } + } + + @Test + void numbersEachSectionFromItsOwnFirstPage() throws Exception { + Path out = tempDir.resolve("numbering.pdf"); + try (MultiSectionDocument doc = GraphCompose.documents(out) + .section(cover()) + .section(body()) + .create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + // Body's first page is the document's 2nd page, yet its footer counts + // from the section's own first page: "Page 1 of 2", not "Page 2 of 3". + assertThat(pageText(pdf, 2)).contains("Page 1 of 2"); + assertThat(pageText(pdf, 3)).contains("Page 2 of 2"); + // The cover has no footer. + assertThat(pageText(pdf, 1)).doesNotContain("Page"); + } + } + + @Test + void bookmarkAndExternalLinkInALaterSectionRebaseToGlobalPages() throws Exception { + Path out = tempDir.resolve("rebased-chrome.pdf"); + DocumentSession cover = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + cover.pageFlow(page -> page.addParagraph("Cover")); + + DocumentSession body = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + body.pageFlow(page -> page.addParagraph(p -> p.text("Chapter body") + .bookmark(new DocumentBookmarkOptions("Chapter")) + .link(new DocumentLinkOptions("https://example.com/chapter")))); + + try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + // The bookmark and the external link live in the SECOND section, so + // both must land on the body's global page (index 1), not page 0. + PDDocumentOutline outline = pdf.getDocumentCatalog().getDocumentOutline(); + PDOutlineItem chapter = outline.getFirstChild(); + assertThat(chapter.getTitle()).isEqualTo("Chapter"); + assertThat(((PDPageDestination) chapter.getDestination()).retrievePageNumber()).isEqualTo(1); + + assertThat(pdf.getPage(0).getAnnotations()).noneMatch(a -> a instanceof PDAnnotationLink); + assertThat(pdf.getPage(1).getAnnotations()) + .anyMatch(a -> a instanceof PDAnnotationLink link && link.getAction() instanceof PDActionURI); + } + } + + @Test + void duplicateAnchorAcrossSectionsResolvesToTheLastSection() throws Exception { + Path out = tempDir.resolve("dup-anchor.pdf"); + DocumentSession first = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + first.pageFlow(page -> page.addSection(s -> s.anchor("dup") + .addParagraph(p -> p.text("Jump to dup").linkTo("dup")))); + + DocumentSession second = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + second.pageFlow(page -> page.addSection(s -> s.anchor("dup").addParagraph("Second dup"))); + + try (MultiSectionDocument doc = GraphCompose.documents(out).section(first).section(second).create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + // "dup" is declared in both sections; the last registration wins, so + // the link on page 0 jumps to the second section's page (index 1). + assertThat(goToTargetPages(pdf, 0)).contains(1); + } + } + + @Test + void sectionSnapshotsReturnsOnePerSection() { + try (MultiSectionDocument doc = GraphCompose.documents() + .section(cover()) + .section(body()) + .create()) { + assertThat(doc.sectionSnapshots()).hasSize(2); + } + } + + @Test + void builderRejectsAnEmptyDocument() { + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> GraphCompose.documents().create()) + .withMessageContaining("at least one section"); + } +} From 88571aae51589c6eef77debc98f17b39c32ff330 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 00:57:31 +0100 Subject: [PATCH 2/2] fix(engine): place multi-section stroke patterns and node labels on the correct page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gradient- or pattern-stroked path, SVG, or text layer in a section after the first registered its shading pattern against the section-local page index of the combined document, so the pattern landed on an earlier section's page resources while the stroke drew on the section's own page. The stroke then referenced a pattern absent from its own page dictionary and dropped from the output. Resolve the page through the render environment's page-offset mapping at that site and at the debug node-label MediaBox lookup, which shared the flaw. Single-section rendering is unaffected (offset zero). Also mark the low-level multi-section assembly seam — PdfFixedLayoutBackend.Section, renderSections, and writeSections — @Beta so its shape can still change, and move the example and tests onto the canonical DocumentHeaderFooter / DocumentMetadata APIs instead of the deprecated PDF-options forms. Tests: a gradient-stroked path in a later section registers its pattern on its own page (red before the fix); an internal link whose source is on a third section lands on its global page; a footer can skip its section's own first page; document metadata is taken from the first section that sets it; and close() is idempotent, rejects further rendering, and buildPdf() without a default file throws. --- .../examples/multi-section-document.pdf | Bin 2832 -> 2832 bytes examples/README.md | 2 +- .../structure/MultiSectionExample.java | 6 +- .../fixed/pdf/PdfFixedLayoutBackend.java | 4 + .../fixed/pdf/PdfNodeLabelRenderer.java | 5 +- .../fixed/pdf/handlers/PdfPathPainter.java | 6 +- .../fixed/pdf/MultiSectionDocumentTest.java | 139 +++++++++++++++++- 7 files changed, 154 insertions(+), 8 deletions(-) diff --git a/assets/readme/examples/multi-section-document.pdf b/assets/readme/examples/multi-section-document.pdf index 6afed1d48c5755747c2b29f9af5f3fad70caa4f7..14503b3e564e3ba62ef7ccb6275d01e4a0dc7f53 100644 GIT binary patch delta 144 zcmbOrHbHE|F3x%b12<;_M@wTz3ln2Yb7K<=7bgQlXH!F0b0;SwGebiQb3;crV+%JU oS2t%T16M;s6E_1(H*;5K6DKnhQ%g5H1sh6?U;&vi*_P`v0Q24=f&c&j delta 144 zcmbOrHbHE|F3x&017{OwCj%EFBWEL5M+;*MCl?o2OIITca}!H9M-x{^V*?XMOGgt^ oBLhnpCrd|1M@u6EXIB?vH*;r0CrdLs1sh6?U;&vi*_P`v04FIU8UO$Q diff --git a/examples/README.md b/examples/README.md index 48c76c041..3d8deb3d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -755,7 +755,7 @@ portrait, page-numbered body in a single document. ```java DocumentSession cover = GraphCompose.document().pageSize(440, 300).margin(DocumentInsets.of(0)).create(); DocumentSession body = GraphCompose.document().pageSize(300, 440).margin(DocumentInsets.of(40)).create(); -body.footer(PdfHeaderFooterOptions.builder().centerText("{page} / {pages}").build()); +body.footer(DocumentHeaderFooter.builder().centerText("{page} / {pages}").build()); try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) { doc.buildPdf(); // cover keeps its geometry; body is numbered 1..N from its own first page diff --git a/examples/src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java b/examples/src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java index b522faf03..52b69a915 100644 --- a/examples/src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java +++ b/examples/src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java @@ -3,7 +3,7 @@ import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.api.MultiSectionDocument; -import com.demcha.compose.document.backend.fixed.pdf.options.PdfHeaderFooterOptions; +import com.demcha.compose.document.output.DocumentHeaderFooter; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentTextStyle; @@ -21,7 +21,7 @@ *
{@code
  * DocumentSession cover = GraphCompose.document().pageSize(440, 300).margin(of(0)).create();
  * DocumentSession body  = GraphCompose.document().pageSize(300, 440).margin(of(40)).create();
- * body.footer(PdfHeaderFooterOptions.builder().centerText("{page}").build());
+ * body.footer(DocumentHeaderFooter.builder().centerText("{page}").build());
  *
  * try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) {
  *     doc.buildPdf();
@@ -77,7 +77,7 @@ public static Path generate() throws Exception {
                 .pageSize(300, 440)
                 .margin(DocumentInsets.of(40))
                 .create();
-        body.footer(PdfHeaderFooterOptions.builder().centerText("{page} / {pages}").build());
+        body.footer(DocumentHeaderFooter.builder().centerText("{page} / {pages}").build());
         body.pageFlow(page -> {
             for (int i = 0; i < CHAPTERS.length; i++) {
                 String[] chapter = CHAPTERS[i];
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
index 86273f096..aba40acdf 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
@@ -1,5 +1,6 @@
 package com.demcha.compose.document.backend.fixed.pdf;
 
+import com.demcha.compose.document.api.Beta;
 import com.demcha.compose.document.backend.fixed.FixedLayoutBackend;
 import com.demcha.compose.document.backend.fixed.FixedLayoutRenderContext;
 import com.demcha.compose.document.backend.fixed.pdf.handlers.*;
@@ -421,6 +422,7 @@ private void renderGraph(LayoutGraph graph, PdfRenderEnvironment environment) th
      * @throws Exception if PDF creation, rendering, or saving fails
      * @since 1.9.0
      */
+    @Beta
     public byte[] renderSections(List
sections) throws Exception { try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { writeSections(sections, output); @@ -437,6 +439,7 @@ public byte[] renderSections(List
sections) throws Exception { * @throws Exception if PDF creation, rendering, or saving fails * @since 1.9.0 */ + @Beta public void writeSections(List
sections, OutputStream output) throws Exception { Objects.requireNonNull(output, "output"); try (PDDocument document = buildSectionsDocument(sections)) { @@ -535,6 +538,7 @@ private static List unionCustomFonts(List
section * @param chrome the section's configured backend * @since 1.9.0 */ + @Beta public record Section(LayoutGraph graph, LayoutCanvas canvas, List customFonts, diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfNodeLabelRenderer.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfNodeLabelRenderer.java index cb6acf87e..a9351f0ac 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfNodeLabelRenderer.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfNodeLabelRenderer.java @@ -70,7 +70,10 @@ static void drawAll(Map> owne PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); for (Map.Entry> page : byPage.entrySet()) { int pageIndex = page.getKey(); - PDRectangle mediaBox = environment.document().getPage(pageIndex).getMediaBox(); + // Resolve the section-local index to the physical combined-document page + // so the MediaBox matches the page the label draws on (identity for a + // single section; offset for a later section in a multi-section document). + PDRectangle mediaBox = environment.documentPage(pageIndex).getMediaBox(); PDPageContentStream stream = environment.pageSurface(pageIndex); for (Map.Entry label : page.getValue().entrySet()) { draw(stream, font, mediaBox, label.getKey(), label.getValue(), labelText); diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathPainter.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathPainter.java index e2c0b48cf..e271389b6 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathPainter.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathPainter.java @@ -137,7 +137,11 @@ private static void fillStrokePath(PDPageContentStream stream, boolean hasStrokeWidth = stroke != null && stroke.width() > 0; if (strokePaint != null && hasStrokeWidth) { - PDResources resources = environment.document().getPage(pageIndex).getResources(); + // The shading pattern must register on the SAME physical page the + // stroke draws on. In a multi-section document that page is offset + // from the section-local pageIndex, so resolve it through the + // environment's page-offset mapping (identity for a single section). + PDResources resources = environment.documentPage(pageIndex).getResources(); stream.setStrokingColor(PdfShadingSupport.strokePattern( strokePaint, resources, x, y, width, height)); stream.setLineWidth((float) stroke.width()); diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java index c69d9d4ca..dcb8a4d3f 100644 --- a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java @@ -3,11 +3,17 @@ 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.fixed.pdf.options.PdfHeaderFooterOptions; import com.demcha.compose.document.node.DocumentBookmarkOptions; import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentMetadata; +import com.demcha.compose.document.output.DocumentPageNumbering; +import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; +import com.demcha.compose.document.style.DocumentStroke; import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo; import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; @@ -54,7 +60,7 @@ private static DocumentSession body() { .pageSize(240, 360) .margin(DocumentInsets.of(24)) .create(); - body.footer(PdfHeaderFooterOptions.builder().centerText("Page {page} of {pages}").build()); + body.footer(DocumentHeaderFooter.builder().centerText("Page {page} of {pages}").build()); body.pageFlow(page -> { page.addSection(s -> s.anchor("intro").addParagraph("Introduction")); page.addPageBreak(b -> b.name("body-break")); @@ -203,4 +209,133 @@ void builderRejectsAnEmptyDocument() { .isThrownBy(() -> GraphCompose.documents().create()) .withMessageContaining("at least one section"); } + + @Test + void gradientStrokeInALaterSectionRegistersOnItsOwnPage() throws Exception { + Path out = tempDir.resolve("gradient-section.pdf"); + DocumentColor a = DocumentColor.rgb(167, 139, 250); + DocumentColor b = DocumentColor.rgb(97, 40, 217); + DocumentPaint axis = new DocumentPaint.LinearAxis( + List.of(new DocumentPaint.Stop(0.0, a), new DocumentPaint.Stop(1.0, b)), 0.0, 0.0, 1.0, 1.0); + + DocumentSession cover = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + cover.pageFlow(page -> page.addParagraph("Cover")); + + DocumentSession body = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + body.pageFlow().name("Flow").addPath(p -> p.size(120, 60) + .moveTo(0, 0.5).curveTo(0.25, 1, 0.75, 0, 1, 0.5) + .stroke(DocumentStroke.of(a, 3)) + .strokePaint(axis)).build(); + + try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + // The gradient stroke is on the BODY (section 2, page index 1). Its shading + // pattern must register on the body's own page resources, not the cover's. + assertThat(patternCount(pdf, 1)).isEqualTo(1); + assertThat(patternCount(pdf, 0)).isZero(); + } + } + + @Test + void internalLinkSourceInALaterSectionLandsOnItsGlobalPage() throws Exception { + Path out = tempDir.resolve("backward-link.pdf"); + DocumentSession cover = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + cover.pageFlow(page -> page.addSection(s -> s.anchor("home").addParagraph("Home"))); + + DocumentSession middle = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + middle.pageFlow(page -> page.addParagraph("Middle")); + + DocumentSession last = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + last.pageFlow(page -> page.addParagraph(p -> p.text("Back to home").linkTo("home"))); + + try (MultiSectionDocument doc = GraphCompose.documents(out) + .section(cover).section(middle).section(last).create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + assertThat(pdf.getNumberOfPages()).isEqualTo(3); + // The link SOURCE is on the third section (global page 2, cumulative offset 2); + // its annotation must land on page 2 and target the cover (page 0). + assertThat(goToTargetPages(pdf, 2)).contains(0); + assertThat(goToTargetPages(pdf, 0)).isEmpty(); + } + } + + @Test + void footerCanSkipASectionsOwnFirstPage() throws Exception { + Path out = tempDir.resolve("skip-first.pdf"); + DocumentSession cover = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + cover.pageFlow(page -> page.addParagraph("Cover")); + + DocumentSession body = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + body.footer(DocumentHeaderFooter.builder() + .centerText("Folio {page}") + .numbering(DocumentPageNumbering.builder().showOnFirstPage(false).build()) + .build()); + body.pageFlow(page -> { + page.addParagraph("Body one"); + page.addPageBreak(b -> b.name("body-break")); + page.addParagraph("Body two"); + }); + + try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + // Body's own first page (document page 2) suppresses the footer; its + // second page (document page 3) shows "Folio 2" — the section-local window. + assertThat(pageText(pdf, 2)).doesNotContain("Folio"); + assertThat(pageText(pdf, 3)).contains("Folio 2"); + } + } + + @Test + void metadataIsTakenFromTheFirstSectionThatDeclaresIt() throws Exception { + Path out = tempDir.resolve("metadata.pdf"); + DocumentSession cover = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + cover.metadata(DocumentMetadata.builder().title("Cover wins").build()); + cover.pageFlow(page -> page.addParagraph("Cover")); + + DocumentSession body = GraphCompose.document().pageSize(300, 400).margin(DocumentInsets.of(24)).create(); + body.metadata(DocumentMetadata.builder().title("Body loses").build()); + body.pageFlow(page -> page.addParagraph("Body")); + + try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + assertThat(pdf.getDocumentInformation().getTitle()).isEqualTo("Cover wins"); + } + } + + @Test + void closeIsIdempotentThenRejectsFurtherRendering() { + MultiSectionDocument doc = GraphCompose.documents().section(cover()).section(body()).create(); + doc.close(); + doc.close(); // idempotent — no throw on the second call + assertThatExceptionOfType(IllegalStateException.class).isThrownBy(doc::toPdfBytes); + } + + @Test + void buildPdfWithoutADefaultOutputFileThrows() { + try (MultiSectionDocument doc = GraphCompose.documents().section(cover()).section(body()).create()) { + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(doc::buildPdf) + .withMessageContaining("default output file"); + } + } + + private static int patternCount(PDDocument doc, int zeroBasedPage) throws IOException { + int count = 0; + for (COSName ignored : doc.getPage(zeroBasedPage).getResources().getPatternNames()) { + count++; + } + return count; + } }