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