Skip to content

Commit d0e8d1b

Browse files
authored
feat(api): GraphCompose.documents() — multi-section documents in one PDF (#238)
* feat(api): GraphCompose.documents() — multi-section documents in one PDF A document had a single page geometry: one page size, one margin, one numbering scheme for the whole file. Assembling a book — a full-bleed cover of one size in front of a margined, page-numbered body of another — meant rendering separate documents and merging them with external PDFBox, which re-parses each part and is awkward to keep navigable. GraphCompose.documents() concatenates several independent DocumentSession sections — each with its own page size, margins, fonts, and footer numbering — into one PDF inside the engine, with no external merge. Each section's pages are appended at a growing page offset; anchors, internal links, and the bookmark outline resolve across section boundaries against the combined document, so a link on the cover can jump to a chapter in the body. Each section's footer counts from its own first page, so roman front-matter can precede an arabic-numbered body. Document-level metadata and protection are taken from the first section that declares them. Single-section output is unchanged: the existing renderers delegate to a windowed overload with a zero page offset, and the link/anchor/bookmark page indices are rebased only when a section starts past page one. The full suite passes with no changed visual baselines. Tests: MultiSectionDocumentTest renders a cover + body and asserts each section keeps its page size, a cover link resolves to the body's global page, a bookmark and external link in a later section rebase to their global pages, a duplicate anchor resolves to the last section, and each section is numbered from its own first page. Example: MultiSectionExample (landscape cover + portrait, page-numbered body in one PDF). * fix(engine): place multi-section stroke patterns and node labels on the correct page A gradient- or pattern-stroked path, SVG, or text layer in a section after the first registered its shading pattern against the section-local page index of the combined document, so the pattern landed on an earlier section's page resources while the stroke drew on the section's own page. The stroke then referenced a pattern absent from its own page dictionary and dropped from the output. Resolve the page through the render environment's page-offset mapping at that site and at the debug node-label MediaBox lookup, which shared the flaw. Single-section rendering is unaffected (offset zero). Also mark the low-level multi-section assembly seam — PdfFixedLayoutBackend.Section, renderSections, and writeSections — @beta so its shape can still change, and move the example and tests onto the canonical DocumentHeaderFooter / DocumentMetadata APIs instead of the deprecated PDF-options forms. Tests: a gradient-stroked path in a later section registers its pattern on its own page (red before the fix); an internal link whose source is on a third section lands on its global page; a footer can skip its section's own first page; document metadata is taken from the first section that sets it; and close() is idempotent, rejects further rendering, and buildPdf() without a default file throws.
1 parent 724114d commit d0e8d1b

17 files changed

Lines changed: 1146 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ PDF `GoTo` actions. External links are unchanged.
1212

1313
### Public API
1414

15+
- **`GraphCompose.documents()` + `MultiSectionDocumentBuilder` / `MultiSectionDocument`**
16+
(`@since 1.9.0`). Concatenates several independently authored `DocumentSession`
17+
sections — each with its own page size, margins, fonts, and footer numbering —
18+
into one PDF inside the engine, with no external PDF merge. Anchors, internal
19+
links, and the bookmark outline resolve across section boundaries against the
20+
combined document, and each section is numbered from its own first page, so a
21+
full-bleed cover of one page size can precede a margined, page-numbered body of
22+
another. Document-level metadata and protection are taken from the first section
23+
that declares them. Single-section output is unchanged. `MultiSectionDocument`
24+
is `AutoCloseable` and owns its sections.
25+
1526
- **`addTableOfContents(...)` + `TocBuilder` / `DocumentLeader`** (`@since 1.9.0`).
1627
A native, clickable table of contents: each `entry(label, anchor)` becomes a row
1728
whose label links to the chapter (`linkTo`), a dotted or dashed leader fills the
2.77 KB
Binary file not shown.

examples/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ are with the canonical DSL, then jump to its detailed section below.
103103
| [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) |
104104
| [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) |
105105
| [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) |
106+
| [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) |
106107
| [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) |
107108
| [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) |
108109
| [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")
742743
[📄 View PDF](../assets/readme/examples/table-of-contents.pdf) ·
743744
[📜 Full source](src/main/java/com/demcha/examples/features/navigation/TocExample.java)
744745

746+
### Multi-section documents
747+
748+
`GraphCompose.documents()` concatenates several independently authored sections —
749+
each a full `DocumentSession` with its own page size, margins, fonts, and footer
750+
numbering — into one PDF **inside the engine** (no external merge). Anchors, links,
751+
and the bookmark outline resolve across section boundaries, and each section is
752+
numbered from its own first page, so a full-bleed landscape cover can precede a
753+
portrait, page-numbered body in a single document.
754+
755+
```java
756+
DocumentSession cover = GraphCompose.document().pageSize(440, 300).margin(DocumentInsets.of(0)).create();
757+
DocumentSession body = GraphCompose.document().pageSize(300, 440).margin(DocumentInsets.of(40)).create();
758+
body.footer(DocumentHeaderFooter.builder().centerText("{page} / {pages}").build());
759+
760+
try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) {
761+
doc.buildPdf(); // cover keeps its geometry; body is numbered 1..N from its own first page
762+
}
763+
```
764+
765+
[📄 View PDF](../assets/readme/examples/multi-section-document.pdf) ·
766+
[📜 Full source](src/main/java/com/demcha/examples/features/structure/MultiSectionExample.java)
767+
745768
---
746769

747770
## Production patterns

examples/src/main/java/com/demcha/examples/GenerateAllExamples.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import com.demcha.examples.features.navigation.InPdfNavigationExample;
3232
import com.demcha.examples.features.navigation.PageReferenceExample;
3333
import com.demcha.examples.features.navigation.TocExample;
34+
import com.demcha.examples.features.structure.MultiSectionExample;
3435
import com.demcha.examples.features.text.RichTextShowcaseExample;
3536
import com.demcha.examples.features.text.SectionPresetsExample;
3637
import com.demcha.examples.features.themes.CustomBusinessThemeExample;
@@ -172,6 +173,7 @@ public static void main(String[] args) throws Exception {
172173
System.out.println("Generated: " + InPdfNavigationExample.generate());
173174
System.out.println("Generated: " + PageReferenceExample.generate());
174175
System.out.println("Generated: " + TocExample.generate());
176+
System.out.println("Generated: " + MultiSectionExample.generate());
175177

176178
// Theming + chrome
177179
System.out.println("Generated: " + CustomBusinessThemeExample.generate());
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.demcha.examples.features.structure;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.api.MultiSectionDocument;
6+
import com.demcha.compose.document.output.DocumentHeaderFooter;
7+
import com.demcha.compose.document.style.DocumentColor;
8+
import com.demcha.compose.document.style.DocumentInsets;
9+
import com.demcha.compose.document.style.DocumentTextStyle;
10+
import com.demcha.examples.support.ExampleOutputPaths;
11+
12+
import java.nio.file.Path;
13+
14+
/**
15+
* Runnable showcase for v1.9 {@code GraphCompose.documents()}: several sections,
16+
* each a full {@link DocumentSession} with its own page size and chrome,
17+
* concatenated into one PDF — no external merge. Here a full-bleed landscape
18+
* cover precedes a portrait, page-numbered body, and the cover's call to action
19+
* links straight to a chapter in the body.
20+
*
21+
* <pre>{@code
22+
* DocumentSession cover = GraphCompose.document().pageSize(440, 300).margin(of(0)).create();
23+
* DocumentSession body = GraphCompose.document().pageSize(300, 440).margin(of(40)).create();
24+
* body.footer(DocumentHeaderFooter.builder().centerText("{page}").build());
25+
*
26+
* try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) {
27+
* doc.buildPdf();
28+
* }
29+
* }</pre>
30+
*
31+
* @author Artem Demchyshyn
32+
*/
33+
public final class MultiSectionExample {
34+
35+
private static final DocumentColor COVER_BG = DocumentColor.rgb(28, 39, 64);
36+
private static final DocumentColor COVER_INK = DocumentColor.rgb(244, 247, 252);
37+
private static final DocumentColor COVER_ACCENT = DocumentColor.rgb(126, 170, 255);
38+
private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
39+
private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135);
40+
41+
private static final String[][] CHAPTERS = {
42+
{"1. The opening chapter", "ch1"},
43+
{"2. The middle chapter", "ch2"},
44+
{"3. The closing chapter", "ch3"},
45+
};
46+
47+
private MultiSectionExample() {
48+
}
49+
50+
/**
51+
* Renders a landscape cover and a portrait body into a single PDF via
52+
* {@code GraphCompose.documents()}.
53+
*
54+
* @return path to the generated PDF
55+
* @throws Exception if rendering or file IO fails
56+
*/
57+
public static Path generate() throws Exception {
58+
Path pdfFile = ExampleOutputPaths.prepare("features/structure", "multi-section-document.pdf");
59+
60+
// Cover: landscape, full-bleed background, no margin.
61+
DocumentSession cover = GraphCompose.document()
62+
.pageSize(440, 300)
63+
.margin(DocumentInsets.of(0))
64+
.pageBackground(COVER_BG)
65+
.create();
66+
cover.pageFlow(page -> page.addSection(s -> s.padding(DocumentInsets.of(46))
67+
.addParagraph(p -> p.text("The GraphCompose Book")
68+
.textStyle(DocumentTextStyle.DEFAULT.withSize(30).withColor(COVER_INK)))
69+
.addParagraph(p -> p.text("One document, two page geometries")
70+
.textStyle(DocumentTextStyle.DEFAULT.withSize(13).withColor(COVER_INK)))
71+
.addParagraph(p -> p.text("Open the opening chapter")
72+
.textStyle(DocumentTextStyle.DEFAULT.withSize(13).withColor(COVER_ACCENT))
73+
.linkTo("ch1"))));
74+
75+
// Body: portrait, margined, page-numbered from its own first page.
76+
DocumentSession body = GraphCompose.document()
77+
.pageSize(300, 440)
78+
.margin(DocumentInsets.of(40))
79+
.create();
80+
body.footer(DocumentHeaderFooter.builder().centerText("{page} / {pages}").build());
81+
body.pageFlow(page -> {
82+
for (int i = 0; i < CHAPTERS.length; i++) {
83+
String[] chapter = CHAPTERS[i];
84+
if (i > 0) {
85+
page.addPageBreak(b -> b.name("to_" + chapter[1]));
86+
}
87+
page.addSection(s -> s.anchor(chapter[1])
88+
.addParagraph(p -> p.text(chapter[0])
89+
.textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK)))
90+
.addParagraph(p -> p.text("Body pages carry their own margins and footer page numbers, "
91+
+ "independent of the borderless cover in front of them.")
92+
.textStyle(DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED))));
93+
}
94+
});
95+
96+
try (MultiSectionDocument document = GraphCompose.documents(pdfFile)
97+
.section(cover)
98+
.section(body)
99+
.create()) {
100+
document.buildPdf();
101+
}
102+
103+
return pdfFile;
104+
}
105+
106+
public static void main(String[] args) throws Exception {
107+
System.out.println("Generated: " + generate());
108+
}
109+
}

