diff --git a/document.go b/document.go index 97233fa..7346637 100644 --- a/document.go +++ b/document.go @@ -161,6 +161,9 @@ func (d *Document) Write(w io.Writer) error { node.set("MediaBox", pdfArray{pdfReal(0), pdfReal(0), pdfReal(p.width), pdfReal(p.height)}) node.set("Contents", cstream) node.set("Resources", p.resources(fontRefs, imageRefs)) + if len(p.links) > 0 { + node.set("Annots", d.buildLinkAnnots(bd, p.links)) + } bd.put(pageRefs[i], node) } @@ -188,6 +191,30 @@ func (d *Document) Write(w io.Writer) error { return d.emit(w, bd, catalog, info, hasInfo) } +// buildLinkAnnots emits one /Link annotation object per link and returns the +// /Annots array referencing them. A link is a borderless rectangle whose /A is a +// URI action; /Rect is [llx lly urx ury] in the page's default user space. +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)) + + a := newDict() + a.set("Type", pdfName("Annot")) + a.set("Subtype", pdfName("Link")) + a.set("Rect", pdfArray{ + pdfReal(ln.rect.X), pdfReal(ln.rect.Y), + pdfReal(ln.rect.X + ln.rect.Width), pdfReal(ln.rect.Y + ln.rect.Height), + }) + a.set("Border", pdfArray{pdfInt(0), pdfInt(0), pdfInt(0)}) + a.set("A", action) + annots[i] = bd.add(a) + } + return annots +} + // producer returns the effective /Producer string. func (d *Document) producer() string { if d.opts.Producer != "" { diff --git a/linkannot_test.go b/linkannot_test.go new file mode 100644 index 0000000..3ca1575 --- /dev/null +++ b/linkannot_test.go @@ -0,0 +1,54 @@ +// Copyright (c) the go-pdfkit authors. +// SPDX-License-Identifier: BSD-3-Clause + +package pdfkit + +import ( + "bytes" + "strings" + "testing" +) + +// AddLink places a clickable /Link annotation with a URI action over a rectangle, +// so a typeset hyperref link becomes navigable in the PDF. A URL carrying parens +// exercises string escaping. +func TestLinkAnnotation(t *testing.T) { + doc := New(Options{}) + p := doc.AddPage(A4) + p.Rectangle(Rect{X: 10, Y: 20, Width: 30, Height: 40}) + p.Fill() + p.AddLink(Rect{X: 100, Y: 200, Width: 80, Height: 12}, "https://example.org/a(b)") + + var b bytes.Buffer + if err := doc.Write(&b); err != nil { + t.Fatal(err) + } + out := b.String() + for _, want := range []string{ + "/Annots", + "/Subtype", + "/Link", + "/Rect", + "/URI", + `(https://example.org/a\(b\))`, // the parens must be escaped inside the PDF string + } { + if !strings.Contains(out, want) { + t.Errorf("PDF output missing %q", want) + } + } +} + +// A page with no links emits no /Annots array. +func TestNoLinksNoAnnots(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(), "/Annots") { + t.Error("a page with no links should not emit an /Annots array") + } +} diff --git a/page.go b/page.go index 554379e..a813fca 100644 --- a/page.go +++ b/page.go @@ -36,6 +36,24 @@ type Page struct { // extGStates records the transparency graphics states this page uses, in // registration order; each becomes a /GS resource. extGStates []extGState + + // 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 +} + +// linkAnnot is a clickable rectangle carrying a URI action. +type linkAnnot struct { + rect Rect + uri string +} + +// AddLink adds a borderless clickable link over rect — in the same PDF user-space +// coordinates the drawing methods use — that opens uri when activated. It is how a +// typeset hyperref link (\href/\url) becomes navigable in the PDF, matching the +// the SVG output already emits. +func (p *Page) AddLink(rect Rect, uri string) { + p.links = append(p.links, linkAnnot{rect: rect, uri: uri}) } // Width returns the page width in points.