Skip to content

Commit b408cca

Browse files
committed
html: register element ids as PDF named destinations
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.
1 parent cea1877 commit b408cca

15 files changed

Lines changed: 624 additions & 33 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Fixed
11+
12+
- **Internal anchor links now register a PDF named destination** — a block-level element with an `id` (`<h2 id="section">`, `<div id="details">`, …) now auto-registers a named destination, so `<a href="#section">` resolves to a direct `/Dest` on the target's page instead of emitting a dangling `/GoTo` string action that jumps nowhere. The HTML walker wraps any block element carrying an `id` in a `layout` anchor marker that tags its first `PlacedBlock`; the renderer surfaces these on `PageResult.Anchors`, and the document layer registers them via `AddNamedDest` during layout — no caller `doc.AddNamedDest()` needed. Resolved links use an `/XYZ` destination at the target's y-position with zoom retained (the same shape auto-bookmarks use, so a link and an outline entry to the same target behave identically); the annotation resolver and the `/Dests` writer share one destination-array builder and agree first-registration-wins on duplicate names. The anchor and bookmark markers now expose an `unwrap` so the layouter still reaches a decorated element's optional interfaces (CSS `clear`, `page-break-inside: avoid`, flex cross-axis stretch) — previously wrapping an element to record its `id` silently disabled those. Inline targets (`<span id>`, the `<a id>`/`<a name>` idiom) are out of scope: the destination is registered only for block-level elements. This restores the `layout.Anchor` behavior documented under 0.8.0 (#223), which was absent from the tree.
13+
1014
## [0.10.0] - 2026-07-10
1115

