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