Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions document.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"crypto/sha256"
"fmt"
"io"
"sort"
"strconv"
"time"
)
Expand Down Expand Up @@ -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()
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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"))
Expand All @@ -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 != "" {
Expand Down
55 changes: 55 additions & 0 deletions nameddest_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
28 changes: 27 additions & 1 deletion page.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
// <a href="#name"> 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 }

Expand Down