Files
sift/internal/pdftext/outline.go
T
kyleandornith-1.5-35b-a3b f57aaef59e pdftext: extract PDF text in a re-exec'd child (sift pdftext) with a deadline
The parser is pure Go but a goroutine cannot be killed, so extraction runs in
a child process: the binary re-executed as `sift pdftext`, reading the PDF on
stdin and writing JSON Lines on stdout, killed at a deadline by the parent.

pdftext.go holds the types, the five errors, Extract (reader.Open with
ErrWrongPassword -> ErrEncrypted, per-page recover, trailer /Info /Title, and
rule-3 UTF-16BE/Latin-1 text decode) and ChildMain (soft 768 MiB memory limit,
-no env read, no network, exit codes 0/3/4/1). outline.go walks /Outlines ->
/First -> /Next with /First children at level+1, resolving /Dest and /GoTo /A
dests to 1-based pages, bounded at 10,000 items and never visiting an object
twice. runner.go is the Runner: env-isolated child (nil env is an empty
environment), 60 s deadline, 64 MiB output cap, 200 MiB stdin cap, stderr
capped at 4 KiB, exit-code to error mapping, and Doc parsing with ErrNoText.
cmd/sift/main.go adds the unlisted `pdftext` case. tools.go is removed; the
go-pdfkit modules stay vendored.

Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
2026-09-25 22:58:05 -07:00

143 lines
3.6 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, err := doc.Resolve(catDict.Get("Outlines"))
if err != nil || outlines == nil {
return out
}
outlinesDict, ok := reader.ToDict(outlines)
if !ok {
return out
}
first, ok := toRef(outlinesDict.Get("First"))
if !ok {
return out
}
seen := map[reader.Ref]bool{}
walkChildren(doc, first, 1, seen, &out)
return out
}
// 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 walkChildren(doc *reader.Document, 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
obj, err := doc.Resolve(cur)
if err != nil || obj == nil {
break
}
d, ok := reader.ToDict(obj)
if !ok {
break
}
if page, ok := destPage(doc, d); ok {
*out = append(*out, OutlineItem{Title: textString(d.Get("Title")), Page: page, Level: level})
if len(*out) >= maxOutlineItems {
return
}
}
if child, ok := toRef(d.Get("First")); ok {
walkChildren(doc, 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. It reports
// ok only when the destination is an array whose first element is a page
// reference that maps to a real page; a named or otherwise unresolvable
// destination is not ok, so the caller skips the item.
func destPage(doc *reader.Document, d reader.Dict) (int, bool) {
if ref, ok := destRef(doc, d); ok {
if page, ok := pageOf(doc, ref); ok {
return page, true
}
}
return 0, false
}
// destRef finds the destination page reference, from /Dest or from a /GoTo
// action's /D.
func destRef(doc *reader.Document, d reader.Dict) (reader.Ref, bool) {
if o := d.Get("Dest"); o != nil {
if arr, ok := reader.ToArray(o); ok && len(arr) > 0 {
if ref, ok := arr[0].(reader.Ref); ok {
return ref, true
}
}
}
if o := d.Get("A"); o != nil {
if a, ok := reader.ToDict(o); ok {
if s, ok := reader.ToName(a.Get("S")); ok && s == "GoTo" {
if d2 := a.Get("D"); d2 != nil {
if arr, ok := reader.ToArray(d2); ok && len(arr) > 0 {
if ref, ok := arr[0].(reader.Ref); ok {
return ref, true
}
}
}
}
}
}
return reader.Ref{}, false
}
// pageOf maps a reference to its 1-based page by comparing it with each page's
// reference.
func pageOf(doc *reader.Document, ref reader.Ref) (int, bool) {
for i := 1; i <= doc.PageCount(); i++ {
pr, ok := doc.PageRef(i)
if ok && pr == ref {
return i, true
}
}
return 0, false
}
// 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
}