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>
340 lines
9.7 KiB
Go
340 lines
9.7 KiB
Go
package reader
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"fmt"
|
|
"slices"
|
|
)
|
|
|
|
// repair rebuilds the cross-reference information by reading the file itself
|
|
// rather than what it says about itself: every "N G obj" header in order, the
|
|
// later definition of an object number winning, plus whatever trailer or
|
|
// catalogue can be found. Damaged files are the normal case for a reader, not
|
|
// an exceptional one.
|
|
func (d *Document) repair() error {
|
|
d.repaired = true
|
|
d.cache = map[int]Object{}
|
|
d.objStms = map[int]map[int]Object{}
|
|
d.pages = nil
|
|
d.xref = map[int]xrefEntry{}
|
|
|
|
for _, h := range scanObjectHeaders(d.buf) {
|
|
// A later definition wins, so the entry is overwritten unconditionally
|
|
// rather than through setEntry.
|
|
d.xref[h.num] = xrefEntry{kind: 'n', offset: int64(h.offset), gen: h.gen}
|
|
}
|
|
if len(d.xref) == 0 {
|
|
return fmt.Errorf("reader: the file holds no indirect objects")
|
|
}
|
|
// A rebuild reads object streams, and in an encrypted file it cannot read
|
|
// one until the file key is known. The trailers a scan can see are where
|
|
// /Encrypt is named, so the key is established before the streams are
|
|
// indexed rather than after: otherwise a file whose catalogue lives in an
|
|
// encrypted object stream is rebuilt from bytes nobody can read, and the
|
|
// rebuild fails for a reason that has nothing to do with the file.
|
|
if err := d.establishDecryption(); err != nil {
|
|
return err
|
|
}
|
|
// Objects held in object streams are invisible to a header scan; take them
|
|
// from every object stream the scan did find.
|
|
d.indexObjectStreams()
|
|
d.loadRepairedTrailer()
|
|
if d.trailer == nil {
|
|
return fmt.Errorf("reader: no document catalogue found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// establishDecryption derives the file key from a trailer the scan can see, for
|
|
// a rebuild that runs before the cross-reference tables could name /Encrypt.
|
|
// A file that is not encrypted, or whose key is already known, needs nothing.
|
|
func (d *Document) establishDecryption() error {
|
|
if d.decrypt != nil || !bytes.Contains(d.buf, []byte("/Encrypt")) {
|
|
// /Encrypt can only be named by a trailer, and no trailer is
|
|
// compressed, so a file that does not hold those bytes anywhere is not
|
|
// encrypted. Saying so cheaply keeps the scan below off the common path.
|
|
return nil
|
|
}
|
|
previous := d.trailer
|
|
for _, tr := range d.trailerCandidates() {
|
|
if tr == nil || tr.Get("Encrypt").Kind() == KindNull {
|
|
continue
|
|
}
|
|
d.trailer = tr
|
|
err := d.setUpDecryption(d.password)
|
|
d.trailer = previous
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Whatever was read while the key was unknown was read wrong.
|
|
d.cache = map[int]Object{}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// trailerCandidates lists every dictionary that could carry trailer entries:
|
|
// what the tables managed to read, then the file's trailer keywords, then the
|
|
// dictionary of each cross-reference stream.
|
|
//
|
|
// The last of those is not optional. A file written with cross-reference
|
|
// streams has no trailer keyword anywhere in it, and /Encrypt then exists only
|
|
// in a /Type /XRef stream's dictionary — which is the one stream a PDF never
|
|
// encrypts, precisely so that it can be read before the key is known.
|
|
//
|
|
// The cross-reference streams come highest object number first, an incremental
|
|
// update having appended both its new objects and its new table to the file.
|
|
func (d *Document) trailerCandidates() []Dict {
|
|
out := append([]Dict{d.trailer}, scanTrailers(d.buf)...)
|
|
|
|
nums := make([]int, 0, len(d.xref))
|
|
for num := range d.xref {
|
|
nums = append(nums, num)
|
|
}
|
|
slices.Sort(nums)
|
|
var streams []Dict
|
|
for _, num := range nums {
|
|
// An error here leaves a nil object, which is not a stream; the
|
|
// rebuild has already been told about anything unreadable.
|
|
o, _ := d.Get(Ref{Num: num})
|
|
s, ok := ToStream(o)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if t, ok := ToName(s.Dict.Get("Type")); ok && t == "XRef" {
|
|
streams = append(streams, s.Dict)
|
|
}
|
|
}
|
|
slices.Reverse(streams)
|
|
return append(out, streams...)
|
|
}
|
|
|
|
// loadRepairedTrailer finds a trailer that leads to a catalogue: the file's own
|
|
// trailer dictionaries first, newest first; then any object that calls itself a
|
|
// catalogue; and failing both, a catalogue built over whatever pages survive.
|
|
func (d *Document) loadRepairedTrailer() {
|
|
previous := d.trailer
|
|
for _, tr := range scanTrailers(d.buf) {
|
|
d.trailer = tr
|
|
if _, err := d.Catalog(); err == nil {
|
|
return
|
|
}
|
|
}
|
|
if previous != nil {
|
|
d.trailer = previous
|
|
if _, err := d.Catalog(); err == nil {
|
|
return
|
|
}
|
|
}
|
|
d.trailer = nil
|
|
for _, num := range d.objectsOfType("Catalog") {
|
|
d.trailer = Dict{"Root": Ref{Num: num}}
|
|
if _, err := d.Catalog(); err == nil {
|
|
return
|
|
}
|
|
d.trailer = nil
|
|
}
|
|
d.synthesiseCatalogue()
|
|
}
|
|
|
|
// objectsOfType lists, in object-number order, the objects whose /Type is the
|
|
// given name.
|
|
func (d *Document) objectsOfType(want Name) []int {
|
|
// The objects that exist are walked, in order, rather than every number
|
|
// up to the largest of them. A file may name any object number it likes:
|
|
// a 219-byte one in the wild declares object 2147483647, and counting up
|
|
// to that is two thousand million map lookups for three objects — which
|
|
// is a quarter of a minute of somebody's afternoon for a file that fits
|
|
// in a tweet.
|
|
nums := make([]int, 0, len(d.xref))
|
|
for num := range d.xref {
|
|
nums = append(nums, num)
|
|
}
|
|
slices.Sort(nums)
|
|
var out []int
|
|
for _, num := range nums {
|
|
o, err := d.Get(Ref{Num: num})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
dict, ok := ToDict(o)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if t, ok := ToName(dict.Get("Type")); ok && t == want {
|
|
out = append(out, num)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// synthesiseCatalogue is the last resort for a file whose catalogue and page
|
|
// tree did not survive: build one over whatever objects still call themselves
|
|
// pages. A truncated file that kept its pages is worth opening; one that kept
|
|
// none is not a document.
|
|
func (d *Document) synthesiseCatalogue() {
|
|
var kids Array
|
|
for _, num := range d.objectsOfType("Page") {
|
|
kids = append(kids, Ref{Num: num})
|
|
}
|
|
if len(kids) == 0 {
|
|
return
|
|
}
|
|
d.trailer = Dict{"Root": Dict{
|
|
"Type": Name("Catalog"),
|
|
"Pages": Dict{
|
|
"Type": Name("Pages"),
|
|
"Kids": kids,
|
|
"Count": Integer(len(kids)),
|
|
},
|
|
}}
|
|
}
|
|
|
|
// indexObjectStreams adds the objects held inside every object stream the scan
|
|
// found, without overwriting an object written directly in the file.
|
|
func (d *Document) indexObjectStreams() {
|
|
// Two object streams in a damaged file may both claim the same object
|
|
// number — a partial rewrite leaves the old one in place beside the new —
|
|
// and the loop below lets the first stream walked define it. Walking
|
|
// d.xref in map order therefore made the same file read differently from
|
|
// one run to the next.
|
|
//
|
|
// They are walked latest in the file first, which is the rule the header
|
|
// scan above already applies to objects written directly: a later
|
|
// definition wins, an incremental update having appended it.
|
|
nums := make([]int, 0, len(d.xref))
|
|
for num := range d.xref {
|
|
nums = append(nums, num)
|
|
}
|
|
slices.Sort(nums)
|
|
type located struct {
|
|
offset int64
|
|
num int
|
|
}
|
|
var found []located
|
|
for _, num := range nums {
|
|
o, err := d.Get(Ref{Num: num})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s, ok := ToStream(o)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if t, ok := ToName(s.Dict.Get("Type")); ok && t == "ObjStm" {
|
|
found = append(found, located{d.xref[num].offset, num})
|
|
}
|
|
}
|
|
slices.SortFunc(found, func(a, b located) int { return cmp.Compare(b.offset, a.offset) })
|
|
streams := make([]int, 0, len(found))
|
|
for _, f := range found {
|
|
streams = append(streams, f.num)
|
|
}
|
|
for _, num := range streams {
|
|
// The stream object is already cached by the pass above, so this
|
|
// cannot fail; an empty result simply contributes nothing.
|
|
objs, _ := d.objectStream(num)
|
|
for objNum, obj := range objs {
|
|
if _, ok := d.xref[objNum]; ok {
|
|
continue
|
|
}
|
|
d.xref[objNum] = xrefEntry{kind: 'o', strmNum: num}
|
|
d.cache[objNum] = obj
|
|
}
|
|
}
|
|
}
|
|
|
|
// An objectHeader is one "N G obj" found by scanning.
|
|
type objectHeader struct {
|
|
num, gen, offset int
|
|
}
|
|
|
|
// scanObjectHeaders finds every indirect object header in the file, in the
|
|
// order they appear.
|
|
func scanObjectHeaders(b []byte) []objectHeader {
|
|
var out []objectHeader
|
|
for i := 0; i+3 <= len(b); {
|
|
j := bytes.Index(b[i:], []byte("obj"))
|
|
if j < 0 {
|
|
return out
|
|
}
|
|
at := i + j
|
|
i = at + 3
|
|
if at+3 < len(b) && isRegular(b[at+3]) {
|
|
continue
|
|
}
|
|
h, ok := headerBefore(b, at)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out = append(out, h)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// headerBefore reads the "N G" that must precede an obj keyword at at.
|
|
func headerBefore(b []byte, at int) (objectHeader, bool) {
|
|
p := skipSpaceBack(b, at)
|
|
if p == at {
|
|
return objectHeader{}, false
|
|
}
|
|
gen, p, ok := digitsBack(b, p)
|
|
if !ok {
|
|
return objectHeader{}, false
|
|
}
|
|
q := skipSpaceBack(b, p)
|
|
if q == p {
|
|
return objectHeader{}, false
|
|
}
|
|
num, p, ok := digitsBack(b, q)
|
|
if !ok {
|
|
return objectHeader{}, false
|
|
}
|
|
return objectHeader{num: num, gen: gen, offset: p}, true
|
|
}
|
|
|
|
// skipSpaceBack walks back over white-space and returns the new position.
|
|
func skipSpaceBack(b []byte, p int) int {
|
|
for p > 0 && isSpace(b[p-1]) {
|
|
p--
|
|
}
|
|
return p
|
|
}
|
|
|
|
// digitsBack reads a decimal number that ends at p, going backwards.
|
|
func digitsBack(b []byte, p int) (int, int, bool) {
|
|
end := p
|
|
for p > 0 && b[p-1] >= '0' && b[p-1] <= '9' {
|
|
p--
|
|
}
|
|
if p == end || end-p > 10 {
|
|
return 0, p, false
|
|
}
|
|
v := 0
|
|
for _, c := range b[p:end] {
|
|
v = v*10 + int(c-'0')
|
|
}
|
|
return v, p, true
|
|
}
|
|
|
|
// scanTrailers finds the file's trailer dictionaries, newest first.
|
|
func scanTrailers(b []byte) []Dict {
|
|
var out []Dict
|
|
for i := len(b); i > 0; {
|
|
j := bytes.LastIndex(b[:i], []byte("trailer"))
|
|
if j < 0 {
|
|
break
|
|
}
|
|
i = j
|
|
o, _, err := ParseObject(b[j+len("trailer"):])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if tr, ok := ToDict(o); ok {
|
|
out = append(out, tr)
|
|
}
|
|
}
|
|
return out
|
|
}
|