Skip to content

Commit 724114d

Browse files
authored
feat(api): addTableOfContents(...) — native clickable table of contents (#237)
A table of contents had to be hand-assembled: a row per entry, a leader sized by hand, and a manual two-pass to resolve the page numbers. addTableOfContents(toc -> toc.entry(label, anchor)) builds it natively — each entry 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. It is assembled entirely from existing primitives — auto/weight columns, a fill leader line, and a page reference — with no new engine code; the numbers resolve through the existing page-reference two-pass. The entry rows are added to the surrounding flow rather than wrapped in one block, so a long table of contents paginates across pages. TocBuilder configures the title, leader (DocumentLeader NONE/DOTS/DASHES), and text styles; a blank entry label or anchor is rejected at the call site. Tests: TocTest renders a contents page and asserts each entry prints its resolved page, each label is a clickable go-to link to its anchor's page, and a 30-entry contents paginates across pages; TocBuilderTest covers the blank label/anchor rejection. Example: TocExample (a contents page plus five chapters). Full suite green, no visual baselines changed.
1 parent 3cf16cd commit 724114d

10 files changed

Lines changed: 487 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ PDF `GoTo` actions. External links are unchanged.
1212

1313
### Public API
1414

15+
- **`addTableOfContents(...)` + `TocBuilder` / `DocumentLeader`** (`@since 1.9.0`).
16+
A native, clickable table of contents: each `entry(label, anchor)` becomes a row
17+
whose label links to the chapter (`linkTo`), a dotted or dashed leader fills the
18+
gap, and the page number is resolved automatically from the laid-out document —
19+
no manual two-pass. Built entirely from the existing primitives (auto/weight
20+
columns, `line().fill()`, `addPageReference`) and added to the flow, so a long
21+
contents paginates across pages.
22+
1523
- **`addPageReference(anchor)` + `PageReferenceNode`** (`@since 1.9.0`). Prints the
1624
page a declared `anchor(...)` lands on — a native "see page N" cross-reference —
1725
in a single authoring pass. A document that contains a page reference is laid
2.45 KB
Binary file not shown.

examples/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ are with the canonical DSL, then jump to its detailed section below.
102102
| [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) |
103103
| [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) |
104104
| [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) |
105+
| [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) |
105106
| [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) |
106107
| [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) |
107108
| [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())
722723
[📄 View PDF](../assets/readme/examples/page-reference.pdf) ·
723724
[📜 Full source](src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java)
724725

726+
### Table of contents
727+
728+
`addTableOfContents(...)` builds a native, clickable table of contents from the
729+
page-reference primitive: each `entry(label, anchor)` becomes a row whose label
730+
links to the chapter, a dotted (or dashed) leader fills the gap, and the page
731+
number is resolved automatically from the laid-out document — no manual two-pass.
732+
The rows are added to the flow, so a long contents paginates naturally.
733+
734+
```java
735+
flow.addTableOfContents(toc -> toc.title("Contents")
736+
.leader(DocumentLeader.DOTS)
737+
.entry("Introduction", "intro")
738+
.entry("Appendix", "appendix"));
739+
// ... chapters declared with .anchor("intro"), .anchor("appendix"), ...
740+
```
741+
742+
[📄 View PDF](../assets/readme/examples/table-of-contents.pdf) ·
743+
[📜 Full source](src/main/java/com/demcha/examples/features/navigation/TocExample.java)
744+
725745
---
726746

727747
## Production patterns

examples/src/main/java/com/demcha/examples/GenerateAllExamples.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import com.demcha.examples.features.text.InlineHighlightExample;
3131
import com.demcha.examples.features.navigation.InPdfNavigationExample;
3232
import com.demcha.examples.features.navigation.PageReferenceExample;
33+
import com.demcha.examples.features.navigation.TocExample;
3334
import com.demcha.examples.features.text.RichTextShowcaseExample;
3435
import com.demcha.examples.features.text.SectionPresetsExample;
3536
import com.demcha.examples.features.themes.CustomBusinessThemeExample;
@@ -170,6 +171,7 @@ public static void main(String[] args) throws Exception {
170171
System.out.println("Generated: " + SectionPresetsExample.generate());
171172
System.out.println("Generated: " + InPdfNavigationExample.generate());
172173
System.out.println("Generated: " + PageReferenceExample.generate());
174+
System.out.println("Generated: " + TocExample.generate());
173175

174176
// Theming + chrome
175177
System.out.println("Generated: " + CustomBusinessThemeExample.generate());
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.demcha.examples.features.navigation;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.style.DocumentColor;
6+
import com.demcha.compose.document.style.DocumentInsets;
7+
import com.demcha.compose.document.style.DocumentLeader;
8+
import com.demcha.compose.document.style.DocumentTextStyle;
9+
import com.demcha.examples.support.ExampleOutputPaths;
10+
11+
import java.nio.file.Path;
12+
13+
/**
14+
* Runnable showcase for v1.9 {@code addTableOfContents(...)}: a native, clickable
15+
* table of contents. Each {@code entry(label, anchor)} becomes a row whose label
16+
* links to the chapter, a dotted leader fills the gap, and the page number is
17+
* resolved automatically from the laid-out document — no manual two-pass.
18+
*
19+
* <pre>{@code
20+
* flow.addTableOfContents(toc -> toc.title("Contents")
21+
* .leader(DocumentLeader.DOTS)
22+
* .entry("Introduction", "intro")
23+
* .entry("Appendix", "appendix"));
24+
* // ... chapters declared with .anchor("intro"), .anchor("appendix"), ...
25+
* }</pre>
26+
*
27+
* @author Artem Demchyshyn
28+
*/
29+
public final class TocExample {
30+
31+
private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
32+
private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135);
33+
34+
private static final String[][] CHAPTERS = {
35+
{"Introduction", "intro"},
36+
{"Getting started", "start"},
37+
{"A longer chapter title that runs on", "longer"},
38+
{"Configuration reference", "config"},
39+
{"Appendix", "appendix"},
40+
};
41+
42+
private TocExample() {
43+
}
44+
45+
/**
46+
* Renders a table-of-contents page followed by one page per chapter, with the
47+
* contents page numbers resolved automatically.
48+
*
49+
* @return path to the generated PDF
50+
* @throws Exception if rendering or file IO fails
51+
*/
52+
public static Path generate() throws Exception {
53+
Path pdfFile = ExampleOutputPaths.prepare("features/navigation", "table-of-contents.pdf");
54+
55+
DocumentTextStyle title = DocumentTextStyle.DEFAULT.withSize(20).withColor(INK);
56+
DocumentTextStyle entry = DocumentTextStyle.DEFAULT.withSize(11).withColor(INK);
57+
DocumentTextStyle number = DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED);
58+
DocumentTextStyle chapterHeading = DocumentTextStyle.DEFAULT.withSize(18).withColor(INK);
59+
60+
try (DocumentSession document = GraphCompose.document(pdfFile)
61+
.pageSize(360, 280)
62+
.margin(DocumentInsets.of(34))
63+
.create()) {
64+
document.pageFlow(page -> {
65+
page.addTableOfContents(toc -> {
66+
toc.title("Contents").titleStyle(title)
67+
.leader(DocumentLeader.DOTS)
68+
.entryStyle(entry)
69+
.pageNumberStyle(number);
70+
for (String[] chapter : CHAPTERS) {
71+
toc.entry(chapter[0], chapter[1]);
72+
}
73+
});
74+
75+
for (String[] chapter : CHAPTERS) {
76+
page.addPageBreak(b -> b.name("to_" + chapter[1]));
77+
page.addSection(s -> s.anchor(chapter[1])
78+
.addParagraph(p -> p.text(chapter[0]).textStyle(chapterHeading)));
79+
}
80+
});
81+
document.buildPdf();
82+
}
83+
84+
return pdfFile;
85+
}
86+
87+
public static void main(String[] args) throws Exception {
88+
System.out.println("Generated: " + generate());
89+
}
90+
}

