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>
134 lines
4.7 KiB
Go
134 lines
4.7 KiB
Go
// Package pdftest builds small, deterministic PDFs for tests: text pages in Helvetica, an optional
|
|
// outline (bookmarks), and image-only ("scanned") pages. It exists so the P5a tests need no binary
|
|
// fixtures from elsewhere. It is test support: nothing outside _test.go files imports it.
|
|
package pdftest
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Outline is one bookmark: a title that points at a 1-based page, nested at Level (1 = top).
|
|
// Build supports one level; Level is kept so tests can state what they expect back.
|
|
type Outline struct {
|
|
Title string
|
|
Page int
|
|
Level int
|
|
}
|
|
|
|
// Doc describes a PDF to build. Pages holds each page's text; a page's lines are split on "\n"
|
|
// and set one per line (long lines are not wrapped: callers keep them under ~90 characters).
|
|
// Scanned pages (1-based) carry a small image and no text at all.
|
|
type Doc struct {
|
|
Title string
|
|
Pages []string
|
|
Outline []Outline
|
|
Scanned []int
|
|
}
|
|
|
|
// Build returns the PDF bytes for d. It panics on a Doc it cannot express (an outline entry
|
|
// pointing past the last page), since that is a bug in the test that built it.
|
|
func Build(d Doc) []byte {
|
|
var b bytes.Buffer
|
|
var offsets []int
|
|
obj := func(body string) int {
|
|
offsets = append(offsets, b.Len())
|
|
n := len(offsets)
|
|
fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", n, body)
|
|
return n
|
|
}
|
|
b.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
|
|
|
// Object numbers are fixed by position: 1 catalog, 2 pages, 3 font, 4 image, 5 info, then
|
|
// two per page (page, content), then the outline.
|
|
nPages := len(d.Pages)
|
|
firstPage := 6
|
|
pageObj := func(i int) int { return firstPage + 2*i }
|
|
outlineRoot := firstPage + 2*nPages
|
|
scanned := map[int]bool{}
|
|
for _, p := range d.Scanned {
|
|
scanned[p] = true
|
|
}
|
|
|
|
catalog := "<< /Type /Catalog /Pages 2 0 R"
|
|
if len(d.Outline) > 0 {
|
|
catalog += fmt.Sprintf(" /Outlines %d 0 R /PageMode /UseOutlines", outlineRoot)
|
|
}
|
|
obj(catalog + " >>")
|
|
var kids []string
|
|
for i := range d.Pages {
|
|
kids = append(kids, fmt.Sprintf("%d 0 R", pageObj(i)))
|
|
}
|
|
obj(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), nPages))
|
|
obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
|
|
img := []byte{0x00, 0x80, 0x80, 0x00} // 2x2 grey pixels
|
|
obj(fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length %d >>\nstream\n%s\nendstream", len(img), img))
|
|
obj(fmt.Sprintf("<< /Title (%s) /Producer (sift pdftest) >>", esc(d.Title)))
|
|
|
|
for i, text := range d.Pages {
|
|
var content string
|
|
if scanned[i+1] {
|
|
content = "q 400 0 0 500 100 150 cm /Im1 Do Q"
|
|
} else {
|
|
var c strings.Builder
|
|
c.WriteString("BT /F1 11 Tf 13 TL 60 760 Td\n")
|
|
for _, line := range strings.Split(text, "\n") {
|
|
fmt.Fprintf(&c, "(%s) Tj T*\n", esc(line))
|
|
}
|
|
c.WriteString("ET")
|
|
content = c.String()
|
|
}
|
|
obj(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 4 0 R >> >> /Contents %d 0 R >>", pageObj(i)+1))
|
|
obj(fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content))
|
|
}
|
|
|
|
if len(d.Outline) > 0 {
|
|
first := outlineRoot + 1
|
|
last := outlineRoot + len(d.Outline)
|
|
obj(fmt.Sprintf("<< /Type /Outlines /First %d 0 R /Last %d 0 R /Count %d >>", first, last, len(d.Outline)))
|
|
for i, o := range d.Outline {
|
|
if o.Page < 1 || o.Page > nPages {
|
|
panic(fmt.Sprintf("pdftest: outline %q points at page %d of %d", o.Title, o.Page, nPages))
|
|
}
|
|
n := first + i
|
|
item := fmt.Sprintf("<< /Title (%s) /Parent %d 0 R /Dest [%d 0 R /XYZ null null null]", esc(o.Title), outlineRoot, pageObj(o.Page-1))
|
|
if i > 0 {
|
|
item += fmt.Sprintf(" /Prev %d 0 R", n-1)
|
|
}
|
|
if n < last {
|
|
item += fmt.Sprintf(" /Next %d 0 R", n+1)
|
|
}
|
|
obj(item + " >>")
|
|
}
|
|
}
|
|
|
|
xref := b.Len()
|
|
fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
|
|
for _, off := range offsets {
|
|
fmt.Fprintf(&b, "%010d 00000 n \n", off)
|
|
}
|
|
fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R /Info 5 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref)
|
|
return b.Bytes()
|
|
}
|
|
|
|
// Lorem returns n lines of distinct, searchable filler: "p<page> l<line> …", so a test can tell
|
|
// which page a line came from.
|
|
func Lorem(page, n int) string {
|
|
words := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima"}
|
|
var lines []string
|
|
for i := 1; i <= n; i++ {
|
|
var w []string
|
|
for j := 0; j < 10; j++ {
|
|
w = append(w, words[(page*7+i*3+j)%len(words)])
|
|
}
|
|
lines = append(lines, fmt.Sprintf("p%d l%d %s", page, i, strings.Join(w, " ")))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func esc(s string) string {
|
|
r := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`)
|
|
return r.Replace(s)
|
|
}
|