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

304 lines
8.1 KiB
Go

package reader
import (
"bytes"
"fmt"
)
// An xrefEntry says where object N lives.
type xrefEntry struct {
kind byte // 'n' at a byte offset, 'o' inside an object stream, 'f' free
offset int64 // kind 'n'
gen int // kind 'n'
strmNum int // kind 'o': the object stream's own number
strmIdx int // kind 'o': the index within it
}
// maxXrefSections bounds a /Prev chain, which a file is free to make a cycle.
const maxXrefSections = 1024
// startxrefWindow is how far back from the end the startxref keyword is looked
// for. The specification allows only a short tail; real files sometimes append
// junk, so the window is generous.
const startxrefWindow = 4096
// findStartxref reads the offset the file's last startxref names.
func (d *Document) findStartxref() (int64, error) {
from := len(d.buf) - startxrefWindow
if from < 0 {
from = 0
}
i := bytes.LastIndex(d.buf[from:], []byte("startxref"))
if i < 0 {
return 0, fmt.Errorf("reader: no startxref in the last %d bytes", startxrefWindow)
}
l := &lexer{buf: d.buf, pos: from + i + len("startxref")}
t, err := l.next()
if err != nil {
return 0, err
}
if t.kind != tokInteger || t.i < 0 || t.i >= int64(len(d.buf)) {
return 0, &SyntaxError{t.pos, "startxref does not name an offset inside the file"}
}
return t.i, nil
}
// loadXref follows the chain of cross-reference sections from the last one
// back through /Prev, taking the newest definition of every object and of
// every trailer entry.
func (d *Document) loadXref() error {
off, err := d.findStartxref()
if err != nil {
return err
}
seen := map[int64]bool{}
for n := 0; off > 0 && n < maxXrefSections; n++ {
if seen[off] {
return fmt.Errorf("reader: cross-reference sections form a cycle at offset %d", off)
}
seen[off] = true
tr, err := d.readXrefSection(off)
if err != nil {
return err
}
d.mergeTrailer(tr)
// A hybrid file keeps a second, stream-shaped section for the objects
// its table deliberately hides from old readers.
if x, ok := ToInt(tr.Get("XRefStm")); ok && x > 0 && !seen[x] {
seen[x] = true
if hy, err := d.readXrefSection(x); err == nil {
d.mergeTrailer(hy)
}
}
p, ok := ToInt(tr.Get("Prev"))
if !ok || p <= 0 {
break
}
off = p
}
if len(d.xref) == 0 {
return fmt.Errorf("reader: the cross-reference table is empty")
}
return nil
}
// mergeTrailer keeps the first value seen for each key, sections being read
// newest first.
func (d *Document) mergeTrailer(tr Dict) {
if d.trailer == nil {
d.trailer = Dict{}
}
for k, v := range tr {
if _, ok := d.trailer[k]; !ok {
d.trailer[k] = v
}
}
}
// setEntry records an entry unless a newer section already defined the object.
func (d *Document) setEntry(num int, e xrefEntry) {
if num < 0 {
return
}
if _, ok := d.xref[num]; !ok {
d.xref[num] = e
}
}
// readXrefSection reads whichever of the two forms is at off and returns its
// trailer dictionary.
func (d *Document) readXrefSection(off int64) (Dict, error) {
if off < 0 || off >= int64(len(d.buf)) {
return nil, fmt.Errorf("reader: cross-reference offset %d is outside the file", off)
}
l := &lexer{buf: d.buf, pos: int(off)}
t, err := l.next()
if err != nil {
return nil, err
}
if t.kind == tokKeyword && string(t.text) == "xref" {
return d.readXrefTable(l)
}
_, obj, _, err := ParseIndirectObject(d.buf[off:], nil)
if err != nil {
return nil, err
}
s, ok := ToStream(obj)
if !ok {
return nil, &SyntaxError{int(off), "neither an xref table nor an xref stream"}
}
return d.readXrefStream(s)
}
// readXrefTable reads the classic subsection form, the xref keyword already
// consumed, up to and including its trailer dictionary.
func (d *Document) readXrefTable(l *lexer) (Dict, error) {
p := &parser{lex: *l}
for {
save := p.lex.pos
t, err := p.lex.next()
if err != nil {
return nil, err
}
if t.kind == tokKeyword && string(t.text) == "trailer" {
o, err := p.parseObject()
if err != nil {
return nil, err
}
tr, ok := ToDict(o)
if !ok {
return nil, &SyntaxError{save, "the trailer is not a dictionary"}
}
return tr, nil
}
if t.kind != tokInteger {
return nil, &SyntaxError{t.pos, "a cross-reference subsection header was expected"}
}
start := int(t.i)
count, err := p.expectUint("a subsection entry count")
if err != nil {
return nil, err
}
for i := 0; i < count; i++ {
if err := d.readXrefTableEntry(p, start+i); err != nil {
return nil, err
}
}
}
}
// readXrefTableEntry reads one "offset generation n|f" line.
func (d *Document) readXrefTableEntry(p *parser, num int) error {
t1, err := p.lex.next()
if err != nil {
return err
}
if t1.kind != tokInteger {
return &SyntaxError{t1.pos, "a cross-reference entry offset was expected"}
}
t2, err := p.lex.next()
if err != nil {
return err
}
if t2.kind != tokInteger {
return &SyntaxError{t2.pos, "a cross-reference entry generation was expected"}
}
t3, err := p.lex.next()
if err != nil {
return err
}
if t3.kind != tokKeyword || len(t3.text) != 1 || (t3.text[0] != 'n' && t3.text[0] != 'f') {
return &SyntaxError{t3.pos, "a cross-reference entry must end in n or f"}
}
if t3.text[0] == 'n' {
d.setEntry(num, xrefEntry{kind: 'n', offset: t1.i, gen: int(t2.i)})
return nil
}
d.setEntry(num, xrefEntry{kind: 'f'})
return nil
}
// readXrefStream reads the /Type /XRef stream form. Its own data is never
// encrypted, so it is decoded without the document's decryptor.
func (d *Document) readXrefStream(s *Stream) (Dict, error) {
data, img, err := Decode(s.Dict, s.Raw, nil)
if err != nil {
return nil, err
}
if img != "" {
return nil, fmt.Errorf("reader: the cross-reference stream is filtered as an image")
}
widths, err := xrefWidths(s.Dict)
if err != nil {
return nil, err
}
index, err := xrefIndex(s.Dict)
if err != nil {
return nil, err
}
row := widths[0] + widths[1] + widths[2]
if row == 0 {
return nil, fmt.Errorf("reader: the cross-reference stream's /W entries are all zero")
}
pos := 0
for i := 0; i+1 < len(index); i += 2 {
for k := 0; k < index[i+1]; k++ {
if pos+row > len(data) {
// A section that claims more rows than it carries still
// contributes the rows it does carry.
return s.Dict, nil
}
f1 := xrefField(data[pos:], widths[0], 1)
f2 := xrefField(data[pos+widths[0]:], widths[1], 0)
f3 := xrefField(data[pos+widths[0]+widths[1]:], widths[2], 0)
pos += row
d.storeXrefStreamEntry(index[i]+k, f1, f2, f3)
}
}
return s.Dict, nil
}
// storeXrefStreamEntry turns one decoded row into an entry.
func (d *Document) storeXrefStreamEntry(num int, f1, f2, f3 int64) {
switch f1 {
case 1:
d.setEntry(num, xrefEntry{kind: 'n', offset: f2, gen: int(f3)})
case 2:
d.setEntry(num, xrefEntry{kind: 'o', strmNum: int(f2), strmIdx: int(f3)})
default:
d.setEntry(num, xrefEntry{kind: 'f'})
}
}
// xrefWidths reads /W, which says how many bytes each of the three fields
// occupies.
func xrefWidths(d Dict) ([3]int, error) {
var w [3]int
arr, ok := ToArray(d.Get("W"))
if !ok || len(arr) < 3 {
return w, fmt.Errorf("reader: the cross-reference stream has no usable /W")
}
for i := 0; i < 3; i++ {
n, ok := ToInt(arr[i])
if !ok || n < 0 || n > 8 {
return w, fmt.Errorf("reader: /W[%d] is not a byte width", i)
}
w[i] = int(n)
}
return w, nil
}
// xrefIndex reads /Index, defaulting to the whole range /Size describes.
func xrefIndex(d Dict) ([]int, error) {
arr, ok := ToArray(d.Get("Index"))
if !ok {
size, ok := ToInt(d.Get("Size"))
if !ok || size < 0 {
return nil, fmt.Errorf("reader: the cross-reference stream has neither /Index nor /Size")
}
return []int{0, int(size)}, nil
}
out := make([]int, 0, len(arr))
for _, e := range arr {
n, ok := ToInt(e)
if !ok || n < 0 {
return nil, fmt.Errorf("reader: /Index holds a value that is not a count")
}
out = append(out, int(n))
}
return out, nil
}
// xrefField reads one big-endian field of the given width, or its default when
// the width is zero.
func xrefField(b []byte, width int, def int64) int64 {
if width == 0 {
return def
}
v := int64(0)
for i := 0; i < width; i++ {
v = v<<8 | int64(b[i])
}
return v
}