From 503afdbefd1cc23d6b30bbb430a6c2a79fd8fba5 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 3 Sep 2026 21:37:10 +0200 Subject: [PATCH] pdfkit: document outline (bookmarks) via AddOutlineItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated PDF had no outline, so a viewer showed no bookmark sidebar to navigate a document's sections. Add Document.AddOutlineItem(title, level, pageIndex): items nest by level (an item deeper than the one before becomes its child) and Write builds the /Outlines tree — each a /Title with a /Dest to [page /Fit] and the /Parent //Prev //Next //First //Last //Count links a PDF outline requires. An invalid level or an out-of-range page is ignored; a document with no items carries no catalog /Outlines. Tests cover a nested tree (section/subsection with a stack unwind back to top level), the sibling and child links, and the ignored-item and no-outline paths; 100% coverage, go vet and gofmt clean. Co-Authored-By: Claude Opus 4.8 --- document.go | 125 +++++++++++++++++++++++++++++++++++++++++++++--- outline_test.go | 60 +++++++++++++++++++++++ 2 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 outline_test.go diff --git a/document.go b/document.go index d4a0fd7..8d36d01 100644 --- a/document.go +++ b/document.go @@ -48,12 +48,33 @@ const DefaultProducer = "go-pdfkit/pdfkit" // pages with AddPage, then serialise with Write. It is not safe for concurrent // use. type Document struct { - opts Options - pages []*Page - fonts []*Font // registration order; index drives the /F name - fontIx map[*Font]int // font -> registration index - use map[*Font]*fontUse // per-document glyph usage, keyed by font - images []*imageXObject // registration order; index drives the /Im name + opts Options + pages []*Page + fonts []*Font // registration order; index drives the /F name + fontIx map[*Font]int // font -> registration index + use map[*Font]*fontUse // per-document glyph usage, keyed by font + images []*imageXObject // registration order; index drives the /Im name + outline []outlineItem // document outline (bookmarks), in document order +} + +// outlineItem is one entry in the document outline (the viewer's bookmark tree): +// its title, its nesting level (1 = top level, higher = deeper), and the page it +// jumps to. +type outlineItem struct { + title string + level int + pageIndex int +} + +// 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) { + if level < 1 || pageIndex < 0 || pageIndex >= len(d.pages) { + return + } + d.outline = append(d.outline, outlineItem{title: title, level: level, pageIndex: pageIndex}) } // New returns a new, empty Document configured by opts. @@ -188,6 +209,9 @@ func (d *Document) Write(w io.Writer) error { if names := d.buildDestNames(bd, dests); names != 0 { catDict.set("Names", names) } + if outlines := d.buildOutlines(bd, pageRefs); outlines != 0 { + catDict.set("Outlines", outlines) + } bd.put(catalog, catDict) var info objRef @@ -269,6 +293,95 @@ func (d *Document) buildDestNames(bd *builder, dests []destEntry) objRef { return bd.add(names) } +// outlineNode is the resolved tree form of the flat outline items during Write. +type outlineNode struct { + item outlineItem + ref objRef + children []*outlineNode +} + +// buildOutlines builds the /Outlines tree a viewer shows as its bookmark sidebar and +// returns its root object (0 when there are no items). Items nest by level — an item +// deeper than the one before becomes its child — and each is a /Title with a /Dest to +// [page /Fit] plus the /Parent //Prev //Next //First //Last //Count links a PDF +// outline tree requires. +func (d *Document) buildOutlines(bd *builder, pageRefs []objRef) objRef { + if len(d.outline) == 0 { + return 0 + } + var roots []*outlineNode + var stack []*outlineNode + for _, it := range d.outline { + n := &outlineNode{item: it} + for len(stack) > 0 && stack[len(stack)-1].item.level >= it.level { + stack = stack[:len(stack)-1] + } + if len(stack) == 0 { + roots = append(roots, n) + } else { + p := stack[len(stack)-1] + p.children = append(p.children, n) + } + stack = append(stack, n) + } + + root := bd.reserve() + reserveOutline(bd, roots) + + rd := newDict() + rd.set("Type", pdfName("Outlines")) + rd.set("First", roots[0].ref) + rd.set("Last", roots[len(roots)-1].ref) + rd.set("Count", pdfInt(outlineCount(roots))) + bd.put(root, rd) + + d.fillOutline(bd, pageRefs, roots, root) + return root +} + +// reserveOutline assigns an object number to every node, depth first, so sibling and +// parent references exist before the bodies are written. +func reserveOutline(bd *builder, ns []*outlineNode) { + for _, n := range ns { + n.ref = bd.reserve() + reserveOutline(bd, n.children) + } +} + +// outlineCount is the number of descendants across all levels of ns — the /Count of +// an open outline node. +func outlineCount(ns []*outlineNode) int { + c := 0 + for _, n := range ns { + c += 1 + outlineCount(n.children) + } + return c +} + +// fillOutline writes each node's item dictionary, linking its parent, siblings and +// children. +func (d *Document) fillOutline(bd *builder, pageRefs []objRef, ns []*outlineNode, parent objRef) { + for i, n := range ns { + od := newDict() + od.set("Title", pdfString(n.item.title)) + od.set("Parent", parent) + od.set("Dest", pdfArray{pageRefs[n.item.pageIndex], pdfName("Fit")}) + if i > 0 { + od.set("Prev", ns[i-1].ref) + } + if i < len(ns)-1 { + od.set("Next", ns[i+1].ref) + } + if len(n.children) > 0 { + od.set("First", n.children[0].ref) + od.set("Last", n.children[len(n.children)-1].ref) + od.set("Count", pdfInt(outlineCount(n.children))) + } + bd.put(n.ref, od) + d.fillOutline(bd, pageRefs, n.children, n.ref) + } +} + // producer returns the effective /Producer string. func (d *Document) producer() string { if d.opts.Producer != "" { diff --git a/outline_test.go b/outline_test.go new file mode 100644 index 0000000..94b290d --- /dev/null +++ b/outline_test.go @@ -0,0 +1,60 @@ +// Copyright (c) the go-pdfkit authors. +// SPDX-License-Identifier: BSD-3-Clause + +package pdfkit + +import ( + "bytes" + "strings" + "testing" +) + +// AddOutlineItem builds the /Outlines bookmark tree a viewer shows as its navigation +// sidebar. Items nest by level, each jumps to a page (/Dest [page /Fit]), and the +// tree carries the /Parent //Prev //Next //First //Last //Count links PDF requires. +func TestOutlineTree(t *testing.T) { + doc := New(Options{}) + for i := 0; i < 3; i++ { + doc.AddPage(A4) + } + doc.AddOutlineItem("Introduction", 1, 0) + doc.AddOutlineItem("Background", 2, 0) // child of Introduction + doc.AddOutlineItem("Prior work", 2, 1) // sibling of Background + doc.AddOutlineItem("Method", 1, 2) // pops back to top level (stack unwind) + doc.AddOutlineItem("", 0, 0) // invalid level: ignored + doc.AddOutlineItem("Off end", 1, 9) // page out of range: ignored + + var b bytes.Buffer + if err := doc.Write(&b); err != nil { + t.Fatal(err) + } + out := b.String() + for _, want := range []string{ + "/Outlines", "/Title", "/Parent", "/First", "/Last", "/Count", + "/Prev", "/Next", "/Dest", "/Fit", + "(Introduction)", "(Background)", "(Prior work)", "(Method)", + } { + if !strings.Contains(out, want) { + t.Errorf("PDF output missing %q", want) + } + } + // The ignored items never made it into the outline. + if strings.Contains(out, "(Off end)") { + t.Error("an out-of-range outline page must be ignored") + } +} + +// A document with no outline items carries no /Outlines entry in its catalog. +func TestNoOutlineNoEntry(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(), "/Outlines") { + t.Error("a document with no bookmarks should not emit a catalog /Outlines") + } +}