src/main/java/com/demcha/compose/GraphCompose.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import com.demcha.compose.font.DefaultFonts;
77
import com.demcha.compose.document.api.DocumentPageSize;
88
import com.demcha.compose.document.api.DocumentSession;
9+
import com.demcha.compose.document.api.MultiSectionDocument;
10+
import com.demcha.compose.document.api.MultiSectionDocumentBuilder;
911
import com.demcha.compose.document.output.DocumentDebugOptions;
1012
import com.demcha.compose.document.style.DocumentInsets;
1113

@@ -92,6 +94,31 @@ public static DocumentBuilder document(Path outputFile) {
9294
return new DocumentBuilder(outputFile);
9395
}
9496

97+
/**
98+
* Starts a multi-section document: several independently authored
99+
* {@link DocumentSession} sections — each with its own page size, margins,
100+
* fonts, and page numbering — concatenated into one PDF with cross-section
101+
* anchors, links, and bookmark outline.
102+
*
103+
* @return builder for assembling a multi-section document
104+
* @since 1.9.0
105+
*/
106+
public static MultiSectionDocumentBuilder documents() {
107+
return new MultiSectionDocumentBuilder(null);
108+
}
109+
110+
/**
111+
* Starts a multi-section document with a default output target used by
112+
* {@link MultiSectionDocument#buildPdf()}.
113+
*
114+
* @param outputFile default PDF output path for {@link MultiSectionDocument#buildPdf()}
115+
* @return builder for assembling a multi-section document
116+
* @since 1.9.0
117+
*/
118+
public static MultiSectionDocumentBuilder documents(Path outputFile) {
119+
return new MultiSectionDocumentBuilder(outputFile);
120+
}
121+
95122
/**
96123
* Returns the logical font families bundled with GraphCompose out of the box.
97124
*

src/main/java/com/demcha/compose/document/api/DocumentSession.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,6 +1198,24 @@ public <E extends DocumentNode> NodeRegistry register(NodeDefinition<E> definiti
11981198
}
11991199
}
12001200

1201+
/**
1202+
* Captures this session as one section of a {@link MultiSectionDocument}: its
1203+
* resolved layout graph, page geometry, custom fonts, and chrome (header /
1204+
* footer / watermark / metadata). Package-private — used only by
1205+
* {@link MultiSectionDocument} to concatenate sessions into one PDF.
1206+
*
1207+
* @return a render unit for this section
1208+
*/
1209+
com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend.Section toSectionRenderUnit() {
1210+
ensureOpen();
1211+
ensureRenderable();
1212+
return new com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend.Section(
1213+
layoutGraph(),
1214+
canvas,
1215+
List.copyOf(customFontFamilies),
1216+
chromeOptions.toConveniencePdfBackend(debug));
1217+
}
1218+
12011219
/**
12021220
* Inner adapter exposing session-private state to {@link DocumentRenderingFacade}
12031221
* without making the corresponding session methods public.

0 commit comments

Comments
 (0)