Files
sift/internal/pdftext/outline.go
T
kyleandClaude Opus 5.5 aafc1d1c28 P5a review: outlines from named destinations; pool race; fast over-limit; revalidation under the limiter; Accept pdf
- pdftext/outline: resolve named destinations through the catalog /Dests and the /Names /Dests
  tree, resolve indirect /Dest, /A and /Title, and treat reader.Null (what Dict.Get returns for
  a missing key) as absent. Before this every LaTeX and Acrobat PDF had an empty outline.
  pdftest gains NamedDests; TestOutlineNamedDestinations.
- api: pool() always goes through its Once (data race on concurrent first use);
  TestDefaultPoolConcurrentFirstUse. Revalidation of a stale cache entry counts against the
  caller/host limit.
- pdftext/runner: output over MaxText kills the child at once instead of waiting for the
  deadline; TestRunnerTooMuchTextFailsFast.
- fetch: Accept includes application/pdf;q=0.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 04:48:17 -07:00

218 lines
5.9 KiB
Go

package pdftext
import (
"github.com/go-pdfkit/reader"
)
// maxOutlineItems bounds a walk over a malformed outline that claims more
// entries than the file could hold.
const maxOutlineItems = 10000
// walkOutline returns the document's outline as a depth-first, pre-order list:
// each item before its own children. The result is never nil.
func walkOutline(doc *reader.Document) []OutlineItem {
out := []OutlineItem{}
trailer := doc.Trailer()
if trailer == nil {
return out
}
cat, err := doc.Resolve(trailer.Get("Root"))
if err != nil || cat == nil {
return out
}
catDict, ok := reader.ToDict(cat)
if !ok {
return out
}
outlines, ok := reader.ToDict(resolve(doc, catDict.Get("Outlines")))
if !ok {
return out
}
first, ok := toRef(outlines.Get("First"))
if !ok {
return out
}
o := &outliner{doc: doc, cat: catDict}
seen := map[reader.Ref]bool{}
o.walkChildren(first, 1, seen, &out)
return out
}
// outliner resolves destinations for one document. The page index and the named
// destinations are built once, on first use.
type outliner struct {
doc *reader.Document
cat reader.Dict
pages map[reader.Ref]int
names map[string]reader.Object // named destinations from /Dests and the /Names /Dests tree
}
// walkChildren walks the /Next chain from ref at the given level, emitting each
// resolvable item and recursing into its /First children (level + 1). It stops
// after maxOutlineItems items and never visits an object twice.
func (o *outliner) walkChildren(ref reader.Ref, level int, seen map[reader.Ref]bool, out *[]OutlineItem) {
cur := ref
for cur != (reader.Ref{}) {
if seen[cur] {
break
}
seen[cur] = true
d, ok := reader.ToDict(resolve(o.doc, cur))
if !ok {
break
}
if page, ok := o.destPage(d); ok {
*out = append(*out, OutlineItem{Title: textString(resolve(o.doc, d.Get("Title"))), Page: page, Level: level})
if len(*out) >= maxOutlineItems {
return
}
}
if child, ok := toRef(d.Get("First")); ok {
o.walkChildren(child, level+1, seen, out)
if len(*out) >= maxOutlineItems {
return
}
}
next, ok := toRef(d.Get("Next"))
if !ok {
break
}
cur = next
}
}
// destPage resolves an item's destination to a 1-based page number: /Dest, or a
// /GoTo action's /D; either may be an explicit array, a dictionary holding /D,
// or a name looked up in the catalog's named destinations (LaTeX's hyperref and
// Acrobat write names, so without them most papers have no outline).
func (o *outliner) destPage(d reader.Dict) (int, bool) {
dest := resolve(o.doc, d.Get("Dest"))
if dest == nil {
if a, ok := reader.ToDict(resolve(o.doc, d.Get("A"))); ok {
if s, ok := reader.ToName(a.Get("S")); ok && s == "GoTo" {
dest = resolve(o.doc, a.Get("D"))
}
}
}
return o.pageOfDest(dest, 0)
}
// pageOfDest maps a destination value to its page. depth bounds the chain
// name → dictionary → array, which a malformed file could make circular.
func (o *outliner) pageOfDest(dest reader.Object, depth int) (int, bool) {
if dest == nil || depth > 4 {
return 0, false
}
switch v := dest.(type) {
case reader.Array:
if len(v) == 0 {
return 0, false
}
ref, ok := v[0].(reader.Ref)
if !ok {
return 0, false
}
return o.pageOf(ref)
case reader.Dict:
return o.pageOfDest(resolve(o.doc, v.Get("D")), depth+1)
case reader.Name:
return o.pageOfDest(o.named(string(v)), depth+1)
case reader.String:
return o.pageOfDest(o.named(string(v)), depth+1)
}
return 0, false
}
// pageOf maps a page reference to its 1-based page number.
func (o *outliner) pageOf(ref reader.Ref) (int, bool) {
if o.pages == nil {
o.pages = make(map[reader.Ref]int, o.doc.PageCount())
for i := 1; i <= o.doc.PageCount(); i++ {
if pr, ok := o.doc.PageRef(i); ok {
o.pages[pr] = i
}
}
}
p, ok := o.pages[ref]
return p, ok
}
// named returns the destination a name points to, from the PDF 1.1 /Dests
// dictionary or the /Names /Dests name tree.
func (o *outliner) named(name string) reader.Object {
if o.names == nil {
o.names = map[string]reader.Object{}
if dests, ok := reader.ToDict(resolve(o.doc, o.cat.Get("Dests"))); ok {
for k, v := range dests {
o.names[string(k)] = v
}
}
if names, ok := reader.ToDict(resolve(o.doc, o.cat.Get("Names"))); ok {
o.nameTree(resolve(o.doc, names.Get("Dests")), map[reader.Ref]bool{}, 0)
}
}
return resolve(o.doc, o.names[name])
}
// maxNames bounds a name tree read; maxTreeDepth bounds its nesting.
const (
maxNames = 200000
maxTreeDepth = 32
)
// nameTree reads a name-tree node: leaf /Names pairs [key value …] and /Kids.
func (o *outliner) nameTree(node reader.Object, seen map[reader.Ref]bool, depth int) {
d, ok := reader.ToDict(node)
if !ok || depth > maxTreeDepth || len(o.names) >= maxNames {
return
}
if pairs, ok := reader.ToArray(resolve(o.doc, d.Get("Names"))); ok {
for i := 0; i+1 < len(pairs) && len(o.names) < maxNames; i += 2 {
if k, ok := reader.ToString(resolve(o.doc, pairs[i])); ok {
o.names[string(k)] = pairs[i+1]
}
}
}
if kids, ok := reader.ToArray(resolve(o.doc, d.Get("Kids"))); ok {
for _, k := range kids {
if ref, ok := k.(reader.Ref); ok {
if seen[ref] {
continue
}
seen[ref] = true
}
o.nameTree(resolve(o.doc, k), seen, depth+1)
}
}
}
// resolve follows a reference. A missing, null or unresolvable object is nil:
// reader's Dict.Get answers reader.Null{} for an absent key, not nil.
func resolve(doc *reader.Document, obj reader.Object) reader.Object {
if obj == nil {
return nil
}
if _, isNull := obj.(reader.Null); isNull {
return nil
}
v, err := doc.Resolve(obj)
if err != nil {
return nil
}
if _, isNull := v.(reader.Null); isNull {
return nil
}
return v
}
// toRef reports the reference a dictionary entry names, when it is a direct
// reference rather than an already-resolved object.
func toRef(o reader.Object) (reader.Ref, bool) {
ref, ok := o.(reader.Ref)
return ref, ok
}