- 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>
218 lines
5.9 KiB
Go
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
|
|
}
|