Skip to content

Export document navigation from the box tree: LinkRuns, DocumentIDs, Headings - #139

Merged
tannevaled merged 1 commit into
mainfrom
nav
Sep 6, 2026
Merged

tannevaled merged 1 commit into
mainfrom
nav

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Why

go-pdfkit/html2pdf re-implemented, unexported, what it needed to write PDF link annotations, named destinations and an outline: per-line clickable runs of each <a href>, the position of each id'd element, and the h1..h6 outline. None of that is about PDF — it is what a laid-out document looks like to anything that navigates it (an outline panel, an EPUB export, a scroll-to-hash in a browser shell) — and html2pdf had also copied the engine's own anchorFor and itemRect to get at it. So it moves here, and html2pdf can delete its copies.

What

nav.go adds, over a laid-out *layout.Box tree, in document CSS px:

type LinkRun struct { X, Y, W, H float64; Node *dom.Node; URI string; Fragment string }
func LinkRuns(root *layout.Box, base *url.URL, ids map[string]struct{}) []LinkRun

type Anchor struct{ X, Y float64 }
func DocumentIDs(root *layout.Box) map[string]Anchor

type Heading struct { Level int; Title string; Y float64; Node *dom.Node }
func Headings(root *layout.Box) []Heading
  • LinkRuns: one rectangle per anchor per line (a wrapped link's clickable area follows the text), URI for an absolute http(s) target or Fragment for an id in ids; javascript:/mailto:/tel:/data:/empty hrefs and fragments nobody anchors yield nothing. An anchor that laid out no atom at all (a link around an empty CSS-sized block — Hacker News's vote arrows) gets the rectangle of the outermost box under it, once.
  • DocumentIDs: every id and legacy <a name> with a position — a block's box top-left, an inline's first atom — document order, first occurrence wins.
  • Headings: h1..h6 in document order, title = atoms' texts joined by single spaces, untitled skipped.

LinksFromBox is left as it is (one bounding box per anchor in image pixels, for hit-testing a click) and its tests pass unchanged; its itemRect now rounds out the shared float primitive itemBox instead of carrying a second copy of the same conditions. anchorFor stays unexported and is shared by both.

Tests

html2pdf's, ported to the engine's fixtures (dom.Parse, css.Cascade, layout.LayoutDocument with real fonts): a wrapped anchor splits per line; a dangling #nowhere is dropped; the document's own URL plus a fragment is a Fragment; a relative href resolves against base; javascript:/mailto:/tel:/data:/empty hrefs yield nothing; an atom-less anchor gets its block's rectangle once and a text anchor is not doubled; ids for a block, an inline and <a name>; headings with levels and an icon-only heading skipped; resolveAnchor's edge cases. CGO_ENABLED=0 go vet ./... && go test -short ./... green locally.

🤖 Generated with Claude Code

…Headings

go-pdfkit/html2pdf re-implemented, unexported, what it needed to write PDF
link annotations, named destinations and an outline: per-line clickable
runs of each <a href>, the position of each id'd element, and the h1..h6
outline. None of that is about PDF — it is what a laid-out document looks
like to anything that navigates it (an outline panel, an EPUB export, a
scroll-to-hash in a browser shell) — and html2pdf had also copied the
engine's own anchorFor and itemRect to get at it. So it moves here.

nav.go adds, over a laid-out *layout.Box tree, in document CSS px:

  LinkRuns(root, base, ids) []LinkRun   one rectangle per anchor per line,
                                        URI (absolute http(s)) or Fragment
                                        (an id in ids); an atom-less anchor
                                        gets its outermost box, once
  DocumentIDs(root) map[string]Anchor   every id and legacy <a name> with a
                                        position, first occurrence wins
  Headings(root) []Heading              h1..h6 in order, atoms' texts joined
                                        by single spaces, untitled skipped

LinksFromBox is left as it is (one bounding box per anchor, in image
pixels, for hit-testing a click) and its tests pass unchanged; its
itemRect now rounds out the shared float primitive itemBox instead of
carrying a second copy of the same conditions. anchorFor stays unexported
and is shared by both readings of the tree.

Tests are html2pdf's, ported to the engine's fixtures (dom.Parse,
css.Cascade, layout.LayoutDocument with real fonts): a wrapped anchor
splits per line; a dangling "#nowhere" is dropped; the document's own URL
plus a fragment is a Fragment; a relative href resolves against base;
javascript:/mailto:/tel:/data:/empty hrefs yield nothing; an atom-less
anchor gets its block's rectangle once and a text anchor is not doubled;
ids for a block, an inline and <a name>; headings with levels and an
icon-only heading skipped; resolveAnchor's edge cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit c3247ed into main Sep 6, 2026
7 checks passed
@tannevaled
tannevaled deleted the nav branch September 6, 2026 16:06
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