1216
Adds offline signature verification, encrypted-PDF reading, a Factur-X/ZUGFeRD invoice package, and a standalone PDF optimizer, alongside a security-hardening pass across the reader, font, and C ABI layers against malformed input. This release carries breaking changes: `font.Face` now declares its shaping accessors directly instead of through optional provider interfaces, `html` no longer fetches remote or absolute-path assets by default, and the C ABI's `folio_last_error` changes pointer-ownership semantics.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright 2026 Carlos Munoz and the Folio Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package document_test
5+
6+
import (
7+
"bytes"
8+
"testing"
9+
10+
"github.com/carlos7ags/folio/core"
11+
"github.com/carlos7ags/folio/document"
12+
"github.com/carlos7ags/folio/font"
13+
"github.com/carlos7ags/folio/layout"
14+
"github.com/carlos7ags/folio/reader"
15+
)
16+
17+
// TestNamedDestPageIndexOffsetByManualPages verifies that an anchor produced by
18+
// flow content is registered on its ABSOLUTE page index — offset past any
19+
// manually-added pages (manualPageCount + i) — so an internal link lands on the
20+
// right page in a document that mixes manual pages with laid-out flow content.
21+
func TestNamedDestPageIndexOffsetByManualPages(t *testing.T) {
22+
d := document.NewDocument(document.PageSizeLetter)
23+
d.AddPage() // manual page 0
24+
d.AddPage() // manual page 1
25+
26+
// Flow content: the anchored element becomes flow page 0 → absolute page 2.
27+
d.Add(layout.NewAnchor(layout.NewParagraph("Target section", font.Helvetica, 12), "target"))
28+
29+
var buf bytes.Buffer
30+
if _, err := d.WriteTo(&buf); err != nil {
31+
t.Fatalf("WriteTo: %v", err)
32+
}
33+
r, err := reader.Parse(buf.Bytes())
34+
if err != nil {
35+
t.Fatalf("reader.Parse: %v", err)
36+
}
37+
if got := r.PageCount(); got != 3 {
38+
t.Fatalf("page count = %d, want 3 (2 manual + 1 flow)", got)
39+
}
40+
41+
// /Dests → target → [pageRef /XYZ ...].
42+
cat := r.Catalog()
43+
destsObj, err := r.ResolveObject(cat.Get("Dests"))
44+
if err != nil {
45+
t.Fatalf("resolve /Dests: %v", err)
46+
}
47+
dests, ok := destsObj.(*core.PdfDictionary)
48+
if !ok {
49+
t.Fatalf("/Dests is %T, want *core.PdfDictionary", destsObj)
50+
}
51+
arrObj, err := r.ResolveObject(dests.Get("target"))
52+
if err != nil {
53+
t.Fatalf("resolve dest target: %v", err)
54+
}
55+
arr, ok := arrObj.(*core.PdfArray)
56+
if !ok || arr.Len() < 1 {
57+
t.Fatalf("dest target is %T/len<1, want a destination array", arrObj)
58+
}
59+
pageRef, ok := arr.At(0).(*core.PdfIndirectReference)
60+
if !ok {
61+
t.Fatalf("dest page target is %T, want *core.PdfIndirectReference", arr.At(0))
62+
}
63+
64+
if idx := pageIndexInKids(t, r, pageRef.ObjectNumber); idx != 2 {
65+
t.Errorf("anchor destination page index = %d, want 2 (offset past the 2 manual pages)", idx)
66+
}
67+
}
68+
69+
// TestNamedDestDuplicateNameFirstWins verifies that when a destination name is
70+
// registered more than once (duplicate ids, or an id colliding with a caller's
71+
// AddNamedDest), the /Dests dictionary keeps the FIRST registration — matching
72+
// the link-annotation resolver, which stops at the first match, so both point
73+
// at the same target.
74+
func TestNamedDestDuplicateNameFirstWins(t *testing.T) {
75+
d := document.NewDocument(document.PageSizeLetter)
76+
d.AddPage() // page 0
77+
d.AddPage() // page 1
78+
79+
d.AddNamedDest(document.NamedDest{Name: "dup", PageIndex: 0, FitType: "XYZ", Top: 700})
80+
d.AddNamedDest(document.NamedDest{Name: "dup", PageIndex: 1, FitType: "XYZ", Top: 700})
81+
82+
var buf bytes.Buffer
83+
if _, err := d.WriteTo(&buf); err != nil {
84+
t.Fatalf("WriteTo: %v", err)
85+
}
86+
r, err := reader.Parse(buf.Bytes())
87+
if err != nil {
88+
t.Fatalf("reader.Parse: %v", err)
89+
}
90+
91+
destsObj, err := r.ResolveObject(r.Catalog().Get("Dests"))
92+
if err != nil {
93+
t.Fatalf("resolve /Dests: %v", err)
94+
}
95+
dests, ok := destsObj.(*core.PdfDictionary)
96+
if !ok {
97+
t.Fatalf("/Dests is %T, want *core.PdfDictionary", destsObj)
98+
}
99+
arrObj, err := r.ResolveObject(dests.Get("dup"))
100+
if err != nil {
101+
t.Fatalf("resolve dest dup: %v", err)
102+
}
103+
arr, ok := arrObj.(*core.PdfArray)
104+
if !ok || arr.Len() < 1 {
105+
t.Fatalf("dest dup is %T/len<1, want a destination array", arrObj)
106+
}
107+
pageRef, ok := arr.At(0).(*core.PdfIndirectReference)
108+
if !ok {
109+
t.Fatalf("dest page target is %T, want *core.PdfIndirectReference", arr.At(0))
110+
}
111+
if idx := pageIndexInKids(t, r, pageRef.ObjectNumber); idx != 0 {
112+
t.Errorf("duplicate name resolved to page %d, want 0 (first registration must win)", idx)
113+
}
114+
}
115+
116+
// pageIndexInKids returns the position of the page object objNum within the
117+
// page tree's /Kids array.
118+
func pageIndexInKids(t *testing.T, r *reader.PdfReader, objNum int) int {
119+
t.Helper()
120+
pagesObj, err := r.ResolveObject(r.Catalog().Get("Pages"))
121+
if err != nil {
122+
t.Fatalf("resolve /Pages: %v", err)
123+
}
124+
pages, ok := pagesObj.(*core.PdfDictionary)
125+
if !ok {
126+
t.Fatalf("/Pages is %T, want *core.PdfDictionary", pagesObj)
127+
}
128+
kids, ok := pages.Get("Kids").(*core.PdfArray)
129+
if !ok {
130+
t.Fatal("/Pages has no /Kids array")
131+
}
132+
for i := 0; i < kids.Len(); i++ {
133+
if ref, ok := kids.At(i).(*core.PdfIndirectReference); ok && ref.ObjectNumber == objNum {
134+
return i
135+
}
136+
}
137+
t.Fatalf("page object %d not found in /Kids", objNum)
138+
return -1
139+
}

document/document.go

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,38 @@ func (d *Document) AddNamedDest(dest NamedDest) {
280280
d.namedDests = append(d.namedDests, dest)
281281
}
282282

