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>
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user