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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,14 @@ TrueType font, a synthesised CFF2 font and a bundled OFL OpenType/CFF font.
whole `CFF`/`CFF2` table.
- **Link annotations are written here**: `AddLink`, `AddNamedDest` and
`AddNamedLink` put a clickable `/Link` with a URI or a named destination over
a rectangle. No other annotation type is, and nothing here draws an
appearance stream for one — `go-pdfkit/render` does that when reading.
a rectangle. **Text (sticky note) annotations** too, since v0.16.0:
`Page.AddTextAnnotation` puts a note with its contents, author, subject,
icon, colour and opacity on a page — go-slides carries a slide's speaker
notes in them. No other annotation type is written, and nothing here draws
an appearance stream — `go-pdfkit/render` does that when reading.
- **Bookmarks** (`AddOutlineItem`) nest by level; since v0.16.0
`AddOutlineItemWith` styles a title bold or italic and points it at a place on
the page (`[page /XYZ x y null]`) instead of fitting the whole page.
- **Encryption is not written here.** It is not missing from the organisation:
`go-pdfkit/reader` reads RC4-40 through AES-256 and writes AES-128 and
AES-256 with permissions and both passwords.
Expand Down
88 changes: 84 additions & 4 deletions document.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,42 @@ type outlineItem struct {
title string
level int
pageIndex int
opts OutlineOptions
}

// OutlineOptions refines a bookmark added with AddOutlineItemWith.
type OutlineOptions struct {
// Bold and Italic style the title in the viewer's sidebar (the item's /F
// flags).
Bold, Italic bool
// At, when not nil, is the point of the page the bookmark scrolls to — the
// top left of the view, in the page's user space — instead of fitting the
// whole page (the destination is [page /XYZ x y null]: the zoom is kept).
At *Point
}

// Point is a position in a page's user space, in points.
type Point struct{ X, Y float64 }

// AddOutlineItem appends a bookmark to the document outline: title at nesting level
// (1 = top level; a higher level nests under the most recent shallower item),
// jumping to page pageIndex (0-based). Out-of-range pages are ignored. Items build
// the /Outlines tree a PDF viewer shows as its navigation sidebar.
func (d *Document) AddOutlineItem(title string, level, pageIndex int) {
d.AddOutlineItemWith(title, level, pageIndex, OutlineOptions{})
}

// AddOutlineItemWith is AddOutlineItem with a styled title and a position on
// the page.
func (d *Document) AddOutlineItemWith(title string, level, pageIndex int, opts OutlineOptions) {
if level < 1 || pageIndex < 0 || pageIndex >= len(d.pages) {
return
}
d.outline = append(d.outline, outlineItem{title: title, level: level, pageIndex: pageIndex})
if opts.At != nil {
at := *opts.At
opts.At = &at // the caller's value may change under us
}
d.outline = append(d.outline, outlineItem{title: title, level: level, pageIndex: pageIndex, opts: opts})
}

