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 @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <pre>
* ../mvnw -f pom.xml exec:java \
* -Dexec.mainClass=io.github.demchaav.markdown.examples.BookTocExample
* </pre>
*/
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)");
}
}
Original file line number Diff line number Diff line change
@@ -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 <em>page number is resolved automatically
* from the laid-out document</em> (the engine performs the second layout pass; no manual
* bookkeeping). Opt in by swapping the renderer for the {@code TocNode} type:
*
* <pre>{@code
* MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light())
* .renderer(TocNode.class, new BookTocRenderer())
* .build();
* }</pre>
*
* <p>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.</p>
*/
public final class BookTocRenderer implements NodeRenderer<TocNode> {

/** 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<TocEntry> 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());
}
});
}
}
129 changes: 129 additions & 0 deletions src/test/java/io/github/demchaav/markdown/BookTocTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
}