Files
kyleandClaude Opus 5.5 aafc1d1c28 P5a review: outlines from named destinations; pool race; fast over-limit; revalidation under the limiter; Accept pdf
- pdftext/outline: resolve named destinations through the catalog /Dests and the /Names /Dests
  tree, resolve indirect /Dest, /A and /Title, and treat reader.Null (what Dict.Get returns for
  a missing key) as absent. Before this every LaTeX and Acrobat PDF had an empty outline.
  pdftest gains NamedDests; TestOutlineNamedDestinations.
- api: pool() always goes through its Once (data race on concurrent first use);
  TestDefaultPoolConcurrentFirstUse. Revalidation of a stale cache entry counts against the
  caller/host limit.
- pdftext/runner: output over MaxText kills the child at once instead of waiting for the
  deadline; TestRunnerTooMuchTextFailsFast.
- fetch: Accept includes application/pdf;q=0.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 04:48:17 -07:00

217 lines
5.8 KiB
Go

// Package pdftext extracts text, title and outline from a PDF. Extraction runs
// in a child process (see Runner) so a page that hangs or panics cannot take
// the parent down; Extract is the child's own work, usable directly in tests.
package pdftext
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"runtime/debug"
"strings"
"unicode/utf16"
"github.com/go-pdfkit/extract"
"github.com/go-pdfkit/reader"
)
// The errors Extract and Runner can return.
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")
)
// OutlineItem is one bookmark: its title, the 1-based page it points at, and
// its nesting level (1 = top level).
type OutlineItem struct {
Title string `json:"title"`
Page int `json:"page"` // 1-based
Level int `json:"level"` // 1 = top level
}
// Meta is the document-level summary written as the first output line.
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
}
// metaLine is the first output line: the document summary.
type metaLine struct {
Meta Meta `json:"meta"`
}
// pageOK and pageErr are the per-page lines. A page that extracts yields pageOK
// even when its text is empty (an image-only page); one that fails yields
// pageErr and extraction continues.
type pageOK struct {
Page int `json:"page"`
Text string `json:"text"`
}
type pageErr struct {
Page int `json:"page"`
Error string `json:"error"`
}
// 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 {
doc, err := reader.Open(pdf)
if err != nil {
if errors.Is(err, reader.ErrWrongPassword) {
return ErrEncrypted
}
return ErrUnreadable
}
if doc.PageCount() == 0 {
return ErrUnreadable
}
meta := Meta{
Pages: doc.PageCount(),
Title: infoTitle(doc),
Outline: walkOutline(doc),
}
if meta.Outline == nil {
meta.Outline = []OutlineItem{}
}
if err := writeJSONLine(w, metaLine{Meta: meta}); err != nil {
return err
}
n := doc.PageCount()
if maxPages > 0 && n > maxPages {
n = maxPages
}
for i := 1; i <= n; i++ {
func() {
defer func() {
if r := recover(); r != nil {
_ = writeJSONLine(w, pageErr{Page: i, Error: fmt.Sprintf("panic: %v", r)})
}
}()
text, err := extract.Text(doc, i)
if err != nil {
_ = writeJSONLine(w, pageErr{Page: i, Error: err.Error()})
return
}
_ = writeJSONLine(w, pageOK{Page: i, Text: text})
}()
}
return nil
}
// writeJSONLine marshals v and writes it as one JSON line.
func writeJSONLine(w io.Writer, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
if _, err := w.Write(b); err != nil {
return err
}
_, err = w.Write([]byte{'\n'})
return err
}
// infoTitle returns the document title from the trailer's /Info /Title, decoded
// as a PDF text string (rule 3), "" when absent.
func infoTitle(doc *reader.Document) string {
trailer := doc.Trailer()
if trailer == nil {
return ""
}
info, err := doc.Resolve(trailer.Get("Info"))
if err != nil || info == nil {
return ""
}
d, ok := reader.ToDict(info)
if !ok {
return ""
}
return textString(resolve(doc, d.Get("Title"))) // the title may be an indirect object
}
// textString decodes a reader.String (raw bytes) as a PDF text string per rule 3.
func textString(o reader.Object) string {
s, ok := reader.ToString(o)
if !ok {
return ""
}
return decodePDFString(s)
}
// decodePDFString follows rule 3: bytes starting FE FF are UTF-16BE (the rest is
// decoded as such); otherwise each byte is treated as a Latin-1 code point.
func decodePDFString(b []byte) string {
if len(b) >= 2 && b[0] == 0xFE && b[1] == 0xFF {
return strings.TrimSpace(string(utf16.Decode(utf16BE(b[2:]))))
}
return strings.TrimSpace(string(b))
}
// utf16BE packs big-endian byte pairs into uint16s for utf16.Decode.
func utf16BE(b []byte) []uint16 {
n := len(b) / 2
u := make([]uint16, n)
for i := 0; i < 2*n; i += 2 {
u[i/2] = uint16(b[i])<<8 | uint16(b[i+1])
}
return u
}
// 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. It runs with a soft memory
// limit, reads no environment variable, and opens no network connection.
func ChildMain(args []string, stdin io.Reader, stdout io.Writer) int {
debug.SetMemoryLimit(768 << 20)
fs := flag.NewFlagSet("pdftext", flag.ContinueOnError)
maxPages := fs.Int("max-pages", 2000, "maximum number of pages to extract")
if err := fs.Parse(args); err != nil {
return 1
}
pdf, err := io.ReadAll(io.LimitReader(stdin, 200<<20))
if err != nil {
fmt.Fprintln(os.Stderr, "pdftext: read input:", err)
return 4
}
if len(pdf) == 0 {
fmt.Fprintln(os.Stderr, "pdftext: empty input")
return 4
}
if err := Extract(pdf, *maxPages, stdout); err != nil {
switch {
case errors.Is(err, ErrEncrypted):
return 3
case errors.Is(err, ErrUnreadable):
return 4
default:
return 1
}
}
return 0
}