Skip to content

html: register element ids as PDF named destinations#422

Open
kuadrosx wants to merge 1 commit into
carlos7ags:mainfrom
kuadrosx:fix/internal-anchor-destinations
Open

html: register element ids as PDF named destinations#422
kuadrosx wants to merge 1 commit into
carlos7ags:mainfrom
kuadrosx:fix/internal-anchor-destinations

Conversation

@kuadrosx

Copy link
Copy Markdown

Problem

<a href="#id"> links render as clickable annotations but navigate nowhere: an element's id is consumed only for CSS selector matching, so nothing registers it as a PDF named destination. The link emits a dangling /GoTo (id) string action pointing at a destination that is never defined — the generated PDF has an empty /Dests.

This is the behavior the ## [0.8.0] CHANGELOG entry (#223) describes as fixed ("layout.Anchor element + auto-registered PDF named destinations"), but the implementing symbols (layout.Anchor, PageResult.Anchors, LinkDestName) are absent from the tree — the entry outlived the code (reverted or never merged).

Fix

Re-implements the id → named destination path by mirroring the existing bookmarkAnchor mechanism:

id attr → wrap element in a layout anchor marker → marker tags its first PlacedBlock.AnchorName → renderer surfaces PageResult.Anchors → the document layer registers them via AddNamedDest during layout → the existing /Dests writer + annotation resolver emit the destination.

  • layout/anchor.go (new) — NewAnchor(inner, name), a thin pass-through wrapper modeled on bookmark.go; delegates Measurable so flex / table-cell shrink-to-fit is unaffected.
  • layoutPlacedBlock.AnchorName, PageResult.Anchors (AnchorInfo{Name, Y}), recorded in drawBlockNested at the block's top y.
  • html/converter_dispatch.go — wrap the produced element when the node carries an id (block-element dispatch, so headings/divs/sections are covered uniformly).
  • document/document.go — register each anchor as a NamedDest (FitH at the target y). Extracts a shared buildNamedDestArray so the annotation resolver honors FitH/Top (links land on the section, not the page top) and agrees with the /Dests writer.

No caller AddNamedDest() is required — registration is automatic.

Tests

  • layout/anchor_test.go — wrapper unit tests (name on first block, overflow not re-tagged, Measurable delegation, empty-plan/empty-name safety).
  • html/anchor_dest_test.go — end-to-end: html.Convert → WriteTo, then walks the parsed catalog's /Dests to prove the destination is defined, asserts /FitH and the absence of a dangling /GoTo. Verified red before the fix, green after.
  • gofmt -s, go vet, and the full go test ./... -race suite pass.

Scope notes

  • Stays on the existing flat /Dests dictionary (ISO 32000 §12.3.2.3), which viewers honor. A /Names → /Dests name tree is a separate optional enhancement.
  • Inline ids (e.g. <span id> mid-paragraph) flow through the inline path and are out of scope; real anchor targets are block-level.
  • The stale ## [0.8.0] html: emit real PDF link annotations for href="#anchor" with auto-registered destinations #223 CHANGELOG line is left as released history; this change is logged under ## Unreleased → Fixed.

Internal anchor links (<a href="#section">) rendered as clickable link
annotations but navigated nowhere: folio never registered an HTML
element's id as a PDF named destination, so each /GoTo pointed at a
target that was never defined and the /Dests catalog was empty.

An element carrying an id is now wrapped in a layout anchor marker that
tags its first PlacedBlock with the id. The renderer surfaces these on
PageResult.Anchors and buildAllPages registers each as a named
destination (offset by manualPageCount so it lands on the right absolute
page), feeding the existing /Dests writer and the link-annotation
resolver — no caller AddNamedDest needed. The resolver and writer share
one destination-array builder so an internal link resolves to a direct
/Dest rather than a dangling string /GoTo.

Destinations use /XYZ at the target's y with zoom retained — the same
shape auto-bookmarks use — so clicking a link and clicking an outline
entry to the same target behave identically instead of the link forcing
a fit-width zoom. On a duplicate name (two equal ids, or an id colliding
with a caller's AddNamedDest) the /Dests writer keeps the first
registration, matching the resolver's first-match so both agree.

The anchor and bookmark markers expose an unwrap() and the layouter
resolves optional interfaces through baseElement(), so decorating an
element to record its id no longer masks Clearable, KeepTogether,
HeightSettable, or the column layoutable check — previously wrapping any
element with an id silently disabled CSS clear, page-break-inside:
avoid, flex cross-axis stretch, and column layout on it. This also fixes
the same latent masking in the pre-existing bookmark wrapper.

Inline targets (<span id>, the <a id>/<a name> idiom) remain out of
scope; only block-level elements register a destination.
@kuadrosx
kuadrosx force-pushed the fix/internal-anchor-destinations branch from 1b17d01 to b408cca Compare July 12, 2026 02:14
@kuadrosx
kuadrosx marked this pull request as ready for review July 12, 2026 02:29
@kuadrosx
kuadrosx requested a review from carlos7ags as a code owner July 12, 2026 02:29
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