From 08e8819553a1d40b9bb6f504c3b1755f9b3b743f Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 09:29:45 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20container=20bookmark()=20?= =?UTF-8?q?=E2=80=94=20sections=20and=20containers=20as=20PDF=20outline=20?= =?UTF-8?q?targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PDF bookmark (outline entry) could be set only on the seven leaf builders; a structural unit — a Section, a Container, a page flow — could not be a navigable outline target, so a multi-chapter document had no bookmark panel unless every chapter pinned a leaf bookmark by hand. bookmark(DocumentBookmarkOptions) on AbstractFlowBuilder makes any container flow an outline entry pointing at its start page. It is emitted as a new non-visual BookmarkMarkerPayload fragment — gated to the container's start page, like the existing anchor marker — that rides the backend's generic bookmark-registration branch (a no-op render handler covers fragment dispatch). Because the bookmark has its own marker fragment rather than riding the background decoration, it works on an unstyled container too. A container without a bookmark emits the same fragments as before, so existing documents are byte-for-byte unchanged. Tests: ContainerBookmarkTest asserts two bookmarked sections become outline entries at their pages, an unstyled section still emits its bookmark, a multi-page page flow emits its entry exactly once at the start page, a section without a bookmark adds no entry, and the back-compat SectionNode constructor defaults to no bookmark. Example: ContainerBookmarkExample (a report whose sections are each an outline entry). Full suite green, no visual baselines changed. --- CHANGELOG.md | 7 + assets/readme/examples/container-bookmark.pdf | Bin 0 -> 1382 bytes examples/README.md | 17 +++ .../demcha/examples/GenerateAllExamples.java | 2 + .../navigation/ContainerBookmarkExample.java | 93 +++++++++++++ .../fixed/pdf/PdfFixedLayoutBackend.java | 3 +- .../PdfBookmarkMarkerRenderHandler.java | 36 +++++ .../document/dsl/AbstractFlowBuilder.java | 21 +++ .../compose/document/dsl/ModuleBuilder.java | 2 +- .../compose/document/dsl/PageFlowBuilder.java | 2 +- .../compose/document/dsl/SectionBuilder.java | 2 +- .../layout/NodeDefinitionSupport.java | 41 ++++++ .../definitions/ContainerDefinition.java | 7 +- .../layout/definitions/SectionDefinition.java | 7 +- .../payloads/BookmarkMarkerPayload.java | 37 +++++ .../compose/document/node/ContainerNode.java | 34 ++++- .../compose/document/node/SectionNode.java | 39 +++++- .../fixed/pdf/ContainerBookmarkTest.java | 129 ++++++++++++++++++ 18 files changed, 465 insertions(+), 14 deletions(-) create mode 100644 assets/readme/examples/container-bookmark.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/navigation/ContainerBookmarkExample.java create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfBookmarkMarkerRenderHandler.java create mode 100644 src/main/java/com/demcha/compose/document/layout/payloads/BookmarkMarkerPayload.java create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/ContainerBookmarkTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 251689078..dd74d9e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **Container `bookmark(...)`** (`@since 1.9.0`). `bookmark(DocumentBookmarkOptions)` + on any container flow builder (`Section` / `Container` / page flow) — previously + only the seven leaf builders carried a bookmark — adds a PDF outline entry pointing + at that container's start page, making a structured document navigable through the + reader's bookmark panel. Emitted via its own non-visual marker fragment, so it works + even on an unstyled container, and a container without a bookmark is unaffected. + - **`RowBuilder.flexSpacer()` / `pushRight()` / `arrangement(...)` + `RowArrangement` + `SpacerBuilder.grow(...)`** (`@since 1.9.0`). Main-axis (`justify-content`) layout for a row. A `flexSpacer()` (or `pushRight()`) is an invisible spring that absorbs diff --git a/assets/readme/examples/container-bookmark.pdf b/assets/readme/examples/container-bookmark.pdf new file mode 100644 index 0000000000000000000000000000000000000000..293e7d3456bf9b28efae00afe9a2561a3f09aa90 GIT binary patch literal 1382 zcmY!laBZ^4=fsl4ocwey{jk)c;>`R! z1$~fe{eZ;u)M5oApz@+vT-aBp;L9?&a~oryyEJJauPFM7sF*#nO}HcQM=im~ODd zd&R$$*;>2L$JuKyNO29Ud3D>eGIhtcC9CaDWq2M8{Lf~}b!Ov)J5NNOyDdB_WIk!J zspVc)+eu|!F%M~yO^3S(S@mUvIpC2wSP2^rSHID0>@f*%F&W|TG z@9qBZ=0)*`vqzp)U6);ypSGf9V~Ka+rAwc86uBkK9@jf6nQP)R>uKCXMfYX@x2s>? zmiT72c1CrDRMh^tiv!z4cU)0EnjrgYm-OQ|Upixqv&Eyo{4h+Y-}~C%O^-E$>B~Rc zxgY*8x3H8@d8emTaz@$-BT%urV zftns_C&hXnHV|q1f2w+=x$kAOzYT^3mlwV$@j2N4&^7lkPm9Z}k~8)Bn|7_W+SV=O zckXzxU!0JlOOJ|ElM*xYS>YuemzX3UnK<{Uq-q?9VDvh;iN}OVJBf$w$;;o}&1X)j znk#HyWEQcc&-}RciHPl|nvyK;gxhTi<9gbyzR2*}*)K<*tShm5{b!x-&2608mgh1W zq*JA1w@(#~n%f*xbp4Zc&z?Q|EY*`lR!zOcKYuE3`HUT>?|OS1Fk*g z&8XN>CX#UK?ecY7ubh6boOI#Qm3yB4GyW7f$sAaF#VfmS#iZxUUqtqwtNPOU&A#cI z@|FL~{!8V(I+6T4(L7v!iNfMbAM}dPKJAJQoT?DM>eAK#?+NJ(^`;2!Zeae$!1&;p z8@3oU!Hhu@b1wa${QMFHQ27AN@SZLT(Kbd-#%``IPA<;o#wM=jhUP}DZe|ANmd56W zE(V6qrY0_CZboJ<&aUP_6)w((7EVT%29{=yrluyYE=J~#PId~E8xadjSP?;~X 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) | +| [Container bookmarks](#container-bookmarks) | `section.bookmark(new DocumentBookmarkOptions(title))` — make a section / container a PDF outline (bookmark-panel) target | [PDF](../assets/readme/examples/container-bookmark.pdf) · [Source](src/main/java/com/demcha/examples/features/navigation/ContainerBookmarkExample.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) | @@ -780,6 +781,22 @@ 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) +### Container bookmarks + +`bookmark(...)` on a section or container flow adds a PDF **outline** entry — the +reader's bookmark panel — pointing at that container's start page, making a +structured document navigable. It works on any container, even an unstyled one +(no fill or border), and is independent of the page content. + +```java +flow.addSection(s -> s.bookmark(new DocumentBookmarkOptions("2. Methodology")) + .addParagraph(heading) + .addParagraph(body)); +``` + +[📄 View PDF](../assets/readme/examples/container-bookmark.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/navigation/ContainerBookmarkExample.java) + ### Multi-section documents `GraphCompose.documents()` concatenates several independently authored sections — diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index f82668830..b059c5ee1 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -32,6 +32,7 @@ import com.demcha.examples.features.text.InlineHighlightExample; import com.demcha.examples.features.navigation.InPdfNavigationExample; import com.demcha.examples.features.navigation.PageReferenceExample; +import com.demcha.examples.features.navigation.ContainerBookmarkExample; import com.demcha.examples.features.navigation.TocExample; import com.demcha.examples.features.structure.MultiSectionExample; import com.demcha.examples.features.text.RichTextShowcaseExample; @@ -177,6 +178,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: " + ContainerBookmarkExample.generate()); System.out.println("Generated: " + MultiSectionExample.generate()); // Theming + chrome diff --git a/examples/src/main/java/com/demcha/examples/features/navigation/ContainerBookmarkExample.java b/examples/src/main/java/com/demcha/examples/features/navigation/ContainerBookmarkExample.java new file mode 100644 index 000000000..de5999362 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/navigation/ContainerBookmarkExample.java @@ -0,0 +1,93 @@ +package com.demcha.examples.features.navigation; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +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 container {@code bookmark(...)}: a section (or any + * container flow) becomes a PDF outline target. Each bookmarked section adds an + * entry to the reader's bookmark panel pointing at the section's start page — a + * navigable outline for a structured document, with no manual coordinates. The + * outline is a viewer-panel feature; the page itself shows the report content. + * + *
{@code
+ * flow.addSection(s -> s.bookmark(new DocumentBookmarkOptions("2. Methodology"))
+ *     .addParagraph(heading)
+ *     .addParagraph(body));
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class ContainerBookmarkExample { + + 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. Introduction", "Why this report exists and what it covers."}, + {"2. Methodology", "How the data was gathered and analysed."}, + {"3. Results", "What the analysis found, in brief."}, + {"4. Conclusion", "What it means and what to do next."}, + }; + + private ContainerBookmarkExample() { + } + + /** + * Renders a short report whose sections are each a PDF outline entry. + * + * @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/navigation", "container-bookmark.pdf"); + + DocumentTextStyle heading = DocumentTextStyle.DEFAULT.withSize(13).withColor(INK); + DocumentTextStyle body = DocumentTextStyle.DEFAULT.withSize(10).withColor(MUTED); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(360, 320) + .margin(DocumentInsets.of(34)) + .create()) { + document.pageFlow(page -> { + page.addParagraph(p -> p.text("Quarterly Report") + .textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK))); + page.addParagraph(p -> p.text("each section is a bookmark — open the reader's outline panel") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(MUTED)) + .padding(DocumentInsets.bottom(10))); + + for (String[] chapter : CHAPTERS) { + chapterSection(page, heading, body, chapter[0], chapter[1]); + } + }); + + document.buildPdf(); + } + + return pdfFile; + } + + private static void chapterSection(PageFlowBuilder page, + DocumentTextStyle heading, + DocumentTextStyle body, + String title, + String summary) { + page.addSection(s -> s.bookmark(new DocumentBookmarkOptions(title)) + .spacing(2) + .addParagraph(p -> p.text(title).textStyle(heading)) + .addParagraph(p -> p.text(summary).textStyle(body))); + page.addSpacer(s -> s.height(8)); + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} 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 aba40acdf..f0f3ad04d 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 @@ -114,7 +114,8 @@ private static List> defaultHandlers() { new PdfShapeClipEndRenderHandler(), new PdfTransformBeginRenderHandler(), new PdfTransformEndRenderHandler(), - new PdfAnchorMarkerRenderHandler()); + new PdfAnchorMarkerRenderHandler(), + new PdfBookmarkMarkerRenderHandler()); } private static PdfLinkAnnotationWriter.PlacedPdfRect spanLinkRectangle(ParagraphSpan span, diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfBookmarkMarkerRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfBookmarkMarkerRenderHandler.java new file mode 100644 index 000000000..6da8a079b --- /dev/null +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfBookmarkMarkerRenderHandler.java @@ -0,0 +1,36 @@ +package com.demcha.compose.document.backend.fixed.pdf.handlers; + +import com.demcha.compose.document.backend.fixed.pdf.PdfFragmentRenderHandler; +import com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.BookmarkMarkerPayload; + +/** + * No-op render handler for a {@link BookmarkMarkerPayload} fragment. The marker + * draws nothing — the outline entry is registered generically in the backend's + * semantic post-pass (the payload implements {@code PdfSemanticFragmentPayload}), + * so this handler exists only so fragment dispatch finds a handler for the type. + * + * @author Artem Demchyshyn + */ +public final class PdfBookmarkMarkerRenderHandler + implements PdfFragmentRenderHandler { + + /** + * Creates the bookmark-marker handler. + */ + public PdfBookmarkMarkerRenderHandler() { + } + + @Override + public Class payloadType() { + return BookmarkMarkerPayload.class; + } + + @Override + public void render(PlacedFragment fragment, + BookmarkMarkerPayload payload, + PdfRenderEnvironment environment) { + // Intentionally empty — registration happens in finishRenderedFragment. + } +} diff --git a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java index 7ae2b3dd2..60e83405e 100644 --- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -5,6 +5,7 @@ import com.demcha.compose.document.dsl.internal.BuilderSupport; import com.demcha.compose.document.image.DocumentImageData; import com.demcha.compose.document.node.ChartNode; +import com.demcha.compose.document.node.DocumentBookmarkOptions; import com.demcha.compose.document.node.DocumentLinkOptions; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.PageReferenceNode; @@ -28,6 +29,7 @@ public abstract class AbstractFlowBuilder, N private final List children = new ArrayList<>(); private String name = ""; private String anchor; + private DocumentBookmarkOptions bookmarkOptions; private double spacing = 0.0; private DocumentInsets padding = DocumentInsets.zero(); private DocumentInsets margin = DocumentInsets.zero(); @@ -75,6 +77,21 @@ public T anchor(String anchor) { return self(); } + /** + * Adds a PDF outline (bookmark) entry pointing at this container's top on its + * start page, making a section or container a navigable outline target (a + * {@code chrome().viewerPreferences(...)} page mode of {@code USE_OUTLINES} + * opens the bookmark panel automatically). + * + * @param bookmarkOptions outline entry (title, level), or {@code null} to clear + * @return this builder + * @since 1.9.0 + */ + public T bookmark(DocumentBookmarkOptions bookmarkOptions) { + this.bookmarkOptions = bookmarkOptions; + return self(); + } + /** * Sets vertical spacing between child nodes. * @@ -1136,6 +1153,10 @@ protected String anchor() { return anchor; } + protected DocumentBookmarkOptions bookmarkOptions() { + return bookmarkOptions; + } + protected List children() { return List.copyOf(children); } diff --git a/src/main/java/com/demcha/compose/document/dsl/ModuleBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ModuleBuilder.java index f6e6d44e4..bd6a3ab4e 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ModuleBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ModuleBuilder.java @@ -333,7 +333,7 @@ protected SectionNode buildNode() { } moduleChildren.addAll(children()); return new SectionNode(name(), moduleChildren, spacing(), padding(), margin(), fillColor(), - stroke(), cornerRadius(), borders(), keepTogether, anchor()); + stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed(), bookmarkOptions()); } /** diff --git a/src/main/java/com/demcha/compose/document/dsl/PageFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/PageFlowBuilder.java index d02733782..2b28280d6 100644 --- a/src/main/java/com/demcha/compose/document/dsl/PageFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/PageFlowBuilder.java @@ -23,7 +23,7 @@ protected PageFlowBuilder self() { @Override protected ContainerNode buildNode() { - return new ContainerNode(name(), children(), spacing(), padding(), margin(), fillColor(), stroke(), cornerRadius(), borders(), anchor()); + return new ContainerNode(name(), children(), spacing(), padding(), margin(), fillColor(), stroke(), cornerRadius(), borders(), anchor(), bookmarkOptions()); } /** diff --git a/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java b/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java index 76a39f787..deaa99046 100644 --- a/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/SectionBuilder.java @@ -51,7 +51,7 @@ public SectionBuilder keepTogether(boolean value) { @Override protected SectionNode buildNode() { return new SectionNode(name(), children(), spacing(), padding(), margin(), fillColor(), - stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed()); + stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed(), bookmarkOptions()); } /** diff --git a/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java b/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java index f9009182b..3629e0cde 100644 --- a/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java @@ -148,6 +148,47 @@ public static List withAnchorMarker(List base, return List.copyOf(out); } + /** + * Builds a non-visual {@link BookmarkMarkerPayload} fragment at the placement's + * top-left, declaring a PDF outline entry for a container. + * + * @param bookmark the outline entry + * @param placement resolved fragment placement + * @return one bookmark marker fragment + */ + public static LayoutFragment bookmarkMarkerFragment(DocumentBookmarkOptions bookmark, + FragmentPlacement placement) { + return new LayoutFragment( + placement.path(), + 0, + 0.0, + 0.0, + placement.width(), + placement.height(), + new BookmarkMarkerPayload(bookmark)); + } + + /** + * Appends a {@link BookmarkMarkerPayload} fragment to {@code base} when + * {@code bookmark} is non-null, otherwise returns {@code base} unchanged. + * + * @param base already-emitted fragments + * @param bookmark optional outline entry; {@code null} skips the marker + * @param placement resolved fragment placement + * @return {@code base}, optionally with a bookmark marker appended + */ + public static List withBookmarkMarker(List base, + DocumentBookmarkOptions bookmark, + FragmentPlacement placement) { + if (bookmark == null) { + return base; + } + List out = new ArrayList<>(base.size() + 1); + out.addAll(base); + out.add(bookmarkMarkerFragment(bookmark, placement)); + return List.copyOf(out); + } + /** * Emits an optional background/border decoration fragment. * diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/ContainerDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/ContainerDefinition.java index 7ca96932c..b775e507a 100644 --- a/src/main/java/com/demcha/compose/document/layout/definitions/ContainerDefinition.java +++ b/src/main/java/com/demcha/compose/document/layout/definitions/ContainerDefinition.java @@ -56,9 +56,10 @@ public List emitFragments(PreparedNode prepared, node.cornerRadius(), toSideBorders(node.borders()), placement); - return withAnchorMarker( - decoration, - placement.pageIndex() == placement.startPage() ? node.anchor() : null, + boolean onStartPage = placement.pageIndex() == placement.startPage(); + return withBookmarkMarker( + withAnchorMarker(decoration, onStartPage ? node.anchor() : null, placement), + onStartPage ? node.bookmarkOptions() : null, placement); } } diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/SectionDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/SectionDefinition.java index 99be2525f..ba328bf74 100644 --- a/src/main/java/com/demcha/compose/document/layout/definitions/SectionDefinition.java +++ b/src/main/java/com/demcha/compose/document/layout/definitions/SectionDefinition.java @@ -56,9 +56,10 @@ public List emitFragments(PreparedNode prepared, node.cornerRadius(), toSideBorders(node.borders()), placement); - return withAnchorMarker( - decoration, - placement.pageIndex() == placement.startPage() ? node.anchor() : null, + boolean onStartPage = placement.pageIndex() == placement.startPage(); + return withBookmarkMarker( + withAnchorMarker(decoration, onStartPage ? node.anchor() : null, placement), + onStartPage ? node.bookmarkOptions() : null, placement); } } diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/BookmarkMarkerPayload.java b/src/main/java/com/demcha/compose/document/layout/payloads/BookmarkMarkerPayload.java new file mode 100644 index 000000000..1c62af698 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/payloads/BookmarkMarkerPayload.java @@ -0,0 +1,37 @@ +package com.demcha.compose.document.layout.payloads; + +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.DocumentLinkTarget; + +import java.util.Objects; + +/** + * Non-visual marker fragment payload that declares a PDF outline (bookmark) entry + * at a container's resolved top-left. + * + *

A bookmarked {@code Section} / {@code Container} / page flow emits one of + * these at its top on its start page. Because it implements + * {@link PdfSemanticFragmentPayload} and returns non-null {@link #bookmarkOptions()}, + * the PDF backend registers it as an outline entry through the generic semantic + * branch — no deferred resolution, unlike an anchor. The marker draws nothing and + * carries no link.

+ * + * @param bookmarkOptions the outline entry (title, level); never {@code null} + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public record BookmarkMarkerPayload(DocumentBookmarkOptions bookmarkOptions) + implements PdfSemanticFragmentPayload { + + /** + * Validates that the marker carries a bookmark (its sole purpose). + */ + public BookmarkMarkerPayload { + Objects.requireNonNull(bookmarkOptions, "bookmarkOptions"); + } + + @Override + public DocumentLinkTarget linkTarget() { + return null; + } +} diff --git a/src/main/java/com/demcha/compose/document/node/ContainerNode.java b/src/main/java/com/demcha/compose/document/node/ContainerNode.java index fdd1dd22f..8aa4c8954 100644 --- a/src/main/java/com/demcha/compose/document/node/ContainerNode.java +++ b/src/main/java/com/demcha/compose/document/node/ContainerNode.java @@ -19,6 +19,8 @@ * @param borders optional per-side border strokes overriding the uniform stroke * @param anchor optional in-document navigation anchor name; renders a named * destination at the container's top-left, or {@code null} for none + * @param bookmarkOptions optional PDF outline entry placed at the container's top on + * its start page, or {@code null} for none * @author Artem Demchyshyn */ public record ContainerNode( @@ -31,7 +33,8 @@ public record ContainerNode( DocumentStroke stroke, DocumentCornerRadius cornerRadius, DocumentBorders borders, - String anchor + String anchor, + DocumentBookmarkOptions bookmarkOptions ) implements DocumentNode { /** * Creates a normalized vertical flow container. @@ -50,6 +53,33 @@ public record ContainerNode( } } + /** + * Backward-compatible constructor without a bookmark (defaults to none). + * + * @param name node name used in snapshots and layout graph paths + * @param children child semantic nodes in source order + * @param spacing vertical spacing between children + * @param padding inner padding + * @param margin outer margin + * @param fillColor optional background fill + * @param stroke optional uniform border stroke + * @param cornerRadius optional render-only corner radius + * @param borders optional per-side border strokes overriding the uniform stroke + * @param anchor optional navigation anchor name + */ + public ContainerNode(String name, + List children, + double spacing, + DocumentInsets padding, + DocumentInsets margin, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentCornerRadius cornerRadius, + DocumentBorders borders, + String anchor) { + this(name, children, spacing, padding, margin, fillColor, stroke, cornerRadius, borders, anchor, null); + } + /** * Backward-compatible constructor without the navigation anchor (defaults to * no anchor). @@ -73,7 +103,7 @@ public ContainerNode(String name, DocumentStroke stroke, DocumentCornerRadius cornerRadius, DocumentBorders borders) { - this(name, children, spacing, padding, margin, fillColor, stroke, cornerRadius, borders, null); + this(name, children, spacing, padding, margin, fillColor, stroke, cornerRadius, borders, null, null); } /** diff --git a/src/main/java/com/demcha/compose/document/node/SectionNode.java b/src/main/java/com/demcha/compose/document/node/SectionNode.java index d5e0ddb1d..5f2751c39 100644 --- a/src/main/java/com/demcha/compose/document/node/SectionNode.java +++ b/src/main/java/com/demcha/compose/document/node/SectionNode.java @@ -24,6 +24,8 @@ * destination at the section's top-left, or {@code null} for none * @param bleed edges on which the section bleeds to the trimmed page edge, * or {@link DocumentBleed#none()} for normal in-margin placement + * @param bookmarkOptions optional PDF outline entry placed at the section's top on + * its start page, or {@code null} for none * @author Artem Demchyshyn */ public record SectionNode( @@ -38,7 +40,8 @@ public record SectionNode( DocumentBorders borders, boolean keepTogether, String anchor, - DocumentBleed bleed + DocumentBleed bleed, + DocumentBookmarkOptions bookmarkOptions ) implements DocumentNode { /** * Normalizes optional section fields and validates child spacing. @@ -58,6 +61,38 @@ public record SectionNode( } } + /** + * Backward-compatible constructor without a bookmark (defaults to none). + * + * @param name node name + * @param children child nodes + * @param spacing vertical spacing + * @param padding inner padding + * @param margin outer margin + * @param fillColor optional background fill + * @param stroke optional uniform border stroke + * @param cornerRadius optional render-only corner radius + * @param borders optional per-side borders + * @param keepTogether keep-together relocation flag + * @param anchor optional navigation anchor name + * @param bleed optional bleed declaration + */ + public SectionNode(String name, + List children, + double spacing, + DocumentInsets padding, + DocumentInsets margin, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentCornerRadius cornerRadius, + DocumentBorders borders, + boolean keepTogether, + String anchor, + DocumentBleed bleed) { + this(name, children, spacing, padding, margin, fillColor, stroke, cornerRadius, borders, keepTogether, + anchor, bleed, null); + } + /** * Backward-compatible constructor without the bleed declaration (defaults to * {@link DocumentBleed#none()}). @@ -86,7 +121,7 @@ public SectionNode(String name, boolean keepTogether, String anchor) { this(name, children, spacing, padding, margin, fillColor, stroke, cornerRadius, borders, keepTogether, - anchor, DocumentBleed.none()); + anchor, DocumentBleed.none(), null); } /** diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/ContainerBookmarkTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/ContainerBookmarkTest.java new file mode 100644 index 000000000..9bade2169 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/ContainerBookmarkTest.java @@ -0,0 +1,129 @@ +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.node.DocumentBookmarkOptions; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +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.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code AbstractFlowBuilder.bookmark(...)} makes a container (section / page flow) + * a PDF outline target — an entry in the bookmark panel pointing at the + * container's start page, even when the container has no fill or border. + */ +class ContainerBookmarkTest { + + @TempDir + Path tempDir; + + private static List outline(PDDocument doc) { + List items = new ArrayList<>(); + PDDocumentOutline outline = doc.getDocumentCatalog().getDocumentOutline(); + if (outline != null) { + for (PDOutlineItem item : outline.children()) { + items.add(item); + } + } + return items; + } + + private static List titles(PDDocument doc) { + return outline(doc).stream().map(PDOutlineItem::getTitle).toList(); + } + + private DocumentSession session(Path out) { + return GraphCompose.document(out).pageSize(260, 200).margin(DocumentInsets.of(24)).create(); + } + + @Test + void bookmarkedSectionsBecomeOutlineEntriesAtTheirPages() throws Exception { + Path out = tempDir.resolve("bookmarks.pdf"); + try (DocumentSession session = session(out)) { + session.pageFlow(page -> { + page.addSection(s -> s.bookmark(new DocumentBookmarkOptions("Chapter 1")).addParagraph("One")); + page.addPageBreak(b -> b.name("br")); + page.addSection(s -> s.bookmark(new DocumentBookmarkOptions("Chapter 2")).addParagraph("Two")); + }); + session.buildPdf(); + } + + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + assertThat(titles(doc)).containsExactly("Chapter 1", "Chapter 2"); + PDOutlineItem chapter2 = outline(doc).get(1); + // Chapter 2 follows a page break, so its outline destination is page 2 (index 1). + assertThat(((PDPageDestination) chapter2.getDestination()).retrievePageNumber()).isEqualTo(1); + } + } + + @Test + void anUnstyledSectionStillEmitsItsBookmark() throws Exception { + // No fill / border, so the decoration fragment is suppressed — the bookmark + // rides its own marker fragment, not the decoration, so it still appears. + Path out = tempDir.resolve("plain.pdf"); + try (DocumentSession session = session(out)) { + session.pageFlow(page -> page.addSection(s -> s + .bookmark(new DocumentBookmarkOptions("Plain section")).addParagraph("body"))); + session.buildPdf(); + } + + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + assertThat(titles(doc)).containsExactly("Plain section"); + } + } + + @Test + void aBookmarkedMultiPageFlowEmitsItsEntryExactlyOnce() throws Exception { + // The page flow is a ContainerNode (not a SectionNode) and spans two pages; + // its bookmark must appear once, at the start page — not per page slice. + Path out = tempDir.resolve("pageflow.pdf"); + try (DocumentSession session = session(out)) { + session.pageFlow(page -> { + page.bookmark(new DocumentBookmarkOptions("Document")); + page.addSection(s -> s.addParagraph("Front")); + page.addPageBreak(b -> b.name("br")); + page.addSection(s -> s.addParagraph("Back")); + }); + session.buildPdf(); + } + + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + assertThat(doc.getNumberOfPages()).isEqualTo(2); + assertThat(titles(doc)).containsExactly("Document"); + assertThat(((PDPageDestination) outline(doc).get(0).getDestination()).retrievePageNumber()).isZero(); + } + } + + @Test + void aSectionWithoutABookmarkAddsNoOutlineEntry() throws Exception { + Path out = tempDir.resolve("none.pdf"); + try (DocumentSession session = session(out)) { + session.pageFlow(page -> page.addSection(s -> s.addParagraph("body"))); + session.buildPdf(); + } + + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + assertThat(titles(doc)).isEmpty(); + } + } + + @Test + void backwardCompatibleSectionConstructorDefaultsToNoBookmark() { + // The pre-bookmark 12-arg SectionNode constructor still resolves; bookmark is null. + com.demcha.compose.document.node.SectionNode section = new com.demcha.compose.document.node.SectionNode( + "s", List.of(), 0.0, DocumentInsets.zero(), DocumentInsets.zero(), + null, null, null, null, false, null, com.demcha.compose.document.style.DocumentBleed.none()); + assertThat(section.bookmarkOptions()).isNull(); + } +} From 4b45b37b557b8f338f1cef2f83bbd5b0199da81b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 09:44:37 +0100 Subject: [PATCH 2/2] fix(api): keep a module from bleeding when it carries a bookmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threading the container bookmark through ModuleBuilder switched its buildNode to the full SectionNode constructor, which also started honouring bleed() — a module previously ignored bleed. Pass DocumentBleed.none() explicitly so a module's bleed behaviour stays as it was; only the bookmark is threaded through. --- .../java/com/demcha/compose/document/dsl/ModuleBuilder.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/demcha/compose/document/dsl/ModuleBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ModuleBuilder.java index bd6a3ab4e..f1dd08565 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ModuleBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ModuleBuilder.java @@ -2,6 +2,7 @@ import com.demcha.compose.document.dsl.internal.SemanticNameNormalizer; import com.demcha.compose.document.node.*; +import com.demcha.compose.document.style.DocumentBleed; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentTextStyle; @@ -332,8 +333,10 @@ protected SectionNode buildNode() { .build()); } moduleChildren.addAll(children()); + // A module does not bleed (matching its long-standing behaviour) — thread + // only the bookmark, leaving bleed at none(). return new SectionNode(name(), moduleChildren, spacing(), padding(), margin(), fillColor(), - stroke(), cornerRadius(), borders(), keepTogether, anchor(), bleed(), bookmarkOptions()); + stroke(), cornerRadius(), borders(), keepTogether, anchor(), DocumentBleed.none(), bookmarkOptions()); } /**