- 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>
155 lines
5.7 KiB
Go
155 lines
5.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
|
|
// NamedDests writes the outline the way LaTeX's hyperref and Acrobat do: each item is a
|
|
// /GoTo action to a named destination (resolved through the catalog's /Names /Dests tree)
|
|
// and its /Title is an indirect object. Without it items carry a direct /Dest array.
|
|
NamedDests bool
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// With NamedDests the outline items are followed by one title object per item and then
|
|
// the name tree, so the numbers below stay fixed by position.
|
|
nameTree := outlineRoot + 1 + 2*len(d.Outline)
|
|
catalog := "<< /Type /Catalog /Pages 2 0 R"
|
|
if len(d.Outline) > 0 {
|
|
catalog += fmt.Sprintf(" /Outlines %d 0 R /PageMode /UseOutlines", outlineRoot)
|
|
if d.NamedDests {
|
|
catalog += fmt.Sprintf(" /Names << /Dests %d 0 R >>", nameTree)
|
|
}
|
|
}
|
|
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 d.NamedDests {
|
|
item = fmt.Sprintf("<< /Title %d 0 R /Parent %d 0 R /A << /S /GoTo /D (dest.%d) >>", last+1+i, outlineRoot, i)
|
|
}
|
|
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 + " >>")
|
|
}
|
|
if d.NamedDests {
|
|
var names []string
|
|
for i, o := range d.Outline {
|
|
obj(fmt.Sprintf("(%s)", esc(o.Title)))
|
|
names = append(names, fmt.Sprintf("(dest.%d) [%d 0 R /XYZ null null null]", i, pageObj(o.Page-1)))
|
|
}
|
|
obj(fmt.Sprintf("<< /Names [%s] >>", strings.Join(names, " ")))
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|