diff --git a/README.md b/README.md index ce6e5ad..1c2c5dd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/document.go b/document.go index dfd3ded..1ccf117 100644 --- a/document.go +++ b/document.go @@ -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. @@ -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 { @@ -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) } @@ -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 != "" { diff --git a/objects.go b/objects.go index 9041637..1495764 100644 --- a/objects.go +++ b/objects.go @@ -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 diff --git a/page.go b/page.go index 6950f2b..9760078 100644 --- a/page.go +++ b/page.go @@ -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 @@ -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 } diff --git a/textannot_test.go b/textannot_test.go new file mode 100644 index 0000000..f823bca --- /dev/null +++ b/textannot_test.go @@ -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) + } +}