Skip to content

feat: two-pass table of contents with linked, numbered entries - #9

Merged
ralsina merged 2 commits into
mainfrom
two-pass-toc
Sep 8, 2026
Merged

ralsina merged 2 commits into
mainfrom
two-pass-toc

Conversation

@ralsina

@ralsina ralsina commented Sep 8, 2026

Copy link
Copy Markdown
Owner

What

markpdf --toc prepends a table of contents with real page numbers, where every entry links to its section. This closes the gap KDP.md documented — "The tool has no two-pass TOC; write it by hand after the layout settles."

  • --toc — prepend the TOC
  • --toc-depth N — deepest heading level listed, 1 (chapters only) to 6 (default 1)
  • --toc-title T — heading above the list (default "Contents")
  • --pageless + --toc — entries without page numbers (there are no pages)
  • library: toc:, toc_depth:, toc_title: kwargs on Markd::Pdf.render / render_to_memory

How

The C++ shim already computed each heading's page for the PDF outline and threw the mapping away. It now also returns a heading map (index\tlevel\tpage\ttitle per heading) to Crystal.

On top of that, Markd::Pdf.settled_pages runs the numbers to a fixed point: render → read where the headings actually landed → rebuild the TOC block → re-render, until what the TOC says matches where things are. The TOC's own length shifts every page after it, so one pass can never know its numbers. Two or three passes converge; the cap is 6 with a stderr warning if a pathological document never settles.

Entries are clickable: rewrite_heading_anchors injects a unique #mtoc-N anchor into every heading with visible text (same numbering the shim's map uses), and each entry links to it via the shim's existing internal-link machinery. The vendored markd's toc? anchors are deliberately not used (lib/ is off-limits and not duplicate-safe).

Notable findings along the way

  • litehtml page-break properties inherit down the tree (html_tag.cpp:347 resolves custom properties through ancestors). A page-break-after: always on the nav gave every TOC entry its own page (1-page doc → 8 pages). The forced break to the body now rides an empty div after the nav.
  • The kdp gutter iteration folded into the same settle loop (page count depends on margins and the TOC block), and the CLI's kdp path now routes through the library instead of duplicating it — which also fixes --mirror-headers being dropped in kdp mode.
  • The TOC title is a styled <div>, not an <h1>, so it never self-lists, triggers the kdp recto rule, or lands in the bookmarks.

Verification

  • 17 new specs in spec/pdf_toc_spec.cr: map parsing, anchor numbering alignment, block building/filters, heading-map layout, the real two-pass test (40 chapters: every TOC number equals the page where that chapter actually renders), kdp recto+filler numbering, pageless, CLI flag handling
  • Full suite: 252 examples, 0 failures, 1 pre-existing pending
  • ameba clean; all four binaries build (shards build); shim builds (make -C ext)
  • Manual smoke: multi-page TOC convergence, kdp fillers counted in numbers, clickable /Link annotations present, HTML input, pageless, no-headings warning, invalid --toc-depth aborts

Docs: README (flag reference + a TOC section) and KDP.md (gap paragraph replaced with how the TOC plays with recto fillers and the even-page pad).

--toc prepends a table of contents whose page numbers are found by
fixed point: render, read back the heading->page map the shim already
collects for the PDF outline, rebuild the block, re-render — the TOC's
own length shifts every page after it, so one pass can never know its
numbers. Entries link to their sections (#mtoc-N anchors injected into
the headings), --toc-depth caps the listing (1-6, default chapters
only), --toc-title renames it, and --pageless drops the numbers.

The settle loop in Markd::Pdf.settled_pages also absorbs the kdp
gutter iteration (page count depends on margins and on the TOC block
alike), and the CLI's kdp path now goes through the library loop,
which learns to pass mirror_headers through. The forced break to the
body rides an empty div because the shim's page-break properties
inherit down the tree — a break on the nav would give every entry its
own page.
@ralsina
ralsina merged commit 077bcbb into main Sep 8, 2026
2 checks passed
@ralsina
ralsina deleted the two-pass-toc branch September 8, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant