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 000000000..14503b3e5 Binary files /dev/null and b/assets/readme/examples/multi-section-document.pdf differ diff --git a/examples/README.md b/examples/README.md index 1657510c5..3d8deb3d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -103,6 +103,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Page numbering](#page-numbering) | `DocumentPageNumbering` — offset / restart / roman / suppress-on-first-page for `{page}` / `{pages}` footer tokens | [PDF](../assets/readme/examples/page-numbering.pdf) · [Source](src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java) | | [Page references](#page-references) | `addPageReference(anchor)` — print the page an `anchor(...)` lands on (a native "see page N" cross-reference), resolved in one authoring pass | [PDF](../assets/readme/examples/page-reference.pdf) · [Source](src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java) | | [Table of contents](#table-of-contents) | `addTableOfContents(toc -> 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(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 +} +``` + +[📄 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..52b69a915 --- /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.output.DocumentHeaderFooter; +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(DocumentHeaderFooter.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(DocumentHeaderFooter.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..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,12 +1,16 @@ 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.*; 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 +355,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 +380,177 @@ 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 + */ + @Beta + 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 + */ + @Beta + 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 + */ + @Beta + 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 +642,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/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/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/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/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..dcb8a4d3f --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/MultiSectionDocumentTest.java @@ -0,0 +1,341 @@ +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.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; +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(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")); + 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"); + } + + @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; + } +}