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

274 lines
7.2 KiB
Go

package extract
import (
"strings"
"github.com/go-pdfkit/pdffont"
"github.com/go-pdfkit/reader"
)
// maxFormDepth is how deeply one form may draw another before a page is taken
// to be drawing itself.
const maxFormDepth = 12
// An extractor walks a content stream keeping just enough state to say where
// each piece of text was drawn.
type extractor struct {
doc *reader.Document
runs []Run
fonts map[int]*pdffont.Font
// tm is where the next glyph goes and tlm where the line began; both
// belong to the page rather than to the graphics state.
tm, tlm matrix
images []Image
// wantPictures says whether anybody is going to read Image.Data. When
// nobody is, the filters are left undone.
wantPictures bool
}
// run walks a content stream.
func (e *extractor) run(content []byte, resources reader.Dict, g state, depth int) {
if depth > maxFormDepth {
return
}
// The operations are taken one at a time rather than all at once. The
// walk only ever goes forwards, so nothing needs the list; keeping it
// costs a struct and a slice header for every operator on the page, and
// pages are not always small. One arXiv figure holds a single page of
// 84.8 MB of content stream and 4 579 973 operations: holding them cost
// 2 166 MB where reading them one by one costs 935 MB.
scan := reader.NewContentScanner(content)
var stack []state
for {
op, more := scan.Next()
if !more {
break
}
n := numbers(op.Operands)
switch op.Operator {
case "q":
stack = append(stack, g)
case "Q":
if len(stack) > 0 {
g = stack[len(stack)-1]
stack = stack[:len(stack)-1]
}
case "cm":
if len(n) >= 6 {
g.ctm = matrix{n[0], n[1], n[2], n[3], n[4], n[5]}.mul(g.ctm)
}
case "BT":
e.tm, e.tlm = identity, identity
case "ET":
e.tm, e.tlm = identity, identity
case "Tf":
e.setFont(&g, op.Operands, n, resources)
case "Tc":
if len(n) >= 1 {
g.text.charSpace = n[0]
}
case "Tw":
if len(n) >= 1 {
g.text.wordSpace = n[0]
}
case "Tz":
if len(n) >= 1 {
g.text.scale = n[0] / 100
}
case "TL":
if len(n) >= 1 {
g.text.leading = n[0]
}
case "Ts":
if len(n) >= 1 {
g.text.rise = n[0]
}
case "Tr":
if len(n) >= 1 {
g.text.mode = int(n[0])
}
case "Td":
if len(n) >= 2 {
e.tlm = matrix{1, 0, 0, 1, n[0], n[1]}.mul(e.tlm)
e.tm = e.tlm
}
case "TD":
if len(n) >= 2 {
g.text.leading = -n[1]
e.tlm = matrix{1, 0, 0, 1, n[0], n[1]}.mul(e.tlm)
e.tm = e.tlm
}
case "Tm":
if len(n) >= 6 {
e.tlm = matrix{n[0], n[1], n[2], n[3], n[4], n[5]}
e.tm = e.tlm
}
case "T*":
e.nextLine(&g)
case "Tj":
e.showOperands(&g, op.Operands)
case "'":
e.nextLine(&g)
e.showOperands(&g, op.Operands)
case "\"":
if len(n) >= 2 {
g.text.wordSpace, g.text.charSpace = n[0], n[1]
}
e.nextLine(&g)
e.showOperands(&g, op.Operands)
case "TJ":
e.showArray(&g, op.Operands)
case "Do":
e.doXObject(&g, op.Operands, resources, depth)
case "BI":
e.inlineImage(&g, op.Image)
}
}
}
// setFont reads the font a Tf operator names.
func (e *extractor) setFont(g *state, operands []reader.Object, n []float64, resources reader.Dict) {
if len(operands) < 2 {
return
}
name, ok := reader.ToName(operands[0])
if !ok {
return
}
if len(n) >= 1 {
g.text.size = n[len(n)-1]
}
g.text.fontName = string(name)
g.text.font = e.font(name, resources)
}
// font reads a font out of the page's resources, remembering the ones already
// read so that a page setting the same font a thousand times reads it once.
func (e *extractor) font(name reader.Name, resources reader.Dict) *pdffont.Font {
fonts, ok := e.doc.GetDict(resources, "Font")
if !ok {
return nil
}
if ref, ok := fonts.Get(name).(reader.Ref); ok {
if f, seen := e.fonts[ref.Num]; seen {
return f
}
dict, ok := e.doc.GetDict(fonts, name)
if !ok {
return nil
}
f := pdffont.Read(e.doc, dict)
attachProgram(f)
e.fonts[ref.Num] = f
return f
}
dict, ok := e.doc.GetDict(fonts, name)
if !ok {
return nil
}
f := pdffont.Read(e.doc, dict)
attachProgram(f)
return f
}
// nextLine moves down by the leading, which is what T* and the quote
// operators do.
func (e *extractor) nextLine(g *state) {
e.tlm = matrix{1, 0, 0, 1, 0, -g.text.leading}.mul(e.tlm)
e.tm = e.tlm
}
// showOperands shows the string a Tj-like operator carries.
func (e *extractor) showOperands(g *state, operands []reader.Object) {
for _, o := range operands {
if s, ok := reader.ToString(o); ok {
e.show(g, s)
return
}
}
}
// showArray shows the pieces a TJ operator carries, moving the pen by the
// numbers between them.
func (e *extractor) showArray(g *state, operands []reader.Object) {
for _, o := range operands {
arr, ok := reader.ToArray(o)
if !ok {
continue
}
for _, item := range arr {
if s, ok := reader.ToString(item); ok {
e.show(g, s)
continue
}
if v, ok := reader.ToFloat(item); ok {
e.advance(g, -v/1000*g.text.size*g.text.scale)
}
}
}
}
// show reads one string as text and records where it was drawn.
func (e *extractor) show(g *state, s []byte) {
f := g.text.font
if f == nil || len(s) == 0 {
return
}
start := e.tm.mul(g.ctm)
x, y := start.apply(0, g.text.rise)
var text strings.Builder
unreadable := false
width := 0.0
for _, code := range f.Codes(s) {
if t, ok := f.Text(code); ok {
text.WriteString(pdffont.TrimText(t))
} else {
unreadable = true
}
adv := (f.Width(code)*g.text.size + g.text.charSpace) * g.text.scale
if f.Kind() != pdffont.Composite && code == ' ' {
adv += g.text.wordSpace * g.text.scale
}
width += adv
}
e.advance(g, width)
if text.Len() == 0 && !unreadable {
return
}
// How far a length in text space reaches on the page: the text matrix and
// the page's transform together, which is the same matrix the run's own
// position came through.
//
// Only the page's transform used to be counted here, and the text matrix
// was left out. A document is free to put the whole of its scale there —
// "/F1 1 Tf" and then a text matrix of ten is how TeX writes every PDF it
// has ever produced — and such a document reported its text as one point
// tall with letters half a point wide, while saying quite correctly where
// on the page each run began.
//
// Those are the two numbers a word break is decided by. A gap measured in
// points was being weighed against a space width ten times too small, so
// every ordinary kern between two letters looked like a space: "Original
// Domain" came back as "Or ig inal D omain". Across four thousand arXiv
// figures, 57% of the words a reader got back were one or two letters
// long. Nothing failed, and nothing was slow; the text was simply no
// longer text.
scale := start.scale()
e.runs = append(e.runs, Run{
Text: text.String(),
X: x,
Y: y,
Width: width * scale,
Size: g.text.size * scale,
Font: g.text.fontName,
Space: f.Width(' ') * g.text.size * g.text.scale * scale,
Invisible: g.text.mode == 3,
Unreadable: unreadable,
})
}
// advance moves the pen along the line by a distance in text space.
func (e *extractor) advance(g *state, by float64) {
e.tm = matrix{1, 0, 0, 1, by, 0}.mul(e.tm)
}