diff --git a/CHANGELOG.md b/CHANGELOG.md index 5507626..79e6ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,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/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..47f6955 --- /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 non-breaking 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..b65b633 --- /dev/null +++ b/src/test/java/io/github/demchaav/markdown/BookTocTest.java @@ -0,0 +1,129 @@ +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 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(); + + 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; + } +}