283+
// buildNamedDestArray builds a PDF explicit destination array for nd, targeting
284+
// the page referenced by pageRef (ISO 32000 §12.3.2.2). The fit type selects
285+
// the syntax: "FitH" scrolls the given top edge to the window top, "XYZ"
286+
// positions the top-left corner at an explicit zoom, and anything else fits
287+
// the whole page. Shared by the link-annotation resolver and the /Dests
288+
// catalog writer so both emit the same destination for a given name.
289+
func buildNamedDestArray(nd NamedDest, pageRef *core.PdfIndirectReference) *core.PdfArray {
290+
switch nd.FitType {
291+
case "FitH":
292+
return core.NewPdfArray(
293+
pageRef,
294+
core.NewPdfName("FitH"),
295+
core.NewPdfReal(nd.Top),
296+
)
297+
case "XYZ":
298+
// Zero left/top/zoom emit null ("retain current"), matching the
299+
// outline destination writer (ISO 32000 §12.3.2.2).
300+
return core.NewPdfArray(
301+
pageRef,
302+
core.NewPdfName("XYZ"),
303+
pdfNumOrNull(nd.Left),
304+
pdfNumOrNull(nd.Top),
305+
pdfNumOrNull(nd.Zoom),
306+
)
307+
default: // "Fit"
308+
return core.NewPdfArray(
309+
pageRef,
310+
core.NewPdfName("Fit"),
311+
)
312+
}
313+
}
314+
283315
// Add appends a layout element (e.g. Paragraph) to the document.
284316
// Elements are laid out automatically with word wrapping and page breaks
285317
// when WriteTo/Save is called.
@@ -419,7 +451,7 @@ func (d *Document) buildAllPages(ctx context.Context) (all []*Page, structTags [
419451
if rerr != nil {
420452
return nil, nil, rerr
421453
}
422-
for _, res := range results {
454+
for i, res := range results {
423455
ps := d.pageSize
424456
if res.PageHeight > 0 {
425457
ps.Height = res.PageHeight
@@ -459,6 +491,20 @@ func (d *Document) buildAllPages(ctx context.Context) (all []*Page, structTags [
459491
}
460492
p.annotations = append(p.annotations, ann)
461493
}
494+
// Register fragment ids as named destinations so internal
495+
// links (<a href="#id">) resolve to their target block. Use XYZ
496+
// with zoom 0 (retain current zoom) — the same destination shape
497+
// auto-bookmarks use — so clicking a link and clicking an outline
498+
// entry to the same target behave identically instead of the link
499+
// forcing a fit-width zoom.
500+
for _, a := range res.Anchors {
501+
d.AddNamedDest(NamedDest{
502+
Name: a.Name,
503+
PageIndex: manualPageCount + i,
504+
FitType: "XYZ",
505+
Top: a.Y,
506+
})
507+
}
462508
all = append(all, p)
463509
}
464510

@@ -855,10 +901,7 @@ func (d *Document) WriteToWithContext(ctx context.Context, w io.Writer, opts Wri
855901
resolved := false
856902
for _, nd := range d.namedDests {
857903
if nd.Name == ann.dest && nd.PageIndex >= 0 && nd.PageIndex < len(pageRefs) {
858-
annotDict.Set("Dest", core.NewPdfArray(
859-
pageRefs[nd.PageIndex],
860-
core.NewPdfName("Fit"),
861-
))
904+
annotDict.Set("Dest", buildNamedDestArray(nd, pageRefs[nd.PageIndex]))
862905
resolved = true
863906
break
864907
}
@@ -928,29 +971,16 @@ func (d *Document) WriteToWithContext(ctx context.Context, w io.Writer, opts Wri
928971
if nd.PageIndex < 0 || nd.PageIndex >= len(pageRefs) {
929972
continue
930973
}
931-
var destArray *core.PdfArray
932-
switch nd.FitType {
933-
case "FitH":
934-
destArray = core.NewPdfArray(
935-
pageRefs[nd.PageIndex],
936-
core.NewPdfName("FitH"),
937-
core.NewPdfReal(nd.Top),
938-
)
939-
case "XYZ":
940-
destArray = core.NewPdfArray(
941-
pageRefs[nd.PageIndex],
942-
core.NewPdfName("XYZ"),
943-
core.NewPdfReal(nd.Left),
944-
core.NewPdfReal(nd.Top),
945-
core.NewPdfReal(nd.Zoom),
946-
)
947-
default: // "Fit"
948-
destArray = core.NewPdfArray(
949-
pageRefs[nd.PageIndex],
950-
core.NewPdfName("Fit"),
951-
)
974+
// First registration wins, matching the link-annotation resolver
975+
// (which stops at the first match) — so a name that appears twice
976+
// (duplicate ids, or an id colliding with a caller's AddNamedDest)
977+
// resolves to the same target in the /Dests dict and in every
978+
// annotation, instead of the dict last-wins disagreeing with the
979+
// annotation first-wins.
980+
if destsDict.Get(nd.Name) != nil {
981+
continue
952982
}
953-
destsDict.Set(nd.Name, destArray)
983+
destsDict.Set(nd.Name, buildNamedDestArray(nd, pageRefs[nd.PageIndex]))
954984
}
955985
destsRef := writer.AddObject(destsDict)
956986
catalog.Set("Dests", destsRef)

html/anchor_dest_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright 2026 Carlos Munoz and the Folio Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package html_test
5+
6+
import (
7+
"strings"
8+
"testing"
9+
10+
"github.com/carlos7ags/folio/core"
11+
"github.com/carlos7ags/folio/document"
12+
"github.com/carlos7ags/folio/reader"
13+
)
14+
15+
// TestInternalAnchorRegistersNamedDest is the end-to-end regression for
16+
// internal-link navigation: an element with an id must auto-register a
17+
// PDF named destination so <a href="#id"> resolves to it. Before the
18+
// fix, the id was consumed only for CSS matching, so the link emitted a
19+
// dangling /GoTo pointing at a destination that was never defined.
20+
func TestInternalAnchorRegistersNamedDest(t *testing.T) {
21+
const htmlStr = `<html><body>
22+
<a href="#section-two">Jump to section two</a>
23+
<h2 id="section-two">Second Section</h2>
24+
<p>Body text under the second section.</p>
25+
</body></html>`
26+
27+
pdf, r := htmlRoundtrip(t, htmlStr, document.PageSizeLetter)
28+
got := string(pdf)
29+
30+
// The catalog must carry a /Dests dictionary defining the id.
31+
if !strings.Contains(got, "/Dests") {
32+
t.Error("output has no /Dests dictionary — the id was not registered")
33+
}
34+
if !strings.Contains(got, "section-two") {
35+
t.Error("destination name section-two missing from output")
36+
}
37+
// The resolved link should use XYZ (jump to the section's y while
38+
// retaining the current zoom, matching auto-bookmarks), and no dangling
39+
// string GoTo action should remain for a resolvable dest.
40+
if !strings.Contains(got, "/XYZ") {
41+
t.Error("resolved destination should use /XYZ to land on the section without clobbering zoom")
42+
}
43+
if strings.Contains(got, "/GoTo") {
44+
t.Error("output still contains a /GoTo action — the internal link did not resolve to a direct /Dest")
45+
}
46+
47+
// Walk the catalog name → destination the way a viewer would, so we
48+
// prove the destination is *defined*, not merely referenced.
49+
dest := resolveNamedDest(t, r, "section-two")
50+
if dest.Len() < 2 {
51+
t.Fatalf("destination array too short: %d elements", dest.Len())
52+
}
53+
fit, ok := dest.At(1).(*core.PdfName)
54+
if !ok || fit.Value != "XYZ" {
55+
t.Errorf("destination fit = %v, want XYZ", dest.At(1))
56+
}
57+
}
58+
59+
// resolveNamedDest looks up name in the catalog's /Dests dictionary and
60+
// returns its destination array, resolving indirect references.
61+
func resolveNamedDest(t *testing.T, r *reader.PdfReader, name string) *core.PdfArray {
62+
t.Helper()
63+
cat := r.Catalog()
64+
if cat == nil {
65+
t.Fatal("no catalog in parsed PDF")
66+
}
67+
destsObj, err := r.ResolveObject(cat.Get("Dests"))
68+
if err != nil {
69+
t.Fatalf("resolve /Dests: %v", err)
70+
}
71+
dests, ok := destsObj.(*core.PdfDictionary)
72+
if !ok {
73+
t.Fatalf("/Dests is %T, want *core.PdfDictionary", destsObj)
74+
}
75+
arrObj, err := r.ResolveObject(dests.Get(name))
76+
if err != nil {
77+
t.Fatalf("resolve dest %q: %v", name, err)
78+
}
79+
arr, ok := arrObj.(*core.PdfArray)
80+
if !ok {
81+
t.Fatalf("destination %q is %T, want *core.PdfArray (name not defined)", name, arrObj)
82+
}
83+
return arr
84+
}

html/converter_dispatch.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,15 @@ func (c *converter) convertElement(n *html.Node, parentStyle computedStyle) []la
303303

304304
elems := c.convertElementInner(n, style)
305305

306+
// Register the element's id as a named-destination target so
307+
// <a href="#id"> links resolve to it. Wrapping the produced element
308+
// tags its first PlacedBlock with the anchor name; the renderer
309+
// surfaces it and the document layer registers a PDF named
310+
// destination automatically (no caller AddNamedDest needed).
311+
if id := getAttr(n, "id"); id != "" && len(elems) > 0 {
312+
elems[0] = layout.NewAnchor(elems[0], id)
313+
}
314+
306315
// Apply CSS bookmark-level on non-heading elements. Headings carry
307316
// their own bookmark metadata via convertHeading → layout.Heading;
308317
// for other elements we wrap the produced Element so its first

0 commit comments

Comments
 (0)