Files
kyleandClaude Opus 5.5 cfde35397a P5a plan: PDF documents — seven Ornith tasks with acceptance tests first
docs/plans/p5a: 01 kind+fetch limits, 02 pdftext child + runner, 03 dcache PDF tables,
04 pdfpool, 05 pdfdoc render (map/pages/grep), 06 api, 07 digest citations, metrics, CLI,
skill, unit, README. Given tests compile against a panic-only skeleton and fail there for the
intended reasons; PDFs for tests are built by internal/pdftest (given), plus two qpdf-encrypted
fixtures.

Owner changes for the run: vendor go-pdfkit/extract v0.3.0 + reader v0.6.0 (pinned via
tools.go until task 02); AGENTS.md, tools/run-plan.sh (enforces a Co-Authored-By trailer for
the model), docs/implementer-log.md, `make gate`; gofmt (whitespace) on clean.go and guard;
TestUnsupportedType uses a zip now that PDFs are supported; requestDeadline 90→210 s and
writeTimeout 120→240 s for the PDF path's worst case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:47:38 -07:00

432 lines
12 KiB
Go

package reader
import (
"bytes"
"fmt"
)
// maxPageTreeNodes bounds a page tree walk. /Count is a claim, not a fact, and
// /Kids can be made to point back at an ancestor.
const maxPageTreeNodes = 1 << 20
// inheritable lists the page attributes a node may take from an ancestor.
var inheritable = []Name{"Resources", "MediaBox", "CropBox", "Rotate"}
// A Document is a parsed PDF file: its cross-reference information, its
// trailer, and lazy access to every object in it.
type Document struct {
buf []byte
xref map[int]xrefEntry
trailer Dict
cache map[int]Object
loading map[int]bool
objStms map[int]map[int]Object
pages []Ref
password string
decrypt *decryptor
encryptNum int
encryptKnown bool
repaired bool
}
// Open parses the cross-reference information of a PDF file held in memory,
// using the empty password. A file whose tables are damaged is rebuilt by
// scanning it, so Open fails only when there is no usable document structure
// at all, or when the file is encrypted and the empty password does not open
// it — [OpenWithPassword] then does.
func Open(b []byte) (*Document, error) { return OpenWithPassword(b, "") }
// OpenWithPassword is Open with a password to try. The password is tried both
// as the user password and as the owner password, the empty one as well.
func OpenWithPassword(b []byte, password string) (*Document, error) {
d := &Document{
buf: b,
xref: map[int]xrefEntry{},
cache: map[int]Object{},
loading: map[int]bool{},
objStms: map[int]map[int]Object{},
password: password,
}
err := d.loadXref()
if err == nil {
if derr := d.setUpDecryption(password); derr != nil {
return nil, derr
}
d.cache = map[int]Object{}
cerr := error(nil)
if _, cerr = d.Catalog(); cerr == nil {
return d, nil
}
// Tables that parse but lead nowhere are worse than none: rebuild.
// What the catalogue lookup actually said is kept: "no page tree" and
// "/Root is a null" send a reader to different places.
err = fmt.Errorf("reader: the cross-reference tables do not lead to a catalogue: %w", cerr)
}
if rerr := d.repair(); rerr != nil {
// Both diagnoses matter, and the rebuild's is the one about the file
// as it really is: it has read every object header in it, where the
// tables only failed to be read. Returning the tables' error alone
// reported "no startxref" for a file that turned out to hold no
// objects at all, or an encrypted one whose catalogue could not be
// reached — a different problem with a different answer.
return nil, fmt.Errorf("%w (the cross-reference information was unusable too: %v)", rerr, err)
}
// Setting the key up again here used to be necessary, because the rebuild
// ran before anything knew the file was encrypted. repair() establishes it
// itself now, so by this point it is either known or not needed.
return d, nil
}
// Trailer returns the file's trailer dictionary, the newest value of every key
// across the chain of cross-reference sections.
func (d *Document) Trailer() Dict { return d.trailer }
// Repaired reports whether the document's structure was rebuilt by scanning
// the file rather than read from its cross-reference tables.
func (d *Document) Repaired() bool { return d.repaired }
// Resolver returns the function that turns this document's indirect references
// into objects, for [Resolve] and [Decode].
func (d *Document) Resolver() Resolver { return d.Get }
// Resolve follows indirect references until it reaches a direct object.
func (d *Document) Resolve(o Object) (Object, error) { return Resolve(o, d.Get) }
// GetDict resolves an entry of a dictionary to a dictionary.
func (d *Document) GetDict(from Dict, key Name) (Dict, bool) {
o, err := d.Resolve(from.Get(key))
if err != nil {
return nil, false
}
return ToDict(o)
}
// Get returns the object an indirect reference names, or [Null] when the file
// does not define it.
func (d *Document) Get(ref Ref) (Object, error) {
if o, ok := d.cache[ref.Num]; ok {
return o, nil
}
e, ok := d.xref[ref.Num]
if !ok || e.kind == 'f' {
return Null{}, nil
}
if d.loading[ref.Num] {
// An object stream whose own /Length or /Filter points back at an
// object inside itself would otherwise recurse forever.
return Null{}, nil
}
d.loading[ref.Num] = true
defer delete(d.loading, ref.Num)
var obj Object
var err error
if e.kind == 'o' {
obj, err = d.getFromObjectStream(ref.Num, e)
} else {
obj, err = d.getAtOffset(ref.Num, e)
}
if err != nil {
return nil, err
}
d.cache[ref.Num] = obj
return obj, nil
}
// getAtOffset reads an object written directly in the file. An offset that
// does not hold the expected object number means the tables are wrong, which
// is common enough that it triggers a rebuild rather than an error.
func (d *Document) getAtOffset(num int, e xrefEntry) (Object, error) {
if e.offset < 0 || e.offset >= int64(len(d.buf)) {
return d.retryAfterRepair(num, fmt.Errorf("reader: object %d is at offset %d, outside the file", num, e.offset))
}
got, obj, _, err := ParseIndirectObject(d.buf[e.offset:], d.Get)
if err != nil {
return d.retryAfterRepair(num, err)
}
if got.Num != num {
return d.retryAfterRepair(num, fmt.Errorf("reader: offset %d holds object %d, not %d", e.offset, got.Num, num))
}
if d.decrypt != nil {
obj = d.decrypt.decryptObject(num, got.Gen, obj)
}
return obj, nil
}
// retryAfterRepair rebuilds the tables once and looks the object up again.
func (d *Document) retryAfterRepair(num int, cause error) (Object, error) {
if d.repaired {
return Null{}, nil
}
if err := d.repair(); err != nil {
return nil, cause
}
e, ok := d.xref[num]
if !ok || e.kind != 'n' || e.offset >= int64(len(d.buf)) {
return Null{}, nil
}
got, obj, _, err := ParseIndirectObject(d.buf[e.offset:], d.Get)
if err != nil || got.Num != num {
return Null{}, nil
}
if d.decrypt != nil {
obj = d.decrypt.decryptObject(num, got.Gen, obj)
}
return obj, nil
}
// getFromObjectStream reads an object held inside a /Type /ObjStm stream.
func (d *Document) getFromObjectStream(num int, e xrefEntry) (Object, error) {
objs, err := d.objectStream(e.strmNum)
if err != nil {
return nil, err
}
if o, ok := objs[num]; ok {
return o, nil
}
// The index is a hint; a stream that does not hold the object simply
// does not define it.
return Null{}, nil
}
// objectStream parses an object stream once and keeps its contents.
func (d *Document) objectStream(num int) (map[int]Object, error) {
if objs, ok := d.objStms[num]; ok {
return objs, nil
}
objs := map[int]Object{}
d.objStms[num] = objs
o, err := d.Get(Ref{Num: num})
if err != nil {
return objs, err
}
s, ok := ToStream(o)
if !ok {
return objs, nil
}
// An object stream that stops in the middle still holds whole objects in
// the part that did decode, and losing them loses pages.
dec := d.DecodeStreamRecovering(s)
if dec.Image != "" {
return objs, nil
}
data := dec.Data
n := int(intOr(s.Dict.Get("N"), 0))
first := int(intOr(s.Dict.Get("First"), 0))
if n <= 0 || first <= 0 || first > len(data) {
return objs, nil
}
l := &lexer{buf: data[:first]}
type pair struct{ num, off int }
pairs := make([]pair, 0, n)
for i := 0; i < n; i++ {
t1, err1 := l.next()
t2, err2 := l.next()
if err1 != nil || err2 != nil || t1.kind != tokInteger || t2.kind != tokInteger {
break
}
pairs = append(pairs, pair{int(t1.i), first + int(t2.i)})
}
for _, p := range pairs {
if p.off < 0 || p.off > len(data) {
continue
}
obj, _, err := ParseObject(data[p.off:])
if err != nil {
continue
}
objs[p.num] = obj
}
return objs, nil
}
// intOr resolves nothing; it reads a direct integer or returns a default.
func intOr(o Object, def int64) int64 {
if n, ok := ToInt(o); ok {
return n
}
return def
}
// DecodeStream applies a stream's filter chain, resolving any indirect decode
// parameters against this document. A chain that cannot be run to the end is
// an error; [Document.DecodeStreamRecovering] salvages instead.
func (d *Document) DecodeStream(s *Stream) ([]byte, Name, error) {
return Decode(s.Dict, s.Raw, d.Get)
}
// DecodeStreamRecovering applies a stream's filter chain, salvaging what it can
// from a chain that cannot be run to the end and saying that it did so.
func (d *Document) DecodeStreamRecovering(s *Stream) Decoded {
return DecodeRecovering(s.Dict, s.Raw, d.Get)
}
// Catalog returns the document catalogue the trailer's /Root names.
func (d *Document) Catalog() (Dict, error) {
if d.trailer == nil {
return nil, fmt.Errorf("reader: the file has no trailer")
}
o, err := d.Resolve(d.trailer.Get("Root"))
if err != nil {
return nil, err
}
cat, ok := ToDict(o)
if !ok {
return nil, fmt.Errorf("reader: /Root is a %s, not a dictionary", o.Kind())
}
if _, ok := d.GetDict(cat, "Pages"); !ok {
return nil, fmt.Errorf("reader: the catalogue has no page tree")
}
return cat, nil
}
// PageCount reports how many pages the document has.
func (d *Document) PageCount() int { return len(d.pageRefs()) }
// PageRef returns the reference of the i'th page, counting from one.
func (d *Document) PageRef(i int) (Ref, bool) {
refs := d.pageRefs()
if i < 1 || i > len(refs) {
return Ref{}, false
}
return refs[i-1], true
}
// Page returns the i'th page's dictionary, counting from one, with the
// attributes it inherits from its ancestors filled in.
func (d *Document) Page(i int) (Dict, error) {
ref, ok := d.PageRef(i)
if !ok {
return nil, fmt.Errorf("reader: page %d is out of range (the document has %d)", i, d.PageCount())
}
o, err := d.Get(ref)
if err != nil {
return nil, err
}
page, ok := ToDict(o)
if !ok {
return nil, fmt.Errorf("reader: page %d is a %s, not a dictionary", i, o.Kind())
}
return d.withInherited(page), nil
}
// withInherited copies the page and fills in what its ancestors provide.
func (d *Document) withInherited(page Dict) Dict {
out := Dict{}
for k, v := range page {
out[k] = v
}
node := page
for depth := 0; depth < maxRefDepth; depth++ {
missing := false
for _, k := range inheritable {
if _, ok := out[k]; !ok {
missing = true
}
}
if !missing {
break
}
parent, ok := d.GetDict(node, "Parent")
if !ok {
break
}
for _, k := range inheritable {
if _, ok := out[k]; ok {
continue
}
if v, ok := parent[k]; ok {
out[k] = v
}
}
node = parent
}
return out
}
// pageRefs walks the page tree once, in document order.
func (d *Document) pageRefs() []Ref {
if d.pages != nil {
return d.pages
}
d.pages = []Ref{}
cat, err := d.Catalog()
if err != nil {
return d.pages
}
root := cat["Pages"]
seen := map[Ref]bool{}
d.walkPages(root, seen, 0)
if len(d.pages) == 0 {
// A tree that yields nothing but a catalogue that names one page node
// is still a one-page document.
d.pages = d.scanForPages()
}
return d.pages
}
// walkPages appends the leaves of a page tree node in order.
func (d *Document) walkPages(node Object, seen map[Ref]bool, depth int) {
if depth > 64 || len(d.pages) >= maxPageTreeNodes {
return
}
ref, isRef := node.(Ref)
if isRef {
if seen[ref] {
return
}
seen[ref] = true
}
o, err := d.Resolve(node)
if err != nil {
return
}
dict, ok := ToDict(o)
if !ok {
return
}
kids, hasKids := d.kidsOf(dict)
if !hasKids {
if isRef {
d.pages = append(d.pages, ref)
}
return
}
for _, kid := range kids {
d.walkPages(kid, seen, depth+1)
}
}
// kidsOf reports a node's /Kids when it is an interior node of the page tree.
func (d *Document) kidsOf(node Dict) (Array, bool) {
if t, ok := ToName(node.Get("Type")); ok && t == "Page" {
return nil, false
}
o, err := d.Resolve(node.Get("Kids"))
if err != nil {
return nil, false
}
kids, ok := ToArray(o)
return kids, ok
}
// Version reports the PDF version the header declares, "1.7" and the like.
func (d *Document) Version() string {
i := bytes.Index(d.buf, []byte("%PDF-"))
if i < 0 || i+8 > len(d.buf) {
return ""
}
return string(bytes.TrimRight(d.buf[i+5:i+8], "\r\n \t"))
}
// scanForPages is the last resort for a page tree that resolves to nothing:
// every object in the file that calls itself a page, in object-number order.
func (d *Document) scanForPages() []Ref {
var out []Ref
for _, num := range d.objectsOfType("Page") {
out = append(out, Ref{Num: num})
}
return out
}