From 8a209fbad2ed15474f6f051fbafd4ef14c72befb Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 3 Sep 2026 18:20:25 +0200 Subject: [PATCH] pdfkit: add Page.AddLink for clickable /Link URI annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated PDF could draw a hyperref link's text but not make it navigable — there was no annotation API — so \href/\url rendered as plain text in the PDF while the SVG output already carried a live . Add Page.AddLink(rect, uri): it records a borderless clickable rectangle whose /A is a URI action, and Write emits one /Link annotation per link in the page's /Annots array (/Rect is [llx lly urx ury] in default user space, matching the coordinates the drawing methods use). A page with no links emits no /Annots. Tests cover the emitted /Annots //Link //URI structure and paren escaping in the URL, plus the no-links path; 100% coverage, go vet and gofmt clean. Co-Authored-By: Claude Opus 4.8 --- document.go | 27 ++++++++++++++++++++++++ linkannot_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++ page.go | 18 ++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 linkannot_test.go 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.