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