// New returns a new, empty Document configured by opts.
Expand Down Expand Up @@ -225,8 +250,8 @@ 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))
if annots := append(d.buildLinkAnnots(bd, p.links), d.buildTextAnnots(bd, p.notes)...); len(annots) > 0 {
node.set("Annots", annots)
}
bd.put(pageRefs[i], node)
for _, dt := range p.dests {
Expand Down Expand Up @@ -410,7 +435,14 @@ func (d *Document) fillOutline(bd *builder, pageRefs []objRef, ns []*outlineNode
od := newDict()
od.set("Title", pdfTextString(n.item.title))
od.set("Parent", parent)
od.set("Dest", pdfArray{pageRefs[n.item.pageIndex], pdfName("Fit")})
if at := n.item.opts.At; at != nil {
od.set("Dest", pdfArray{pageRefs[n.item.pageIndex], pdfName("XYZ"), pdfReal(at.X), pdfReal(at.Y), pdfNull{}})
} else {
od.set("Dest", pdfArray{pageRefs[n.item.pageIndex], pdfName("Fit")})
}
if f := outlineFlags(n.item.opts); f != 0 {
od.set("F", pdfInt(f))
}
if i > 0 {
od.set("Prev", ns[i-1].ref)
}
Expand All @@ -427,6 +459,54 @@ func (d *Document) fillOutline(bd *builder, pageRefs []objRef, ns []*outlineNode
}
}

// outlineFlags is an outline item's /F: bit 1 italic, bit 2 bold (PDF 32000-1
// 12.3.3).
func outlineFlags(o OutlineOptions) int {
f := 0
if o.Italic {
f |= 1
}
if o.Bold {
f |= 2
}
return f
}

// buildTextAnnots emits one /Text annotation object per note and returns their
// references, for the page's /Annots array (PDF 32000-1 12.5.6.4).
func (d *Document) buildTextAnnots(bd *builder, notes []TextAnnotation) pdfArray {
var annots pdfArray
for _, n := range notes {
a := newDict()
a.set("Type", pdfName("Annot"))
a.set("Subtype", pdfName("Text"))
a.set("Rect", pdfArray{
pdfReal(n.Rect.X), pdfReal(n.Rect.Y),
pdfReal(n.Rect.X + n.Rect.Width), pdfReal(n.Rect.Y + n.Rect.Height),
})
a.set("Contents", pdfTextString(n.Contents))
if n.Title != "" {
a.set("T", pdfTextString(n.Title))
}
if n.Subject != "" {
a.set("Subj", pdfTextString(n.Subject))
}
name := n.Name
if name == "" {
name = "Note"
}
a.set("Name", pdfName(name))
if len(n.Color) == 3 {
a.set("C", pdfArray{pdfReal(n.Color[0]), pdfReal(n.Color[1]), pdfReal(n.Color[2])})
}
if n.Opacity > 0 && n.Opacity <= 1 {
a.set("CA", pdfReal(n.Opacity))
}
annots = append(annots, bd.add(a))
}
return annots
}

// producer returns the effective /Producer string.
func (d *Document) producer() string {
if d.opts.Producer != "" {
Expand Down
5 changes: 5 additions & 0 deletions objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ func (v pdfBool) encodePDF(b *bytes.Buffer) {
}
}

// pdfNull is the PDF null object, the bare keyword null.
type pdfNull struct{}

func (pdfNull) encodePDF(b *bytes.Buffer) { b.WriteString("null") }

// pdfReal is a PDF real (floating-point) object.
type pdfReal float64

Expand Down
37 changes: 37 additions & 0 deletions page.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ type Page struct {
// each becomes a /Link annotation in the page's /Annots array.
links []linkAnnot

// notes are the text (sticky note) annotations on this page, in the order
// added; they follow the links in /Annots.
notes []TextAnnotation

// 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
Expand Down Expand Up @@ -98,6 +102,39 @@ func (p *Page) AddNamedLink(rect Rect, dest string) {
p.links = append(p.links, linkAnnot{rect: rect, dest: dest})
}

// TextAnnotation is a text ("sticky note") annotation: an icon on the page
// that opens a pop-up with Contents, as a reviewer's comment does. It is how a
// slide's speaker notes travel in a PDF.
type TextAnnotation struct {
// Rect is where the icon sits, in the page's user space (origin at the
// bottom left). A viewer draws the icon at a size of its own.
Rect Rect
// Contents is the note's text; line feeds start new lines.
Contents string
// Title is the note's author (/T), shown in the pop-up's title bar.
Title string
// Subject is a short summary (/Subj).
Subject string
// Name is the icon, one of the standard ones — "Note" (the default),
// "Comment", "Key", "Help", "NewParagraph", "Paragraph" or "Insert".
Name string
// Color is the icon's RGB colour, three components in 0..1; nil leaves
// it to the viewer.
Color []float64
// Opacity is the icon's constant opacity (/CA), in (0, 1]; 0 leaves it
// out, meaning opaque.
Opacity float64
}

// AddTextAnnotation adds a text annotation to the page. An annotation with no
// Contents is ignored.
func (p *Page) AddTextAnnotation(a TextAnnotation) {
if a.Contents == "" {
return
}
p.notes = append(p.notes, a)
}

// Width returns the page width in points.
func (p *Page) Width() float64 { return p.width }

Expand Down
116 changes: 116 additions & 0 deletions textannot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved.
// Use of this source code is governed by a BSD-3-Clause license that can be
// found in the LICENSE file at the root of this repository.

package pdfkit

import (
"bytes"
"testing"
)

// A text annotation reads back through the independent reader with its
// contents, author, subject, icon, colour and opacity, after the page's links,
// whether the file is classic or packed into object streams.
func TestTextAnnotation(t *testing.T) {
for _, packed := range []bool{false, true} {
doc := New(Options{ObjectStreams: packed, Compress: packed})
p := doc.AddPage(A4)
p.AddLink(Rect{X: 1, Y: 2, Width: 3, Height: 4}, "https://example.org/")
p.AddTextAnnotation(TextAnnotation{
Rect: Rect{X: 0, Y: 20, Width: 20, Height: 0},
Contents: "first\n\nsecond (with) \\ parens — é",
Title: "Ada",
Subject: "Note",
Color: []float64{1, 0.92, 0.42},
Opacity: 0.25,
})
p.AddTextAnnotation(TextAnnotation{Contents: ""}) // ignored
q := doc.AddPage(A4)
q.AddTextAnnotation(TextAnnotation{Contents: "plain", Name: "Comment"})

b := writeDoc(t, doc)
r := reopenBytes(t, b)
annots := r.Page(1).V.Key("Annots")
if annots.Len() != 2 {
t.Fatalf("packed=%v: page 1 has %d annotations, want a link and a note", packed, annots.Len())
}
a := annots.Index(1)
if got := a.Key("Subtype").Name(); got != "Text" {
t.Errorf("subtype = %q", got)
}
if got := a.Key("Contents").Text(); got != "first\n\nsecond (with) \\ parens — é" {
t.Errorf("contents = %q", got)
}
if got := a.Key("T").Text(); got != "Ada" {
t.Errorf("T = %q", got)
}
if got := a.Key("Subj").Text(); got != "Note" {
t.Errorf("Subj = %q", got)
}
if got := a.Key("Name").Name(); got != "Note" {
t.Errorf("Name = %q, want the default icon", got)
}
if a.Key("C").Len() != 3 || a.Key("C").Index(1).Float64() != 0.92 {
t.Errorf("C = %v", a.Key("C"))
}
if got := a.Key("CA").Float64(); got != 0.25 {
t.Errorf("CA = %v", got)
}
if got := r.Page(2).V.Key("Annots").Index(0).Key("Name").Name(); got != "Comment" {
t.Errorf("page 2 icon = %q", got)
}
if bytes.Contains(b, []byte("/CA 0")) && !bytes.Contains(b, []byte("/CA 0.25")) {
t.Error("opacity written wrongly")
}
}
}

// A page with no annotation has no /Annots; one with only notes has one.
func TestTextAnnotationOnly(t *testing.T) {
doc := New(Options{})
doc.AddPage(A4)
p := doc.AddPage(A4)
p.AddTextAnnotation(TextAnnotation{Contents: "x"})
r := reopenBytes(t, writeDoc(t, doc))
if r.Page(1).V.Key("Annots").Len() != 0 {
t.Error("page 1 should have no annotations")
}
if r.Page(2).V.Key("Annots").Len() != 1 {
t.Error("page 2 should have its note")
}
}

// AddOutlineItemWith writes a bold, italic title and a position: the
// destination is [page /XYZ x y null], which keeps the viewer's zoom.
func TestOutlineOptions(t *testing.T) {
doc := New(Options{})
doc.AddPage(A4)
at := &Point{X: 72, Y: 700}
doc.AddOutlineItemWith("Page 1", 1, 0, OutlineOptions{Bold: true})
doc.AddOutlineItemWith("Heading", 2, 0, OutlineOptions{Italic: true, At: at})
at.Y = 1 // the item took a copy
doc.AddOutlineItem("Plain", 1, 0)

b := writeDoc(t, doc)
for _, want := range []string{"/F 2", "/F 1", "/XYZ 72 700 null", "/Fit"} {
if !bytes.Contains(b, []byte(want)) {
t.Errorf("output lacks %q", want)
}
}
r := reopenBytes(t, b)
first := r.Trailer().Key("Root").Key("Outlines").Key("First")
if got := first.Key("Title").Text(); got != "Page 1" {
t.Fatalf("first title = %q", got)
}
if got := first.Key("F").Int64(); got != 2 {
t.Errorf("F = %d, want bold", got)
}
child := first.Key("First")
if got := child.Key("Title").Text(); got != "Heading" {
t.Errorf("child title = %q", got)
}
if d := child.Key("Dest"); d.Index(1).Name() != "XYZ" || d.Index(2).Float64() != 72 || d.Index(3).Float64() != 700 {
t.Errorf("Dest = %v", d)
}
}
Loading