src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,26 @@ public T addSection(String name, Consumer<SectionBuilder> spec) {
960960
return add(BuilderSupport.configure(new SectionBuilder().name(name), spec).build());
961961
}
962962

963+
/**
964+
* Adds a native table of contents: an optional title and one clickable,
965+
* page-numbered row per {@code entry(label, anchor)}. Each row links its label
966+
* to the anchor, fills the gap with a leader, and prints the anchor's page —
967+
* resolved automatically from the laid-out document, no manual two-pass. The
968+
* rows are added to this flow, so a long table of contents paginates naturally.
969+
*
970+
* @param spec table-of-contents builder callback
971+
* @return this builder
972+
* @since 1.9.0
973+
*/
974+
public T addTableOfContents(Consumer<TocBuilder> spec) {
975+
TocBuilder toc = new TocBuilder();
976+
Objects.requireNonNull(spec, "spec").accept(toc);
977+
for (DocumentNode node : toc.buildEntries()) {
978+
add(node);
979+
}
980+
return self();
981+
}
982+
963983
/**
964984
* Adds a page reference that prints the page number a named {@code anchor(...)}
965985
* lands on — the "see page N" cross-reference. The number is resolved from the
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package com.demcha.compose.document.dsl;
2+
3+
import com.demcha.compose.document.node.DocumentNode;
4+
import com.demcha.compose.document.node.TextAlign;
5+
import com.demcha.compose.document.style.DocumentColor;
6+
import com.demcha.compose.document.style.DocumentLeader;
7+
import com.demcha.compose.document.style.DocumentLineCap;
8+
import com.demcha.compose.document.style.DocumentRowColumn;
9+
import com.demcha.compose.document.style.DocumentStroke;
10+
import com.demcha.compose.document.style.DocumentTextStyle;
11+
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
15+
/**
16+
* Builds a native table of contents: an optional title followed by one entry row
17+
* per {@code entry(label, anchor)}. Each row is laid out as
18+
* {@code columns(auto(), weight(1), auto())} — a clickable label, a leader that
19+
* fills the gap, and the entry's page number resolved automatically from the
20+
* laid-out document (see {@link AbstractFlowBuilder#addPageReference(String)}).
21+
*
22+
* <p>The rows are added directly to the surrounding flow, so a long table of
23+
* contents paginates across pages naturally.</p>
24+
*
25+
* @author Artem Demchyshyn
26+
* @since 1.9.0
27+
*/
28+
public final class TocBuilder {
29+
30+
private static final DocumentColor DEFAULT_LEADER_COLOR = DocumentColor.rgb(150, 150, 150);
31+
32+
private String title = "";
33+
private DocumentTextStyle titleStyle = DocumentTextStyle.DEFAULT.withSize(16);
34+
private DocumentTextStyle entryStyle = DocumentTextStyle.DEFAULT;
35+
private DocumentTextStyle pageNumberStyle = DocumentTextStyle.DEFAULT;
36+
private DocumentLeader leader = DocumentLeader.DOTS;
37+
private DocumentColor leaderColor = DEFAULT_LEADER_COLOR;
38+
private final List<Entry> entries = new ArrayList<>();
39+
40+
/**
41+
* Creates a table-of-contents builder.
42+
*/
43+
public TocBuilder() {
44+
}
45+
46+
/**
47+
* Sets an optional heading rendered above the entries.
48+
*
49+
* @param title heading text, or {@code null}/blank for no heading
50+
* @return this builder
51+
*/
52+
public TocBuilder title(String title) {
53+
this.title = title == null ? "" : title;
54+
return this;
55+
}
56+
57+
/**
58+
* Sets the heading text style.
59+
*
60+
* @param style heading style
61+
* @return this builder
62+
*/
63+
public TocBuilder titleStyle(DocumentTextStyle style) {
64+
this.titleStyle = style == null ? DocumentTextStyle.DEFAULT : style;
65+
return this;
66+
}
67+
68+
/**
69+
* Adds an entry linking {@code label} to a declared {@code anchor(...)} and
70+
* printing that anchor's page number.
71+
*
72+
* @param label the entry text (clickable, jumps to the anchor)
73+
* @param anchor the target anchor name
74+
* @return this builder
75+
* @throws IllegalArgumentException if {@code label} or {@code anchor} is blank
76+
*/
77+
public TocBuilder entry(String label, String anchor) {
78+
if (label == null || label.isBlank()) {
79+
throw new IllegalArgumentException("Table-of-contents entry label must not be blank.");
80+
}
81+
if (anchor == null || anchor.isBlank()) {
82+
throw new IllegalArgumentException("Table-of-contents entry anchor must not be blank: " + label);
83+
}
84+
entries.add(new Entry(label, anchor));
85+
return this;
86+
}
87+
88+
/**
89+
* Sets the leader filling the gap between label and page number.
90+
*
91+
* @param leader leader style; {@code null} resets to {@link DocumentLeader#NONE}
92+
* @return this builder
93+
*/
94+
public TocBuilder leader(DocumentLeader leader) {
95+
this.leader = leader == null ? DocumentLeader.NONE : leader;
96+
return this;
97+
}
98+
99+
/**
100+
* Sets the leader color.
101+
*
102+
* @param color leader color
103+
* @return this builder
104+
*/
105+
public TocBuilder leaderColor(DocumentColor color) {
106+
this.leaderColor = color == null ? DEFAULT_LEADER_COLOR : color;
107+
return this;
108+
}
109+
110+
/**
111+
* Sets the entry label text style.
112+
*
113+
* @param style entry style
114+
* @return this builder
115+
*/
116+
public TocBuilder entryStyle(DocumentTextStyle style) {
117+
this.entryStyle = style == null ? DocumentTextStyle.DEFAULT : style;
118+
return this;
119+
}
120+
121+
/**
122+
* Sets the page-number text style.
123+
*
124+
* @param style page-number style
125+
* @return this builder
126+
*/
127+
public TocBuilder pageNumberStyle(DocumentTextStyle style) {
128+
this.pageNumberStyle = style == null ? DocumentTextStyle.DEFAULT : style;
129+
return this;
130+
}
131+
132+
/**
133+
* Builds the heading (when set) and one row per entry, in source order. The
134+
* caller appends these to the flow so they paginate naturally.
135+
*
136+
* @return the table-of-contents nodes
137+
*/
138+
List<DocumentNode> buildEntries() {
139+
List<DocumentNode> nodes = new ArrayList<>();
140+
if (!title.isBlank()) {
141+
nodes.add(new ParagraphBuilder().text(title).textStyle(titleStyle).build());
142+
}
143+
for (Entry entry : entries) {
144+
nodes.add(buildEntryRow(entry));
145+
}
146+
return nodes;
147+
}
148+
149+
private DocumentNode buildEntryRow(Entry entry) {
150+
RowBuilder row = new RowBuilder()
151+
.gap(6)
152+
.columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1), DocumentRowColumn.auto());
153+
row.addParagraph(p -> p.text(entry.label()).textStyle(entryStyle).linkTo(entry.anchor()));
154+
if (leader == DocumentLeader.NONE) {
155+
row.addSpacer(s -> s.width(1).height(1));
156+
} else {
157+
row.addLine(line -> {
158+
line.fill().stroke(DocumentStroke.of(leaderColor, 1.0));
159+
if (leader == DocumentLeader.DOTS) {
160+
line.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND);
161+
} else {
162+
line.dashed(3, 3);
163+
}
164+
});
165+
}
166+
row.addPageReference(entry.anchor(), pageNumberStyle, TextAlign.RIGHT);
167+
return row.build();
168+
}
169+
170+
private record Entry(String label, String anchor) {
171+
}
172+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.demcha.compose.document.style;
2+
3+
/**
4+
* Leader style filling the gap between a table-of-contents entry's label and its
5+
* page number.
6+
*
7+
* @author Artem Demchyshyn
8+
* @since 1.9.0
9+
*/
10+
public enum DocumentLeader {
11+
/** No leader — the label and page number are separated by empty space. */
12+
NONE,
13+
/** A row of dots (the classic table-of-contents leader). */
14+
DOTS,
15+
/** A dashed line. */
16+
DASHES
17+
}

0 commit comments

Comments
 (0)