Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added assets/readme/examples/table-of-contents.pdf
Binary file not shown.
20 changes: 20 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <pre>{@code
* flow.addTableOfContents(toc -> toc.title("Contents")
* .leader(DocumentLeader.DOTS)
* .entry("Introduction", "intro")
* .entry("Appendix", "appendix"));
* // ... chapters declared with .anchor("intro"), .anchor("appendix"), ...
* }</pre>
*
* @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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,26 @@ public T addSection(String name, Consumer<SectionBuilder> 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<TocBuilder> 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
Expand Down
172 changes: 172 additions & 0 deletions src/main/java/com/demcha/compose/document/dsl/TocBuilder.java
Original file line number Diff line number Diff line change
@@ -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)}).
*
* <p>The rows are added directly to the surrounding flow, so a long table of
* contents paginates across pages naturally.</p>
*
* @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<Entry> 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<DocumentNode> buildEntries() {
List<DocumentNode> 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)

Check notice

Code scanning / CodeQL

Deprecated method or constructor invocation Note

Invoking
RowBuilder.gap
should be avoided because it has been deprecated.
Comment on lines +150 to +151
.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) {
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading