diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d204817..84dceee7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **`addTableOfContents(...)` + `TocBuilder` / `DocumentLeader`** (`@since 1.9.0`). + A native, clickable table of contents: each `entry(label, anchor)` becomes a row + whose label links to the chapter (`linkTo`), a dotted or dashed leader fills the + gap, and the page number is resolved automatically from the laid-out document — + no manual two-pass. Built entirely from the existing primitives (auto/weight + columns, `line().fill()`, `addPageReference`) and added to the flow, so a long + contents paginates across pages. + - **`addPageReference(anchor)` + `PageReferenceNode`** (`@since 1.9.0`). Prints the page a declared `anchor(...)` lands on — a native "see page N" cross-reference — in a single authoring pass. A document that contains a page reference is laid diff --git a/assets/readme/examples/table-of-contents.pdf b/assets/readme/examples/table-of-contents.pdf new file mode 100644 index 000000000..7161a42a1 Binary files /dev/null and b/assets/readme/examples/table-of-contents.pdf differ diff --git a/examples/README.md b/examples/README.md index 73f8775c7..1657510c5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -102,6 +102,7 @@ are with the canonical DSL, then jump to its detailed section below. | [PDF chrome](#pdf-chrome) | `DocumentMetadata`, `DocumentWatermark`, `DocumentHeaderFooter`, `DocumentBookmarkOptions` | [PDF](../assets/readme/examples/pdf-chrome.pdf) · [Source](src/main/java/com/demcha/examples/features/chrome/PdfChromeExample.java) | | [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) | | [HTTP streaming](#http-streaming) | `writePdf(OutputStream)` for Servlet / S3 / GCS — caller's stream is not closed | [PDF](../assets/readme/examples/invoice-http-stream.pdf) · [Source](src/main/java/com/demcha/examples/features/streaming/HttpStreamingExample.java) | | [Word export (DOCX)](#word-export-docx) | `DocxSemanticBackend` — the same session renders a fixed-layout PDF and an editable Word file; paragraphs / lists / tables / images map 1:1, charts fall back to their data table | [PDF](../assets/readme/examples/word-export-companion.pdf) · [DOCX](../assets/readme/examples/word-export-companion.docx) · [Source](src/main/java/com/demcha/examples/features/docx/WordExportExample.java) | | [Layout snapshot regression](#layout-snapshot-regression) | Deterministic `layoutSnapshot()` workflow with baseline + drift report — production regression-testing pattern | [PDF](../assets/readme/examples/invoice-snapshot-regression.pdf) · [Source](src/main/java/com/demcha/examples/features/snapshots/LayoutSnapshotRegressionExample.java) | @@ -722,6 +723,25 @@ flow.addRow(r -> r.columns(auto(), weight(1), auto()) [📄 View PDF](../assets/readme/examples/page-reference.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java) +### Table of contents + +`addTableOfContents(...)` builds a native, clickable table of contents from the +page-reference primitive: each `entry(label, anchor)` becomes a row whose label +links to the chapter, a dotted (or dashed) leader fills the gap, and the page +number is resolved automatically from the laid-out document — no manual two-pass. +The rows are added to the flow, so a long contents paginates naturally. + +```java +flow.addTableOfContents(toc -> toc.title("Contents") + .leader(DocumentLeader.DOTS) + .entry("Introduction", "intro") + .entry("Appendix", "appendix")); +// ... chapters declared with .anchor("intro"), .anchor("appendix"), ... +``` + +[📄 View PDF](../assets/readme/examples/table-of-contents.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/navigation/TocExample.java) + --- ## Production patterns diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 5aca5a38c..489ab70d1 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -30,6 +30,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.TocExample; import com.demcha.examples.features.text.RichTextShowcaseExample; import com.demcha.examples.features.text.SectionPresetsExample; import com.demcha.examples.features.themes.CustomBusinessThemeExample; @@ -170,6 +171,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + SectionPresetsExample.generate()); System.out.println("Generated: " + InPdfNavigationExample.generate()); System.out.println("Generated: " + PageReferenceExample.generate()); + System.out.println("Generated: " + TocExample.generate()); // Theming + chrome System.out.println("Generated: " + CustomBusinessThemeExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/navigation/TocExample.java b/examples/src/main/java/com/demcha/examples/features/navigation/TocExample.java new file mode 100644 index 000000000..51e6b4201 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/navigation/TocExample.java @@ -0,0 +1,90 @@ +package com.demcha.examples.features.navigation; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentLeader; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for v1.9 {@code addTableOfContents(...)}: a native, clickable + * table of contents. Each {@code entry(label, anchor)} becomes a row whose label + * links to the chapter, a dotted leader fills the gap, and the page number is + * resolved automatically from the laid-out document — no manual two-pass. + * + *
{@code
+ * flow.addTableOfContents(toc -> toc.title("Contents")
+ *     .leader(DocumentLeader.DOTS)
+ *     .entry("Introduction", "intro")
+ *     .entry("Appendix", "appendix"));
+ * // ... chapters declared with .anchor("intro"), .anchor("appendix"), ...
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class TocExample { + + 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 = { + {"Introduction", "intro"}, + {"Getting started", "start"}, + {"A longer chapter title that runs on", "longer"}, + {"Configuration reference", "config"}, + {"Appendix", "appendix"}, + }; + + private TocExample() { + } + + /** + * Renders a table-of-contents page followed by one page per chapter, with the + * contents page numbers resolved automatically. + * + * @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", "table-of-contents.pdf"); + + DocumentTextStyle title = DocumentTextStyle.DEFAULT.withSize(20).withColor(INK); + DocumentTextStyle entry = DocumentTextStyle.DEFAULT.withSize(11).withColor(INK); + DocumentTextStyle number = DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED); + DocumentTextStyle chapterHeading = DocumentTextStyle.DEFAULT.withSize(18).withColor(INK); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(360, 280) + .margin(DocumentInsets.of(34)) + .create()) { + document.pageFlow(page -> { + page.addTableOfContents(toc -> { + toc.title("Contents").titleStyle(title) + .leader(DocumentLeader.DOTS) + .entryStyle(entry) + .pageNumberStyle(number); + for (String[] chapter : CHAPTERS) { + toc.entry(chapter[0], chapter[1]); + } + }); + + for (String[] chapter : CHAPTERS) { + page.addPageBreak(b -> b.name("to_" + chapter[1])); + page.addSection(s -> s.anchor(chapter[1]) + .addParagraph(p -> p.text(chapter[0]).textStyle(chapterHeading))); + } + }); + document.buildPdf(); + } + + return pdfFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java index da090a7aa..7ae2b3dd2 100644 --- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -960,6 +960,26 @@ public T addSection(String name, Consumer spec) { return add(BuilderSupport.configure(new SectionBuilder().name(name), spec).build()); } + /** + * Adds a native table of contents: an optional title and one clickable, + * page-numbered row per {@code entry(label, anchor)}. Each row links its label + * to the anchor, fills the gap with a leader, and prints the anchor's page — + * resolved automatically from the laid-out document, no manual two-pass. The + * rows are added to this flow, so a long table of contents paginates naturally. + * + * @param spec table-of-contents builder callback + * @return this builder + * @since 1.9.0 + */ + public T addTableOfContents(Consumer spec) { + TocBuilder toc = new TocBuilder(); + Objects.requireNonNull(spec, "spec").accept(toc); + for (DocumentNode node : toc.buildEntries()) { + add(node); + } + return self(); + } + /** * Adds a page reference that prints the page number a named {@code anchor(...)} * lands on — the "see page N" cross-reference. The number is resolved from the diff --git a/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java b/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java new file mode 100644 index 000000000..190e163d7 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java @@ -0,0 +1,172 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentLeader; +import com.demcha.compose.document.style.DocumentLineCap; +import com.demcha.compose.document.style.DocumentRowColumn; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; + +import java.util.ArrayList; +import java.util.List; + +/** + * Builds a native table of contents: an optional title followed by one entry row + * per {@code entry(label, anchor)}. Each row is laid out as + * {@code columns(auto(), weight(1), auto())} — a clickable label, a leader that + * fills the gap, and the entry's page number resolved automatically from the + * laid-out document (see {@link AbstractFlowBuilder#addPageReference(String)}). + * + *

The rows are added directly to the surrounding flow, so a long table of + * contents paginates across pages naturally.

+ * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public final class TocBuilder { + + private static final DocumentColor DEFAULT_LEADER_COLOR = DocumentColor.rgb(150, 150, 150); + + private String title = ""; + private DocumentTextStyle titleStyle = DocumentTextStyle.DEFAULT.withSize(16); + private DocumentTextStyle entryStyle = DocumentTextStyle.DEFAULT; + private DocumentTextStyle pageNumberStyle = DocumentTextStyle.DEFAULT; + private DocumentLeader leader = DocumentLeader.DOTS; + private DocumentColor leaderColor = DEFAULT_LEADER_COLOR; + private final List entries = new ArrayList<>(); + + /** + * Creates a table-of-contents builder. + */ + public TocBuilder() { + } + + /** + * Sets an optional heading rendered above the entries. + * + * @param title heading text, or {@code null}/blank for no heading + * @return this builder + */ + public TocBuilder title(String title) { + this.title = title == null ? "" : title; + return this; + } + + /** + * Sets the heading text style. + * + * @param style heading style + * @return this builder + */ + public TocBuilder titleStyle(DocumentTextStyle style) { + this.titleStyle = style == null ? DocumentTextStyle.DEFAULT : style; + return this; + } + + /** + * Adds an entry linking {@code label} to a declared {@code anchor(...)} and + * printing that anchor's page number. + * + * @param label the entry text (clickable, jumps to the anchor) + * @param anchor the target anchor name + * @return this builder + * @throws IllegalArgumentException if {@code label} or {@code anchor} is blank + */ + public TocBuilder entry(String label, String anchor) { + if (label == null || label.isBlank()) { + throw new IllegalArgumentException("Table-of-contents entry label must not be blank."); + } + if (anchor == null || anchor.isBlank()) { + throw new IllegalArgumentException("Table-of-contents entry anchor must not be blank: " + label); + } + entries.add(new Entry(label, anchor)); + return this; + } + + /** + * Sets the leader filling the gap between label and page number. + * + * @param leader leader style; {@code null} resets to {@link DocumentLeader#NONE} + * @return this builder + */ + public TocBuilder leader(DocumentLeader leader) { + this.leader = leader == null ? DocumentLeader.NONE : leader; + return this; + } + + /** + * Sets the leader color. + * + * @param color leader color + * @return this builder + */ + public TocBuilder leaderColor(DocumentColor color) { + this.leaderColor = color == null ? DEFAULT_LEADER_COLOR : color; + return this; + } + + /** + * Sets the entry label text style. + * + * @param style entry style + * @return this builder + */ + public TocBuilder entryStyle(DocumentTextStyle style) { + this.entryStyle = style == null ? DocumentTextStyle.DEFAULT : style; + return this; + } + + /** + * Sets the page-number text style. + * + * @param style page-number style + * @return this builder + */ + public TocBuilder pageNumberStyle(DocumentTextStyle style) { + this.pageNumberStyle = style == null ? DocumentTextStyle.DEFAULT : style; + return this; + } + + /** + * Builds the heading (when set) and one row per entry, in source order. The + * caller appends these to the flow so they paginate naturally. + * + * @return the table-of-contents nodes + */ + List buildEntries() { + List nodes = new ArrayList<>(); + if (!title.isBlank()) { + nodes.add(new ParagraphBuilder().text(title).textStyle(titleStyle).build()); + } + for (Entry entry : entries) { + nodes.add(buildEntryRow(entry)); + } + return nodes; + } + + private DocumentNode buildEntryRow(Entry entry) { + RowBuilder row = new RowBuilder() + .gap(6) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1), DocumentRowColumn.auto()); + row.addParagraph(p -> p.text(entry.label()).textStyle(entryStyle).linkTo(entry.anchor())); + if (leader == DocumentLeader.NONE) { + row.addSpacer(s -> s.width(1).height(1)); + } else { + row.addLine(line -> { + line.fill().stroke(DocumentStroke.of(leaderColor, 1.0)); + if (leader == DocumentLeader.DOTS) { + line.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND); + } else { + line.dashed(3, 3); + } + }); + } + row.addPageReference(entry.anchor(), pageNumberStyle, TextAlign.RIGHT); + return row.build(); + } + + private record Entry(String label, String anchor) { + } +} diff --git a/src/main/java/com/demcha/compose/document/style/DocumentLeader.java b/src/main/java/com/demcha/compose/document/style/DocumentLeader.java new file mode 100644 index 000000000..0b2377bfa --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentLeader.java @@ -0,0 +1,17 @@ +package com.demcha.compose.document.style; + +/** + * Leader style filling the gap between a table-of-contents entry's label and its + * page number. + * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public enum DocumentLeader { + /** No leader — the label and page number are separated by empty space. */ + NONE, + /** A row of dots (the classic table-of-contents leader). */ + DOTS, + /** A dashed line. */ + DASHES +} diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/TocTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/TocTest.java new file mode 100644 index 000000000..cc0275cde --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/TocTest.java @@ -0,0 +1,131 @@ +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.style.DocumentInsets; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * End-to-end proof for {@code addTableOfContents(...)}: each entry prints its + * anchor's resolved page (forward references), is a clickable go-to link to that + * page, and a long table of contents paginates across pages. + */ +class TocTest { + + @TempDir + Path tempDir; + + private static String pageText(PDDocument doc, int oneBasedPage) throws Exception { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(oneBasedPage); + stripper.setEndPage(oneBasedPage); + return stripper.getText(doc); + } + + private static List goToTargetPages(PDDocument doc, int zeroBasedPage) throws IOException { + List targets = new ArrayList<>(); + for (PDAnnotation annotation : doc.getPage(zeroBasedPage).getAnnotations()) { + if (annotation instanceof PDAnnotationLink link && link.getAction() instanceof PDActionGoTo goTo + && goTo.getDestination() instanceof PDPageDestination dest) { + targets.add(dest.retrievePageNumber()); + } + } + return targets; + } + + @Test + void tableOfContentsPrintsEachEntrysResolvedPage() throws Exception { + Path out = tempDir.resolve("toc.pdf"); + int intro; + int appendix; + try (DocumentSession session = GraphCompose.document(out) + .pageSize(260, 200) + .margin(DocumentInsets.of(24)) + .create()) { + session.pageFlow(page -> { + page.addTableOfContents(toc -> toc.title("Contents") + .entry("Introduction", "intro") + .entry("Appendix", "appendix")); + page.addPageBreak(b -> b.name("b1")); + page.addSection(s -> s.anchor("intro").addParagraph("Introduction")); + page.addPageBreak(b -> b.name("b2")); + page.addSection(s -> s.anchor("appendix").addParagraph("Appendix")); + }); + intro = session.pageIndex().pageNumberOf("intro").orElseThrow(); + appendix = session.pageIndex().pageNumberOf("appendix").orElseThrow(); + session.buildPdf(); + } + + assertThat(intro).isNotEqualTo(appendix); + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + assertThat(pageText(doc, 1)).contains("Contents", "Introduction", "Appendix", + Integer.toString(intro), Integer.toString(appendix)); + } + } + + @Test + void tableOfContentsEntriesLinkToTheirAnchorPages() throws Exception { + Path out = tempDir.resolve("toc-links.pdf"); + int intro; + int appendix; + try (DocumentSession session = GraphCompose.document(out) + .pageSize(260, 200) + .margin(DocumentInsets.of(24)) + .create()) { + session.pageFlow(page -> { + page.addTableOfContents(toc -> toc.entry("Introduction", "intro").entry("Appendix", "appendix")); + page.addPageBreak(b -> b.name("b1")); + page.addSection(s -> s.anchor("intro").addParagraph("Introduction")); + page.addPageBreak(b -> b.name("b2")); + page.addSection(s -> s.anchor("appendix").addParagraph("Appendix")); + }); + intro = session.pageIndex().pageOf("intro").orElseThrow(); // zero-based + appendix = session.pageIndex().pageOf("appendix").orElseThrow(); + session.buildPdf(); + } + + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + // Each entry label is a go-to link jumping to its anchor's page. + assertThat(goToTargetPages(doc, 0)).contains(intro, appendix); + } + } + + @Test + void longTableOfContentsPaginatesAcrossPages() { + try (DocumentSession session = GraphCompose.document(tempDir.resolve("toc-long.pdf")) + .pageSize(240, 160) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> { + page.addTableOfContents(toc -> { + toc.title("Contents"); + for (int i = 1; i <= 30; i++) { + toc.entry("Chapter " + i, "target"); + } + }); + page.addPageBreak(b -> b.name("b1")); + page.addSection(s -> s.anchor("target").addParagraph("Target")); + }); + // 30 entry rows do not fit one page: the flow paginates them rather + // than overflowing a single atomic block. + assertThatCode(session::buildPdf).doesNotThrowAnyException(); + assertThat(session.layoutGraph().totalPages()).isGreaterThan(1); + } + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/TocBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/TocBuilderTest.java new file mode 100644 index 000000000..414d8c31d --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/TocBuilderTest.java @@ -0,0 +1,27 @@ +package com.demcha.compose.document.dsl; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * {@link TocBuilder} rejects a blank entry label or anchor at the call site with + * a table-of-contents-scoped message, rather than letting a blank anchor surface + * a generic link error later from {@code build()}. + */ +class TocBuilderTest { + + @Test + void entryRejectsBlankLabel() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new TocBuilder().entry(" ", "anchor")) + .withMessageContaining("label"); + } + + @Test + void entryRejectsBlankAnchor() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new TocBuilder().entry("Introduction", "")) + .withMessageContaining("anchor"); + } +}