Files
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

156 lines
4.6 KiB
Go

package reader
import "bytes"
// inlineImageEnd finds where an inline image's data stops — the one genuinely
// ambiguous thing in a content stream, since the data is raw bytes that may
// spell EI themselves.
//
// The answers are tried in order of how much they can be trusted: an image
// with no filter says exactly how long it is through its width, height, depth
// and colour space; a declared /L is believed when an EI really does follow
// it; and failing both, every EI in the data is tried in turn and the first
// one whose data actually decodes is taken. That last step is what a scan
// alone gets wrong, because compressed bytes do spell EI by accident.
func inlineImageEnd(b []byte, start int, d Dict) (int, error) {
if n, ok := inlineImageDataLength(d); ok && start+n <= len(b) && eiFollows(b, start+n) {
return start + n, nil
}
if n, ok := ToInt(d.Get("L")); ok && n >= 0 && start+int(n) <= len(b) && eiFollows(b, start+int(n)) {
return start + int(n), nil
}
expanded := (&InlineImage{Dict: d}).Expanded()
want, haveWant := inlineSampleBytes(d)
// White-space before EI is how the keyword is meant to be written; a
// second pass allows for producers that run the data straight into it.
for _, needSpace := range []bool{true, false} {
for from := start; ; {
end, resume := nextEI(b, start, from, needSpace)
if end < 0 {
break
}
if inlineDataDecodes(expanded, b[start:end], want, haveWant) {
return end, nil
}
from = resume
}
}
return 0, &SyntaxError{start, "an inline image has no EI"}
}
// nextEI returns where the image data would end for the next EI candidate at
// or after from, and where to resume searching. It reports -1 when there is no
// candidate left.
func nextEI(b []byte, start, from int, needSpace bool) (end, resume int) {
for i := from; i+1 < len(b); i++ {
if b[i] != 'E' || b[i+1] != 'I' {
continue
}
if i+2 < len(b) && isRegular(b[i+2]) {
continue
}
if i > start && isSpace(b[i-1]) {
return i - 1, i + 2
}
if !needSpace {
return i, i + 2
}
}
return -1, len(b)
}
// eiFollows reports whether an EI keyword stands at i, after white-space.
func eiFollows(b []byte, i int) bool {
for i < len(b) && isSpace(b[i]) {
i++
}
if !bytes.HasPrefix(b[i:], []byte("EI")) {
return false
}
return i+2 >= len(b) || !isRegular(b[i+2])
}
// inlineDataDecodes reports whether a candidate stretch of bytes is really the
// whole of the image: it has to run through the declared filters without
// complaint, produce the number of samples the image says it has, and — for a
// JPEG, which no filter here can check — begin and end where a JPEG does.
func inlineDataDecodes(expanded Dict, data []byte, want int, haveWant bool) bool {
if expanded.Get("Filter").Kind() == KindNull {
return true
}
out, img, err := Decode(expanded, data, nil)
if err != nil {
return false
}
if img == "DCTDecode" {
return len(out) > 4 && out[0] == 0xFF && out[1] == 0xD8 &&
out[len(out)-2] == 0xFF && out[len(out)-1] == 0xD9
}
if img != "" {
return true
}
return !haveWant || len(out) == want
}
// inlineComponents reports how many samples one pixel of an inline image has,
// for the colour spaces that can be named inline.
func inlineComponents(d Dict) (int, bool) {
if b, ok := ToBool(d.Get("IM")); ok && b {
return 1, true
}
cs, ok := ToName(d.Get("CS"))
if !ok {
if _, isArray := ToArray(d.Get("CS")); isArray {
// An inline [/Indexed …] space has one component per sample.
return 1, true
}
return 0, false
}
switch cs {
case "G", "DeviceGray", "CalGray", "I", "Indexed":
return 1, true
case "RGB", "DeviceRGB", "CalRGB":
return 3, true
case "CMYK", "DeviceCMYK":
return 4, true
}
// A name that refers to a colour space in the page's resources: not
// something the arithmetic can settle here.
return 0, false
}
// inlineSampleBytes computes how many bytes an inline image's samples occupy
// once decoded.
func inlineSampleBytes(d Dict) (int, bool) {
w, okW := ToInt(d.Get("W"))
h, okH := ToInt(d.Get("H"))
if !okW || !okH || w <= 0 || h <= 0 {
return 0, false
}
bpc := int64(8)
if b, ok := ToInt(d.Get("BPC")); ok {
bpc = b
}
if im, ok := ToBool(d.Get("IM")); ok && im {
bpc = 1
}
if bpc <= 0 || bpc > 16 {
return 0, false
}
comps, ok := inlineComponents(d)
if !ok {
return 0, false
}
row := (w*int64(comps)*bpc + 7) / 8
return int(row * h), true
}
// inlineImageDataLength is inlineSampleBytes for an image with no filter, for
// which the samples are the data.
func inlineImageDataLength(d Dict) (int, bool) {
if d.Get("F").Kind() != KindNull || d.Get("Filter").Kind() != KindNull {
return 0, false
}
return inlineSampleBytes(d)
}