Files
sift/docs/plans/p5a/02-pdftext.md
T
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

153 lines
7.9 KiB
Markdown

# 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` (one `case "pdftext":` in the command switch), `docs/implementer-log.md`
- Delete: `tools.go` (it only pinned the two modules until this package imports them)
## Interfaces
```go
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
1. **Extract.**
- Open with `reader.Open(pdf)`. `reader.ErrWrongPassword` (from `errors.Is`) → `ErrEncrypted`.
Any other open error → `ErrUnreadable`. `PageCount() == 0` → `ErrUnreadable`.
- Each page: `extract.Text(doc, n)` with `n` counting from 1, inside a `recover()`; an error
or a panic becomes that page's `"error"` line.
- Title: the trailer's `/Info` dictionary, key `Title`, as a text string (rule 3).
- The meta line comes first and carries the full page count even when `maxPages` is smaller.
2. **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 `/A` when it is a dictionary with `/S /GoTo` and a `/D` array. Map the reference to a
1-based page number by comparing it with `doc.PageRef(i)` for `i` = 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).
3. **Text strings** (`/Title` in Info and in outline items): a `reader.String` is raw bytes.
Bytes starting `FE FF` are UTF-16BE (decode the rest with `unicode/utf16`); otherwise treat
each byte as a Latin-1 code point (close enough to PDFDocEncoding for titles). Trim spaces.
4. **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.
5. **Runner.Run.**
- `exec.CommandContext` with a context that ends at `Deadline` (or `ctx`, whichever is sooner);
`cmd.Env = r.Env` exactly (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 than `MaxText` → kill the child and
return `ErrTooMuchText`. Keep at most 4 KiB of stderr, for the error message only.
- Always `Wait` for the child, so it is reaped before `Run` returns.
- 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 → `ErrUnreadable` wrapped with the first line of stderr.
- Parse the lines into a `Doc`. If every extracted page's text is blank after
`strings.TrimSpace` (or no page was extracted), return `ErrNoText`.
6. **`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: `TestMain` becomes the child when
`SIFT_PDFTEXT_CHILD=1` is in its environment. The `SIFT_PDFTEXT_SLEEP` and
`SIFT_PDFTEXT_PIDFILE` variables 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 types
`reader.Dict`, `reader.Array`, `reader.Ref`, `reader.String`, `reader.Name`. Read their
definitions under `vendor/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.** Row `p5a/02-pdftext`; commit.
```sh
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
after `Run` returns); `make gate` ok; given files byte-identical; `go.mod` untouched.
## Stop and report if
- Removing `tools.go` changes `go.mod`, `go.sum` or `vendor/modules.txt` (it must not: the
package now imports both modules), or a vendored package is missing.