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>
This commit is contained in:
+176
@@ -0,0 +1,176 @@
|
||||
// Package extract reads a PDF page back: the text on it, with where each piece
|
||||
// of it sits, and the images it places.
|
||||
//
|
||||
// A page does not hold text. It holds instructions for drawing glyphs, and the
|
||||
// text is what those glyphs were meant to say — which the document says only
|
||||
// if it was asked to. Where it does not, this says so rather than guessing:
|
||||
// a run whose characters could not be worked out comes back marked, so that a
|
||||
// caller can tell "this page says nothing" from "this page could not be read".
|
||||
package extract
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-pdfkit/pdffont"
|
||||
"github.com/go-pdfkit/reader"
|
||||
)
|
||||
|
||||
// A Run is a piece of text drawn in one go: one font, one size, one place.
|
||||
type Run struct {
|
||||
// Text is what the run says.
|
||||
Text string
|
||||
// X and Y are where it starts, in the page's own coordinates — up from
|
||||
// the bottom left corner, in points.
|
||||
X, Y float64
|
||||
// Width is how far the pen moved drawing it, and Size how tall the text
|
||||
// is, both in points.
|
||||
Width, Size float64
|
||||
// Font is the name the page's resources give the font.
|
||||
Font string
|
||||
// Space is how wide a space would be here, which is what says whether a
|
||||
// gap between two runs is a word break or only kerning.
|
||||
Space float64
|
||||
// Invisible reports text drawn in the mode that puts no ink on the page.
|
||||
// It is how a scanner puts what it read underneath the picture it read it
|
||||
// from, so it is text worth having — and worth knowing about.
|
||||
Invisible bool
|
||||
// Unreadable reports a run some of whose codes said nothing about which
|
||||
// characters they were: the font carried no map and named its glyphs
|
||||
// after numbers. What Text holds is then only the part that could be read.
|
||||
Unreadable bool
|
||||
}
|
||||
|
||||
// Text is everything the page says, in reading order, with the lines put back
|
||||
// together.
|
||||
func Text(d *reader.Document, page int) (string, error) {
|
||||
runs, err := Runs(d, page)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return Assemble(runs), nil
|
||||
}
|
||||
|
||||
// Runs is every piece of text on the page, in the order it was drawn.
|
||||
func Runs(d *reader.Document, page int) ([]Run, error) {
|
||||
e, err := walk(d, page, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.runs, nil
|
||||
}
|
||||
|
||||
// walk reads a page's content stream once, keeping what it finds.
|
||||
//
|
||||
// A page whose content decoded only part of the way is read as far as it went:
|
||||
// 263 streams in 212 of the 1 633 real forms in the corpus cannot be decoded
|
||||
// cleanly, and half a page of text is worth more to somebody searching than
|
||||
// none of it. A page that decoded no bytes at all is a different answer and
|
||||
// stays an error — a conversion that silently produces an empty document from
|
||||
// an unreadable page is worse than one that says it could not read it, and
|
||||
// go-pdfkit/latex relies on being told.
|
||||
//
|
||||
// wantPictures says whether the caller is going to look at the pictures. A
|
||||
// page's images have nothing to do with its text, and undoing their filters is
|
||||
// the most expensive thing on the page: one arXiv figure holds 378 MB of image
|
||||
// once decompressed, so asking that page what it *said* used to cost 1 094 MB
|
||||
// and 1.3 seconds spent inflating pictures the answer throws away. Where they
|
||||
// land is still noted either way, since that costs a matrix multiply; only the
|
||||
// bytes are left alone.
|
||||
func walk(d *reader.Document, page int, wantPictures bool) (*extractor, error) {
|
||||
dec, err := d.PageContentDecoded(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dec.Data) == 0 && dec.Cause != nil {
|
||||
return nil, dec.Cause
|
||||
}
|
||||
content := dec.Data
|
||||
p, _ := d.Page(page)
|
||||
resources, _ := d.GetDict(p, "Resources")
|
||||
e := &extractor{doc: d, fonts: map[int]*pdffont.Font{}, wantPictures: wantPictures}
|
||||
e.run(content, resources, initialState(d, p), 0)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Assemble puts runs back together as lines of text: everything on one
|
||||
// baseline in the order it sits across the page, with a space wherever the
|
||||
// gap between two pieces is wide enough to be one.
|
||||
func Assemble(runs []Run) string {
|
||||
if len(runs) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := groupIntoLines(runs)
|
||||
var out strings.Builder
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
out.WriteString(joinLine(line))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// groupIntoLines gathers runs that sit on the same baseline, in the order they
|
||||
// appear down the page.
|
||||
func groupIntoLines(runs []Run) [][]Run {
|
||||
sorted := append([]Run{}, runs...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
if !sameLine(sorted[i], sorted[j]) {
|
||||
return sorted[i].Y > sorted[j].Y
|
||||
}
|
||||
return sorted[i].X < sorted[j].X
|
||||
})
|
||||
var lines [][]Run
|
||||
for _, r := range sorted {
|
||||
if n := len(lines); n > 0 && sameLine(lines[n-1][0], r) {
|
||||
lines[n-1] = append(lines[n-1], r)
|
||||
continue
|
||||
}
|
||||
lines = append(lines, []Run{r})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// sameLine reports whether two runs sit on the same baseline. A line is
|
||||
// allowed to wobble by a fraction of its own height, which is what a
|
||||
// superscript or a slightly different font does.
|
||||
func sameLine(a, b Run) bool {
|
||||
tol := a.Size * 0.3
|
||||
if tol <= 0 {
|
||||
tol = 1
|
||||
}
|
||||
d := a.Y - b.Y
|
||||
return d < tol && d > -tol
|
||||
}
|
||||
|
||||
// joinLine writes one line out, putting a space wherever the gap between two
|
||||
// pieces is wide enough to be one.
|
||||
func joinLine(line []Run) string {
|
||||
var out strings.Builder
|
||||
for i, r := range line {
|
||||
if i > 0 {
|
||||
prev := line[i-1]
|
||||
gap := r.X - (prev.X + prev.Width)
|
||||
if wantsSpace(prev, r, gap) {
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
out.WriteString(r.Text)
|
||||
}
|
||||
return strings.TrimRight(out.String(), " ")
|
||||
}
|
||||
|
||||
// wantsSpace decides whether a gap between two pieces of a line is a word
|
||||
// break. A space of its own is never doubled, and a gap of about a third of a
|
||||
// space is where kerning ends and words begin.
|
||||
func wantsSpace(prev, next Run, gap float64) bool {
|
||||
if strings.HasSuffix(prev.Text, " ") || strings.HasPrefix(next.Text, " ") {
|
||||
return false
|
||||
}
|
||||
space := prev.Space
|
||||
if space <= 0 {
|
||||
space = prev.Size * 0.25
|
||||
}
|
||||
return gap > space*0.3
|
||||
}
|
||||
Reference in New Issue
Block a user