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 000000000..293e7d345
Binary files /dev/null and b/assets/readme/examples/container-bookmark.pdf differ
diff --git a/examples/README.md b/examples/README.md
index 7986bbfe9..68d89bd9c 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -105,6 +105,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) |
+| [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..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());
+ stroke(), cornerRadius(), borders(), keepTogether, anchor(), DocumentBleed.none(), 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();
+ }
+}