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>
153 lines
7.9 KiB
Markdown
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.
|