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>
7.9 KiB
7.9 KiB
P5a task 02: pdftext — extract a PDF in a child process
Branch: p5a (git switch p5a; git status --short must be empty, otherwise stop)
Commit subject: pdftext: extract PDF text in a re-exec'd child (sift pdftext) with a deadline
Goal
Parsing a PDF from the internet is the riskiest thing sift will do. The parser is pure Go
(github.com/go-pdfkit/extract + github.com/go-pdfkit/reader, already in vendor/), but a
goroutine cannot be killed, so it runs in a child process: the sift binary re-executed as
sift pdftext, reading the PDF on stdin and writing JSON Lines on stdout. The parent kills it at
a deadline. This task builds both halves; nothing calls them yet (task 06 does).
Files
- Copy:
internal/pdftest/pdftest.go(test support that builds PDFs; never edit it),tests/testdata/pdf/encrypted-user.pdf,tests/testdata/pdf/encrypted-owner.pdf,tests/testdata/pdf/README.md,internal/pdftext/pdftext_test.go - Create:
internal/pdftext/pdftext.go(types, errors,Extract,ChildMain),internal/pdftext/outline.go(the outline walk),internal/pdftext/runner.go(Runner) - Modify:
cmd/sift/main.go(onecase "pdftext":in the command switch),docs/implementer-log.md - Delete:
tools.go(it only pinned the two modules until this package imports them)
Interfaces
package pdftext
type OutlineItem struct {
Title string `json:"title"`
Page int `json:"page"` // 1-based
Level int `json:"level"` // 1 = top level
}
type Meta struct {
Pages int `json:"pages"` // pages in the document (not only those extracted)
Title string `json:"title"` // /Info /Title, "" when absent
Outline []OutlineItem `json:"outline"` // never nil: [] when the document has none
}
// Doc is an extracted document: Text[i] is page i+1's text, for the pages extracted
// (len(Text) ≤ Pages). Failed lists pages whose extraction failed (their Text is "").
type Doc struct {
Meta
Text []string
Failed []int
}
var (
ErrEncrypted = errors.New("encrypted PDF (password required)")
ErrUnreadable = errors.New("unreadable PDF")
ErrNoText = errors.New("no text layer (scanned PDF?)")
ErrTimeout = errors.New("pdf extraction timed out")
ErrTooMuchText = errors.New("pdf text over the output limit")
)
// Extract writes one {"meta":{…}} line, then one {"page":n,"text":"…"} line per page from 1 up
// to min(Pages, maxPages); a page that fails yields {"page":n,"error":"…"} and extraction goes
// on. It returns ErrEncrypted when the document needs a user password, ErrUnreadable when it
// cannot be opened or has no pages, and never panics.
func Extract(pdf []byte, maxPages int, w io.Writer) error
// ChildMain is `sift pdftext`: flags (-max-pages N, default 2000), the PDF on stdin (read with a
// 200 MiB cap), Extract to stdout. Exit codes: 0 ok, 3 ErrEncrypted, 4 ErrUnreadable, 1 anything
// else.
func ChildMain(args []string, stdin io.Reader, stdout io.Writer) int
type Runner struct {
Exe string // "" = /proc/self/exe
Args []string // nil = []string{"pdftext"}; -max-pages is appended
Env []string // the child's whole environment; nil = none
Deadline time.Duration // 0 = 60 s
MaxText int64 // bytes of child output; 0 = 64 MiB
MaxPages int // 0 = 2000
}
// Run executes the child, writes pdf to its stdin, and parses its output into a Doc.
func (r *Runner) Run(ctx context.Context, pdf []byte) (*Doc, error)
Rules
- Extract.
- Open with
reader.Open(pdf).reader.ErrWrongPassword(fromerrors.Is) →ErrEncrypted. Any other open error →ErrUnreadable.PageCount() == 0→ErrUnreadable. - Each page:
extract.Text(doc, n)withncounting from 1, inside arecover(); an error or a panic becomes that page's"error"line. - Title: the trailer's
/Infodictionary, keyTitle, as a text string (rule 3). - The meta line comes first and carries the full page count even when
maxPagesis smaller.
- Open with
- Outline (
outline.go). From the trailer's/Root(the catalog) follow/Outlines→/First, then each item's/Next; an item's children start at its/First(level + 1).- An item's page comes from
/Dest(an array whose first element is a page reference), or from/Awhen it is a dictionary with/S /GoToand a/Darray. Map the reference to a 1-based page number by comparing it withdoc.PageRef(i)fori= 1…PageCount(). - Skip an item whose destination is a name or cannot be resolved; keep walking.
- Stop after 10,000 items, and never visit an object twice (a malformed outline can loop).
- An item's page comes from
- Text strings (
/Titlein Info and in outline items): areader.Stringis raw bytes. Bytes startingFE FFare UTF-16BE (decode the rest withunicode/utf16); otherwise treat each byte as a Latin-1 code point (close enough to PDFDocEncoding for titles). Trim spaces. - ChildMain runs with a soft memory limit: call
debug.SetMemoryLimit(768 << 20)first. It must not read any environment variable and must not open the network. - Runner.Run.
exec.CommandContextwith a context that ends atDeadline(orctx, whichever is sooner);cmd.Env = r.Envexactly (nil means an empty environment — the parent's never leaks in).- Write the PDF to stdin from a goroutine, then close stdin.
- Read stdout through
io.LimitReader(…, MaxText+1); more thanMaxText→ kill the child and returnErrTooMuchText. Keep at most 4 KiB of stderr, for the error message only. - Always
Waitfor the child, so it is reaped beforeRunreturns. - Deadline passed →
ErrTimeout(even if the kill produced some other error). - Exit 3 →
ErrEncrypted, exit 4 →ErrUnreadable, any other non-zero exit or unparseable output →ErrUnreadablewrapped with the first line of stderr. - Parse the lines into a
Doc. If every extracted page's text is blank afterstrings.TrimSpace(or no page was extracted), returnErrNoText.
cmd/sift/main.go:case "pdftext": os.Exit(pdftext.ChildMain(os.Args[1:], os.Stdin, os.Stdout)). It is not listed in the help text: users never run it.
Facts
- The tests run the test binary itself as the child:
TestMainbecomes the child whenSIFT_PDFTEXT_CHILD=1is in its environment. TheSIFT_PDFTEXT_SLEEPandSIFT_PDFTEXT_PIDFILEvariables exist only in that test file; production code never reads them. extract.Text(doc, 1)returns""with a nil error for an image-only page.- Reader API you need:
reader.Open,doc.PageCount(),doc.PageRef(i) (Ref, bool),doc.Trailer() Dict,doc.Resolve(o) (Object, error),Dict.Get(Name) Object, and the typesreader.Dict,reader.Array,reader.Ref,reader.String,reader.Name. Read their definitions undervendor/github.com/go-pdfkit/reader/(object.go,document.go) — that is inside this repository.
Steps
- 1.
git switch p5a; copy the given files;git rm tools.go. - 2. See them fail:
go test -count=1 ./internal/pdftext/(undefined names). - 3.
pdftext.go, 4.outline.go, 5.runner.go, 6.cmd/sift/main.go. - 7.
gofmt -w;go test -race -count=3 ./internal/pdftext/→ok. - 8.
make build && printf 'nope' | bin/sift pdftext; echo "exit $?"→exit 4. - 9.
make gate. 10. Rowp5a/02-pdftext; commit.
git add internal/pdftext internal/pdftest tests/testdata/pdf cmd/sift docs/implementer-log.md # tools.go is already staged by git rm
git commit -F .state/commit-msg.txt
Done when
- The given tests pass under
-race -count=3, including the deadline test (the child is gone afterRunreturns);make gateok; given files byte-identical;go.moduntouched.
Stop and report if
- Removing
tools.gochangesgo.mod,go.sumorvendor/modules.txt(it must not: the package now imports both modules), or a vendored package is missing.