From 2a2bfbf4d95f341ee74b7876f7b1a15de311d7e6 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 1 Jul 2026 23:14:58 +0100 Subject: [PATCH 1/2] feat(render): book-style page-numbered [TOC] via BookTocRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An opt-in alternative to the default [TOC] link list: swapping the TocNode renderer (MarkdownTheme.builder(base).renderer(TocNode.class, new BookTocRenderer("Contents"))) renders the marker as dot-leader contents rows with page numbers resolved automatically from the laid-out document — the engine's addTableOfContents/TocBuilder does the second layout pass; the library only feeds label+anchor pairs from the planned heading slugs. Labels indent by heading level with non-breaking spaces (the code-block idiom — plain leading spaces are collapsed by text layout and base-14 fonts carry no em-space glyph); optional title row; entries stay clickable GoTo jumps; empty/no-heading documents degrade exactly like the default renderer. Tests: resolved page number printed on the digit-free contents page + clickable entries; no-heading no-op. Runnable BookTocExample (paired with the page-number footer). Suite green (169). --- CHANGELOG.md | 10 ++ README.md | 10 +- examples/README.md | 1 + .../markdown/examples/BookTocExample.java | 50 ++++++++++ .../markdown/render/BookTocRenderer.java | 96 +++++++++++++++++++ .../github/demchaav/markdown/BookTocTest.java | 83 ++++++++++++++++ 6 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 examples/src/main/java/io/github/demchaav/markdown/examples/BookTocExample.java create mode 100644 src/main/java/io/github/demchaav/markdown/render/BookTocRenderer.java create mode 100644 src/test/java/io/github/demchaav/markdown/BookTocTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 5507626..7af7809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ and the project follows [Semantic Versioning](https://semver.org/). were covered in 0.2.0). A footnote cited only inside a *heading* still keeps just its forward link — a heading's single anchor slot already carries its navigation slug. +### Added +- **Book-style page-numbered table of contents — `BookTocRenderer`.** An opt-in alternative to + the default `[TOC]` link list: swap the `TocNode` renderer + (`MarkdownTheme.builder(base).renderer(TocNode.class, new BookTocRenderer("Contents"))`) and + the marker renders as dot-leader contents rows — "Introduction ….. 3" — with **page numbers + resolved automatically from the laid-out document** (the engine's `addTableOfContents`; no + manual two-pass) and every label a clickable jump. Heading nesting indents the label; an + optional title row; empty-heading and no-heading documents degrade exactly like the default + renderer. Runnable `BookTocExample` (paired with the page-number footer). + ### Changed - **Renderer classes reorganised (source-compatible for most users).** The four largest built-in renderers moved from nested classes to top-level classes in the same package: diff --git a/README.md b/README.md index 8f1665c..e8000ee 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,15 @@ declares a **GitHub-style anchor**, so `[text](#heading)` links jump straight to it as native PDF go-to actions (footnote markers jump to their note and back the same way). A standalone **`[TOC]`** (or `[[_TOC_]]`) line expands into an **auto-generated, clickable table of contents** — one link per heading, -nested by level — that you can drop anywhere, including above the headings it lists. +nested by level — that you can drop anywhere, including above the headings it lists. Prefer +a print-style contents page? Swap in the **book TOC** — dot leaders and **live page +numbers**, resolved from the laid-out document: + +```java +MarkdownTheme book = MarkdownTheme.builder(DefaultMarkdownTheme.light()) + .renderer(TocNode.class, new BookTocRenderer("Contents")) + .build(); +``` Content the library does not model (raw HTML blocks, inline HTML) is **surfaced as raw text rather than silently dropped**; `MarkdownComposer.builder().strictMode(true)` diff --git a/examples/README.md b/examples/README.md index ec40e17..75cd3db 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,6 +34,7 @@ From this `examples/` directory (use `../mvnw.cmd` on Windows): | `…examples.FooterExample` | A running footer with `Page N of M` page numbers (`FooterTokens.pageNumbers()`) | `footer.pdf` | | `…examples.PngExportExample` | Rasterize straight to PNG page images via `toImages(dpi)` — no PDF round-trip | `png-export-p*.png` | | `…examples.VectorEmojiExample` | `:shortcode:` emoji as crisp vector glyphs via the optional `graph-compose-emoji` artifact | `vector-emoji.pdf` | +| `…examples.BookTocExample` | Book-style `[TOC]`: dot leaders + live page numbers via `BookTocRenderer` | `book-toc.pdf` | | `…examples.EmojiExample` | Emoji shortcodes → inline images via `ClasspathEmojiResolver` | `emoji.pdf` | | `…examples.FrontMatterExample` | A YAML `---` front-matter title block | `front-matter.pdf` | diff --git a/examples/src/main/java/io/github/demchaav/markdown/examples/BookTocExample.java b/examples/src/main/java/io/github/demchaav/markdown/examples/BookTocExample.java new file mode 100644 index 0000000..a7a6343 --- /dev/null +++ b/examples/src/main/java/io/github/demchaav/markdown/examples/BookTocExample.java @@ -0,0 +1,50 @@ +package io.github.demchaav.markdown.examples; + +import io.github.demchaav.markdown.composer.MarkdownComposer; +import io.github.demchaav.markdown.model.TocNode; +import io.github.demchaav.markdown.render.BookTocRenderer; +import io.github.demchaav.markdown.theme.DefaultMarkdownTheme; +import io.github.demchaav.markdown.theme.MarkdownTheme; +import io.github.demchaav.markdown.theme.tokens.FooterTokens; + +import java.nio.file.Path; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * A book-style, page-numbered table of contents: swap the {@code TocNode} renderer for + * {@link BookTocRenderer} and a {@code [TOC]} marker renders as dot-leader rows — + * "Introduction ..... 3" — with the page numbers resolved automatically from the laid-out + * document and every label a clickable jump. Paired here with the page-number footer, the + * classic report look. The default renderer (a plain clickable link list) stays as-is for + * screen-oriented documents. + * + *
+ *   ../mvnw -f pom.xml exec:java \
+ *     -Dexec.mainClass=io.github.demchaav.markdown.examples.BookTocExample
+ * 
+ */ +public final class BookTocExample { + + private BookTocExample() { + } + + public static void main(String[] args) throws Exception { + String filler = IntStream.range(0, 45) + .mapToObj(i -> "Filler prose paragraph so the chapters land on later pages.") + .collect(Collectors.joining("\n\n")); + String markdown = "[TOC]\n\n# Introduction\n\nWelcome.\n\n" + filler + + "\n\n# Getting started\n\nSetup steps.\n\n## Configuration\n\nDetails.\n\n" + filler + + "\n\n# Appendix\n\nReference material.\n"; + + MarkdownTheme base = DefaultMarkdownTheme.light(); + MarkdownTheme bookTheme = MarkdownTheme.builder(base) + .renderer(TocNode.class, new BookTocRenderer("Contents")) // page-numbered TOC + .tokens(base.tokens().withFooter(FooterTokens.pageNumbers())) // matching footer + .build(); + + Path out = Path.of("book-toc.pdf"); + MarkdownComposer.create(bookTheme).render(markdown).writePdf(out); + System.out.println("Wrote " + out.toAbsolutePath() + " (dot-leader contents with live page numbers)"); + } +} diff --git a/src/main/java/io/github/demchaav/markdown/render/BookTocRenderer.java b/src/main/java/io/github/demchaav/markdown/render/BookTocRenderer.java new file mode 100644 index 0000000..7b5c64f --- /dev/null +++ b/src/main/java/io/github/demchaav/markdown/render/BookTocRenderer.java @@ -0,0 +1,96 @@ +package io.github.demchaav.markdown.render; + +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.style.DocumentLeader; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import io.github.demchaav.markdown.model.TocNode; +import io.github.demchaav.markdown.theme.style.InlineStyle; + +import java.util.List; + +/** + * A book-style alternative to {@link TocRenderer}: renders a {@code [TOC]} marker as a native, + * page-numbered table of contents — each heading becomes a row whose clickable label jumps to + * the heading, a dotted leader fills the gap, and the page number is resolved automatically + * from the laid-out document (the engine performs the second layout pass; no manual + * bookkeeping). Opt in by swapping the renderer for the {@code TocNode} type: + * + *
{@code
+ * MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light())
+ *         .renderer(TocNode.class, new BookTocRenderer())
+ *         .build();
+ * }
+ * + *

The default {@link TocRenderer} (a plain clickable link list, no page numbers) stays the + * screen-oriented default. Heading nesting is shown by indenting the label with spaces — + * the engine's contents rows are flat. Empty-text headings are skipped; a document with no + * headings renders nothing.

+ */ +public final class BookTocRenderer implements NodeRenderer { + + /** Label indent per heading level below the top level. Non-breaking spaces — the same + * idiom the code-block renderer uses — because plain leading spaces are collapsed by the + * text layout and the base-14 fonts carry no em-space glyph. */ + private static final String INDENT = "   "; + + private final String title; + + /** Creates a book TOC with no title row (write your own heading in the Markdown). */ + public BookTocRenderer() { + this(null); + } + + /** + * Creates a book TOC with a title row above the entries. + * + * @param title the contents title (e.g. {@code "Contents"}), or {@code null} for none + */ + public BookTocRenderer(String title) { + this.title = title == null || title.isBlank() ? null : title.strip(); + } + + @Override + public void render(TocNode node, SectionBuilder host, RenderContext ctx) { + List entries = ctx.tocEntries().stream() + .filter(entry -> !entry.text().isEmpty()) + .toList(); + if (entries.isEmpty()) { + return; + } + int minLevel = entries.stream().mapToInt(TocEntry::level).min().orElse(1); + InlineStyle base = ctx.paragraphInline(); + var typo = ctx.tokens().typography(); + DocumentTextStyle entryStyle = DocumentTextStyle.builder() + .fontName(base.family().resolve(false, false)) + .size(base.size()) + .color(base.color()) + .decoration(DocumentTextDecoration.DEFAULT) + .build(); + DocumentTextStyle pageStyle = DocumentTextStyle.builder() + .fontName(base.family().resolve(false, false)) + .size(base.size()) + .color(ctx.tokens().colors().muted()) + .decoration(DocumentTextDecoration.DEFAULT) + .build(); + DocumentTextStyle titleStyle = DocumentTextStyle.builder() + .fontName(typo.headingFamily().resolve(true, false)) + .size(typo.headingSize(2)) + .color(ctx.tokens().colors().heading()) + .decoration(DocumentTextDecoration.DEFAULT) + .build(); + + host.addTableOfContents(toc -> { + if (title != null) { + toc.title(title).titleStyle(titleStyle); + } + toc.leader(DocumentLeader.DOTS) + .leaderColor(ctx.tokens().colors().muted()) + .entryStyle(entryStyle) + .pageNumberStyle(pageStyle); + for (TocEntry entry : entries) { + toc.entry(INDENT.repeat(entry.level() - minLevel) + entry.text(), entry.slug()); + } + }); + } +} diff --git a/src/test/java/io/github/demchaav/markdown/BookTocTest.java b/src/test/java/io/github/demchaav/markdown/BookTocTest.java new file mode 100644 index 0000000..2bb0062 --- /dev/null +++ b/src/test/java/io/github/demchaav/markdown/BookTocTest.java @@ -0,0 +1,83 @@ +package io.github.demchaav.markdown; + +import io.github.demchaav.markdown.composer.MarkdownComposer; +import io.github.demchaav.markdown.model.TocNode; +import io.github.demchaav.markdown.render.BookTocRenderer; +import io.github.demchaav.markdown.theme.DefaultMarkdownTheme; +import io.github.demchaav.markdown.theme.MarkdownTheme; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +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.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link BookTocRenderer} (opted in by swapping the {@code TocNode} renderer) renders a + * page-numbered, dot-leader table of contents whose page numbers are resolved from the + * laid-out document, with clickable entries. + */ +class BookTocTest { + + /** Digit-free filler so a page number in the TOC is unambiguous in extracted text. */ + private static final String FILLER = IntStream.range(0, 40) + .mapToObj(i -> "Filler prose to push the following chapter onto a later page.") + .collect(Collectors.joining("\n\n")); + + private static MarkdownComposer bookComposer(String title) { + MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light()) + .renderer(TocNode.class, new BookTocRenderer(title)) + .build(); + return MarkdownComposer.create(theme); + } + + @Test + void bookTocResolvesPageNumbersAndStaysClickable() throws Exception { + String md = "[TOC]\n\n# Alpha\n\n" + FILLER + "\n\n# Omega\n\nShort tail.\n"; + + byte[] pdf = bookComposer("Contents").render(md).toPdfBytes(); + + try (PDDocument doc = Loader.loadPDF(pdf)) { + assertThat(doc.getNumberOfPages()).isGreaterThan(1); + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(1); + stripper.setEndPage(1); + String tocPage = stripper.getText(doc); + // Title row + both entries on the contents page. + assertThat(tocPage).contains("Contents").contains("Alpha").contains("Omega"); + // "Omega" starts on a later page, and that resolved number is printed in the TOC. + // The filler is digit-free, so any digit >= 2 on page 1 is a TOC page number. + assertThat(tocPage).containsPattern("[2-9]"); + // Entries remain clickable in-document jumps. + assertThat(countGoTo(doc)).isGreaterThanOrEqualTo(2); + } + } + + @Test + void bookTocWithoutHeadingsRendersNothingAndDoesNotCrash() throws Exception { + byte[] pdf = bookComposer(null).render("[TOC]\n\nJust prose, no headings.").toPdfBytes(); + + try (PDDocument doc = Loader.loadPDF(pdf)) { + assertThat(countGoTo(doc)).isZero(); + } + } + + private static int countGoTo(PDDocument doc) throws Exception { + int goTo = 0; + for (PDPage page : doc.getPages()) { + for (PDAnnotation annotation : page.getAnnotations()) { + if (annotation instanceof PDAnnotationLink link && link.getAction() instanceof PDActionGoTo) { + goTo++; + } + } + } + return goTo; + } +} From aa84fd3c97166bfbca9f1d1eb19a27a8a8b12f53 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 1 Jul 2026 23:28:52 +0100 Subject: [PATCH 2/2] test(render): cover the book-TOC nesting indent; review polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the blind cold review of BookTocRenderer: (1) the nesting-indent path had zero coverage — new tests assert an h2 entry carries a leading indent while h1 entries start at the line start (PDFBox extracts the NBSP indent as plain leading blanks), an h2-only document is not indented (minLevel normalization), and the resolved page number sits on its own entry's line; (2) class Javadoc now says non-breaking spaces. The label re-parse edge (engine markdown=true re-parses TocBuilder plain-text labels) is recorded as a tracked audit in the private roadmap — a session-wide markdown(false) decision is its own PR. Suite green (171). --- CHANGELOG.md | 18 ++++---- .../markdown/render/BookTocRenderer.java | 2 +- .../github/demchaav/markdown/BookTocTest.java | 46 +++++++++++++++++++ 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af7809..79e6ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,16 +19,6 @@ and the project follows [Semantic Versioning](https://semver.org/). were covered in 0.2.0). A footnote cited only inside a *heading* still keeps just its forward link — a heading's single anchor slot already carries its navigation slug. -### Added -- **Book-style page-numbered table of contents — `BookTocRenderer`.** An opt-in alternative to - the default `[TOC]` link list: swap the `TocNode` renderer - (`MarkdownTheme.builder(base).renderer(TocNode.class, new BookTocRenderer("Contents"))`) and - the marker renders as dot-leader contents rows — "Introduction ….. 3" — with **page numbers - resolved automatically from the laid-out document** (the engine's `addTableOfContents`; no - manual two-pass) and every label a clickable jump. Heading nesting indents the label; an - optional title row; empty-heading and no-heading documents degrade exactly like the default - renderer. Runnable `BookTocExample` (paired with the page-number footer). - ### Changed - **Renderer classes reorganised (source-compatible for most users).** The four largest built-in renderers moved from nested classes to top-level classes in the same package: @@ -38,6 +28,14 @@ and the project follows [Semantic Versioning](https://semver.org/). needs the import updated. `BuiltinRenderers` drops from ~750 to ~440 lines. ### Added +- **Book-style page-numbered table of contents — `BookTocRenderer`.** An opt-in alternative to + the default `[TOC]` link list: swap the `TocNode` renderer + (`MarkdownTheme.builder(base).renderer(TocNode.class, new BookTocRenderer("Contents"))`) and + the marker renders as dot-leader contents rows — "Introduction ….. 3" — with **page numbers + resolved automatically from the laid-out document** (the engine's `addTableOfContents`; no + manual two-pass) and every label a clickable jump. Heading nesting indents the label; an + optional title row; empty-heading and no-heading documents degrade exactly like the default + renderer. Runnable `BookTocExample` (paired with the page-number footer). - **Vector colour emoji out of the box — optional `graph-compose-emoji` support.** With the companion artifact on the classpath (declared `optional`, mirroring `graph-compose-fonts`), `:shortcode:` emoji render as crisp Noto vector glyphs at any size — no user-supplied images. diff --git a/src/main/java/io/github/demchaav/markdown/render/BookTocRenderer.java b/src/main/java/io/github/demchaav/markdown/render/BookTocRenderer.java index 7b5c64f..47f6955 100644 --- a/src/main/java/io/github/demchaav/markdown/render/BookTocRenderer.java +++ b/src/main/java/io/github/demchaav/markdown/render/BookTocRenderer.java @@ -23,7 +23,7 @@ * } * *

The default {@link TocRenderer} (a plain clickable link list, no page numbers) stays the - * screen-oriented default. Heading nesting is shown by indenting the label with spaces — + * screen-oriented default. Heading nesting is shown by indenting the label with non-breaking spaces — * the engine's contents rows are flat. Empty-text headings are skipped; a document with no * headings renders nothing.

*/ diff --git a/src/test/java/io/github/demchaav/markdown/BookTocTest.java b/src/test/java/io/github/demchaav/markdown/BookTocTest.java index 2bb0062..b65b633 100644 --- a/src/test/java/io/github/demchaav/markdown/BookTocTest.java +++ b/src/test/java/io/github/demchaav/markdown/BookTocTest.java @@ -60,6 +60,52 @@ void bookTocResolvesPageNumbersAndStaysClickable() throws Exception { } } + @Test + void nestedHeadingIndentsItsTocLabelWithNonBreakingSpaces() throws Exception { + String md = "[TOC]\n\n# Alpha\n\n## Beta\n\n" + FILLER + "\n\n# Omega\n\nTail.\n"; + + byte[] pdf = bookComposer(null).render(md).toPdfBytes(); + + try (PDDocument doc = Loader.loadPDF(pdf)) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(1); + stripper.setEndPage(1); + String[] lines = stripper.getText(doc).split("\\r?\\n"); + String alphaLine = lineContaining(lines, "Alpha"); + String betaLine = lineContaining(lines, "Beta"); + String omegaLine = lineContaining(lines, "Omega"); + // The h2 label is indented (PDFBox extracts the NBSP indent as leading blanks); + assertThat(betaLine.substring(0, betaLine.indexOf("Beta"))) + .as("h2 entry carries a leading indent").isNotEmpty().isBlank(); + assertThat(alphaLine).as("h1 entry is not indented").startsWith("Alpha"); + // The resolved page number sits on ITS entry's line (Omega starts on a later page). + assertThat(omegaLine).containsPattern("[2-9]"); + } + } + + @Test + void h2OnlyDocumentIsNotIndented() throws Exception { + // minLevel normalization: when the shallowest heading is h2, it IS the top level. + String md = "[TOC]\n\n## Solo\n\nBody.\n"; + + byte[] pdf = bookComposer(null).render(md).toPdfBytes(); + + try (PDDocument doc = Loader.loadPDF(pdf)) { + String text = new PDFTextStripper().getText(doc); + String soloTocLine = lineContaining(text.split("\\r?\\n"), "Solo"); + assertThat(soloTocLine).as("h2-only doc is top level, no indent").startsWith("Solo"); + } + } + + private static String lineContaining(String[] lines, String needle) { + for (String line : lines) { + if (line.contains(needle)) { + return line; + } + } + throw new AssertionError("no line containing '" + needle + "'"); + } + @Test void bookTocWithoutHeadingsRendersNothingAndDoesNotCrash() throws Exception { byte[] pdf = bookComposer(null).render("[TOC]\n\nJust prose, no headings.").toPdfBytes();