From afed466c29c98a919ae85b8733f551d02fc2718e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 3 Sep 2026 21:00:17 +0200 Subject: [PATCH] pdfkit: named destinations and GoTo links for in-document navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External URI links (AddLink) were navigable, but an in-document jump — a table of contents entry, a cross-reference — had no way to be clickable. Add Page.AddNamedDest(name, x, y) and Page.AddNamedLink(rect, dest). A named destination anchors a name at (x, y); Write collects every page's destinations, resolves each to its page, and emits the /Names /Dests name tree (names unique — first definition wins — and sorted, as a name tree requires), each mapping to [page /FitH y] so the viewer scrolls to the target and fits the page width. A named link is a /Link whose /A is a GoTo action referencing the destination name. A document with no destinations carries no catalog /Names. Tests cover the /Names //Dests //GoTo //FitH structure, the duplicate-name dedupe, and the no-destinations path; 100% coverage, go vet and gofmt clean. Co-Authored-By: Claude Opus 4.8 --- document.go | 58 +++++++++++++++++++++++++++++++++++++++++++++-- nameddest_test.go | 55 ++++++++++++++++++++++++++++++++++++++++++++ page.go | 28 ++++++++++++++++++++++- 3 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 nameddest_test.go diff --git a/document.go b/document.go index 7346637..d4a0fd7 100644 --- a/document.go +++ b/document.go @@ -9,6 +9,7 @@ import ( "crypto/sha256" "fmt" "io" + "sort" "strconv" "time" ) @@ -149,6 +150,7 @@ func (d *Document) Write(w io.Writer) error { imageRefs[x] = d.buildImage(bd, x) } + var dests []destEntry for i, p := range d.pages { content := p.finishContent() cdict := newDict() @@ -165,6 +167,9 @@ func (d *Document) Write(w io.Writer) error { node.set("Annots", d.buildLinkAnnots(bd, p.links)) } bd.put(pageRefs[i], node) + for _, dt := range p.dests { + dests = append(dests, destEntry{name: dt.name, page: pageRefs[i], y: dt.y}) + } } kids := make(pdfArray, len(pageRefs)) @@ -180,6 +185,9 @@ func (d *Document) Write(w io.Writer) error { catDict := newDict() catDict.set("Type", pdfName("Catalog")) catDict.set("Pages", pagesRef) + if names := d.buildDestNames(bd, dests); names != 0 { + catDict.set("Names", names) + } bd.put(catalog, catDict) var info objRef @@ -198,8 +206,15 @@ func (d *Document) buildLinkAnnots(bd *builder, links []linkAnnot) pdfArray { annots := make(pdfArray, len(links)) for i, ln := range links { action := newDict() - action.set("S", pdfName("URI")) - action.set("URI", pdfString(ln.uri)) + if ln.dest != "" { + // An internal jump: GoTo the named destination, resolved through the + // document's /Dests name tree. + action.set("S", pdfName("GoTo")) + action.set("D", pdfString(ln.dest)) + } else { + action.set("S", pdfName("URI")) + action.set("URI", pdfString(ln.uri)) + } a := newDict() a.set("Type", pdfName("Annot")) @@ -215,6 +230,45 @@ func (d *Document) buildLinkAnnots(bd *builder, links []linkAnnot) pdfArray { return annots } +// destEntry is a named destination resolved to its page during Write. +type destEntry struct { + name string + page objRef + y float64 +} + +// buildDestNames builds the /Names dictionary carrying the /Dests name tree that an +// internal GoTo link resolves against. Each name maps to [page /FitH y] — the viewer +// scrolls so y is at the top and the page width fits. Names are unique (first +// definition wins) and sorted, as a PDF name tree requires. Returns 0 (no object) +// when there are no destinations. +func (d *Document) buildDestNames(bd *builder, dests []destEntry) objRef { + if len(dests) == 0 { + return 0 + } + byName := make(map[string]destEntry, len(dests)) + order := make([]string, 0, len(dests)) + for _, dt := range dests { + if _, ok := byName[dt.name]; !ok { + byName[dt.name] = dt + order = append(order, dt.name) + } + } + sort.Strings(order) + + pairs := make(pdfArray, 0, 2*len(order)) + for _, name := range order { + dt := byName[name] + pairs = append(pairs, pdfString(name), pdfArray{dt.page, pdfName("FitH"), pdfReal(dt.y)}) + } + destTree := newDict() + destTree.set("Names", pairs) + + names := newDict() + names.set("Dests", bd.add(destTree)) + return bd.add(names) +} + // producer returns the effective /Producer string. func (d *Document) producer() string { if d.opts.Producer != "" { diff --git a/nameddest_test.go b/nameddest_test.go new file mode 100644 index 0000000..c62602a --- /dev/null +++ b/nameddest_test.go @@ -0,0 +1,55 @@ +// Copyright (c) the go-pdfkit authors. +// SPDX-License-Identifier: BSD-3-Clause + +package pdfkit + +import ( + "bytes" + "strings" + "testing" +) + +// AddNamedDest + AddNamedLink make an in-document jump: a /Link with a GoTo action +// referencing a name in the /Names /Dests tree, each name mapping to [page /FitH y]. +// A repeated name keeps its first definition (a name tree requires unique keys). +func TestNamedDestinationAndInternalLink(t *testing.T) { + doc := New(Options{}) + p1 := doc.AddPage(A4) + p1.AddNamedLink(Rect{X: 10, Y: 700, Width: 40, Height: 12}, "sec2") + + p2 := doc.AddPage(A4) + p2.AddNamedDest("sec2", 0, 780) + p2.AddNamedDest("sec2", 0, 100) // duplicate name: first definition (y=780) wins + p2.AddNamedDest("intro", 0, 800) + + var b bytes.Buffer + if err := doc.Write(&b); err != nil { + t.Fatal(err) + } + out := b.String() + for _, want := range []string{"/Names", "/Dests", "/GoTo", "/D", "/FitH", "/Link", "(intro)"} { + if !strings.Contains(out, want) { + t.Errorf("PDF output missing %q", want) + } + } + // (sec2) appears exactly twice — once as the link's GoTo /D and once as the (single, + // deduplicated) name-tree key — not three times, which would mean the duplicate leaked. + if n := strings.Count(out, "(sec2)"); n != 2 { + t.Errorf("(sec2) appears %d times, want 2 (the duplicate destination must be deduped)", n) + } +} + +// A document with no named destinations carries no /Names entry in its catalog. +func TestNoDestsNoNames(t *testing.T) { + doc := New(Options{}) + p := doc.AddPage(A4) + p.Rectangle(Rect{X: 1, Y: 2, Width: 3, Height: 4}) + p.Fill() + var b bytes.Buffer + if err := doc.Write(&b); err != nil { + t.Fatal(err) + } + if strings.Contains(b.String(), "/Names") { + t.Error("a document with no named destinations should not emit a catalog /Names") + } +} diff --git a/page.go b/page.go index a813fca..bae1ddb 100644 --- a/page.go +++ b/page.go @@ -40,12 +40,24 @@ type Page struct { // links are the clickable link annotations on this page, in the order added; // each becomes a /Link annotation in the page's /Annots array. links []linkAnnot + + // dests are the named destinations anchored on this page; each becomes an entry + // in the document's /Dests name tree, jumped to by an internal GoTo link. + dests []namedDest } -// linkAnnot is a clickable rectangle carrying a URI action. +// linkAnnot is a clickable rectangle. When dest is empty it opens uri (an external +// URI action); otherwise it jumps to the named destination dest (a GoTo action). type linkAnnot struct { rect Rect uri string + dest string +} + +// namedDest is a named jump target anchored at (x, y) in the page's user space. +type namedDest struct { + name string + x, y float64 } // AddLink adds a borderless clickable link over rect — in the same PDF user-space @@ -56,6 +68,20 @@ func (p *Page) AddLink(rect Rect, uri string) { p.links = append(p.links, linkAnnot{rect: rect, uri: uri}) } +// AddNamedDest anchors the named destination name at (x, y) on this page, so an +// internal link can jump to it. The point (x, y) is the top-left the viewer scrolls +// to, in the page's user space. +func (p *Page) AddNamedDest(name string, x, y float64) { + p.dests = append(p.dests, namedDest{name: name, x: x, y: y}) +} + +// AddNamedLink adds a borderless clickable link over rect that jumps to the named +// destination dest in the same document — the in-PDF counterpart of the SVG output's +// for \hyperlink. +func (p *Page) AddNamedLink(rect Rect, dest string) { + p.links = append(p.links, linkAnnot{rect: rect, dest: dest}) +} + // Width returns the page width in points. func (p *Page) Width() float64 { return p.width }