mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -10:00
* stage 12: PDF ingestion Add PDF ingestion to internal/imageinput so specs, design docs, and tickets that arrive as PDF can be attached the same way images are. - internal/imageinput/pdf.go: LoadDocument detects PDFs by magic bytes (%PDF-, never extension alone), enforces a 32 MiB raw-file cap and a 256 KiB extracted-text cap (truncated with a marker, not rejected), and extracts the text layer in pure Go via github.com/ledongthuc/pdf. The parser can panic on malformed structures, so extraction is wrapped in a recover that turns a bad PDF into a clean error. A textless PDF with no rasterizer returns an explicit "no extractable text; OCR not available" error rather than silent empty success. - Dependency posture: default build is pure-Go text extraction (no CGO, no transitive deps) so the static binary still cross-compiles. The optional poppler tools (pdftotext / pdftoppm) are used for richer extraction and page rasterization ONLY when present on PATH (same posture as the LSP language servers); when absent, extraction degrades to the pure-Go text path and never errors. - Vision path: with a vision model and pdftoppm available, the first N pages (default 10) render to PNG and flow through the existing image pipeline (NormalizeImageMediaType, per-image cap, allow-list). Without a rasterizer it degrades to the text layer. - TUI integration: /image <file.pdf> routes to the document path. The text layer attaches for any model and is prepended to the next prompt as a labeled preamble; rendered pages attach as images for vision models. The chip row and /image clear cover documents too. Tests cover magic-byte detection, text extraction on a generated fixture, both size caps, the no-text/no-raster message, malformed-input safety, the pure-Go fallback, and the TUI attach/submit/clear wiring. go build, go test, and gofmt are clean; CGO_ENABLED=0 cross-compiles for linux/amd64, windows/arm64, and darwin/arm64. * stage 12: address review (pdf page count, text cap, content-based routing)
372 lines
13 KiB
Go
372 lines
13 KiB
Go
package imageinput
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// buildMinimalPDF assembles a tiny, single-page PDF whose content stream draws
|
|
// the given text. It computes a real cross-reference table and trailer so a
|
|
// pure-Go PDF parser (ledongthuc/pdf) accepts it. Generating the fixture in-test
|
|
// keeps the repo free of opaque binary blobs while still exercising the real
|
|
// text-extraction path on real PDF bytes.
|
|
func buildMinimalPDF(text string) []byte {
|
|
var buf bytes.Buffer
|
|
offsets := make([]int, 0, 8)
|
|
startObj := func() { offsets = append(offsets, buf.Len()) }
|
|
|
|
buf.WriteString("%PDF-1.4\n")
|
|
|
|
startObj() // object 1: catalog
|
|
buf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
|
|
|
|
startObj() // object 2: page tree
|
|
buf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
|
|
|
|
startObj() // object 3: page
|
|
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
|
|
|
|
content := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET"
|
|
startObj() // object 4: content stream
|
|
buf.WriteString("4 0 obj\n<< /Length " + strconv.Itoa(len(content)) + " >>\nstream\n")
|
|
buf.WriteString(content)
|
|
buf.WriteString("\nendstream\nendobj\n")
|
|
|
|
startObj() // object 5: font
|
|
buf.WriteString("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n")
|
|
|
|
xrefStart := buf.Len()
|
|
buf.WriteString("xref\n")
|
|
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
|
|
buf.WriteString("0000000000 65535 f \n")
|
|
for _, off := range offsets {
|
|
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
|
|
}
|
|
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
|
|
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestIsPDF(t *testing.T) {
|
|
if !isPDF(buildMinimalPDF("hi")) {
|
|
t.Fatal("isPDF should accept real %PDF- bytes")
|
|
}
|
|
if !isPDF([]byte("%PDF-1.7\n...")) {
|
|
t.Fatal("isPDF should accept a bare %PDF- magic prefix")
|
|
}
|
|
if isPDF([]byte("not a pdf at all")) {
|
|
t.Fatal("isPDF should reject non-PDF bytes")
|
|
}
|
|
if isPDF(nil) {
|
|
t.Fatal("isPDF should reject empty input")
|
|
}
|
|
if isPDF([]byte("%PDF")) {
|
|
t.Fatal("isPDF should require the trailing dash of %PDF-")
|
|
}
|
|
}
|
|
|
|
func TestLoadDocumentTextExtraction(t *testing.T) {
|
|
root := t.TempDir()
|
|
want := "Hello ZERO PDF"
|
|
if err := os.WriteFile(filepath.Join(root, "doc.pdf"), buildMinimalPDF(want), 0o644); err != nil {
|
|
t.Fatalf("write pdf: %v", err)
|
|
}
|
|
|
|
doc, err := LoadDocument("doc.pdf", root, DocumentOptions{})
|
|
if err != nil {
|
|
t.Fatalf("LoadDocument: %v", err)
|
|
}
|
|
if !strings.Contains(doc.Text, want) {
|
|
t.Fatalf("extracted text %q should contain %q", doc.Text, want)
|
|
}
|
|
if len(doc.Images) != 0 {
|
|
t.Fatalf("text-only extraction should not return images, got %d", len(doc.Images))
|
|
}
|
|
if doc.Pages != 1 {
|
|
t.Fatalf("Pages = %d, want 1", doc.Pages)
|
|
}
|
|
}
|
|
|
|
// A .pdf-named file that is not actually a PDF must be rejected with a clear
|
|
// error rather than silently treated as a document (extension is never trusted
|
|
// over magic bytes).
|
|
func TestLoadDocumentRejectsFakePDF(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "fake.pdf"), []byte("this is plainly not a PDF document"), 0o644); err != nil {
|
|
t.Fatalf("write fake: %v", err)
|
|
}
|
|
_, err := LoadDocument("fake.pdf", root, DocumentOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error for a .pdf-named non-PDF file")
|
|
}
|
|
if !strings.Contains(err.Error(), "not a PDF") {
|
|
t.Fatalf("error %q should explain the file is not a PDF", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestLoadDocumentMissing(t *testing.T) {
|
|
root := t.TempDir()
|
|
_, err := LoadDocument("nope.pdf", root, DocumentOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error for a missing file")
|
|
}
|
|
if !strings.Contains(err.Error(), "nope.pdf") {
|
|
t.Fatalf("error %q should name the path", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestLoadDocumentRejectsNonRegular(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(root, "adir.pdf"), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
_, err := LoadDocument("adir.pdf", root, DocumentOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error for a non-regular file")
|
|
}
|
|
if !strings.Contains(err.Error(), "regular file") {
|
|
t.Fatalf("error %q should mention regular file", err.Error())
|
|
}
|
|
}
|
|
|
|
// The per-document byte cap is enforced before parsing, mirroring the image cap,
|
|
// so an unbounded file never reaches the parser or a provider.
|
|
func TestLoadDocumentOversizeRejected(t *testing.T) {
|
|
root := t.TempDir()
|
|
big := make([]byte, MaxDocumentBytes+1)
|
|
copy(big, []byte("%PDF-1.4\n"))
|
|
if err := os.WriteFile(filepath.Join(root, "big.pdf"), big, 0o644); err != nil {
|
|
t.Fatalf("write big: %v", err)
|
|
}
|
|
_, err := LoadDocument("big.pdf", root, DocumentOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error for an oversize document")
|
|
}
|
|
if !strings.Contains(err.Error(), "limit") {
|
|
t.Fatalf("error %q should mention the size limit", err.Error())
|
|
}
|
|
}
|
|
|
|
// Extracted text longer than the cap is truncated (with a marker) rather than
|
|
// rejected: a large but valid spec/doc should still be partially usable.
|
|
func TestLoadDocumentTruncatesLongText(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Many short lines so the *extracted text* (not the file) exceeds the cap.
|
|
var body strings.Builder
|
|
line := "The quick brown fox jumps over the lazy dog. "
|
|
for body.Len() < MaxDocumentTextBytes+4096 {
|
|
body.WriteString(line)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "long.pdf"), buildMinimalPDF(body.String()), 0o644); err != nil {
|
|
t.Fatalf("write long: %v", err)
|
|
}
|
|
doc, err := LoadDocument("long.pdf", root, DocumentOptions{})
|
|
if err != nil {
|
|
t.Fatalf("LoadDocument: %v", err)
|
|
}
|
|
if len(doc.Text) > MaxDocumentTextBytes {
|
|
t.Fatalf("capped text length %d exceeds cap %d (marker must be counted against the cap)", len(doc.Text), MaxDocumentTextBytes)
|
|
}
|
|
if !doc.Truncated {
|
|
t.Fatal("Truncated should be set when extracted text is capped")
|
|
}
|
|
if !strings.Contains(doc.Text, documentTruncatedMarker) {
|
|
t.Fatal("truncated text should carry the truncation marker")
|
|
}
|
|
}
|
|
|
|
// A PDF with no extractable text layer and no rasterization/OCR available must
|
|
// surface the explicit "no extractable text" message, never a silent empty
|
|
// success.
|
|
func TestLoadDocumentNoTextNoRaster(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "scan.pdf"), buildEmptyTextPDF(), 0o644); err != nil {
|
|
t.Fatalf("write scan: %v", err)
|
|
}
|
|
// Force the pure-Go path with no external rasterizer so the no-text branch is
|
|
// deterministic regardless of what is installed on the test host.
|
|
_, err := LoadDocument("scan.pdf", root, DocumentOptions{disableExternalTools: true})
|
|
if err == nil {
|
|
t.Fatal("expected an error for a PDF with no extractable text and no raster")
|
|
}
|
|
if !strings.Contains(err.Error(), "no extractable text") {
|
|
t.Fatalf("error %q should explain there is no extractable text", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "OCR") {
|
|
t.Fatalf("error %q should mention OCR is unavailable", err.Error())
|
|
}
|
|
}
|
|
|
|
// buildEmptyTextPDF is a valid single-page PDF with an empty content stream:
|
|
// structurally a PDF, but with no text layer to extract.
|
|
func buildEmptyTextPDF() []byte {
|
|
var buf bytes.Buffer
|
|
offsets := make([]int, 0, 8)
|
|
startObj := func() { offsets = append(offsets, buf.Len()) }
|
|
|
|
buf.WriteString("%PDF-1.4\n")
|
|
startObj()
|
|
buf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
|
|
startObj()
|
|
buf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
|
|
startObj()
|
|
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 4 0 R >>\nendobj\n")
|
|
startObj()
|
|
buf.WriteString("4 0 obj\n<< /Length 0 >>\nstream\n\nendstream\nendobj\n")
|
|
|
|
xrefStart := buf.Len()
|
|
buf.WriteString("xref\n")
|
|
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
|
|
buf.WriteString("0000000000 65535 f \n")
|
|
for _, off := range offsets {
|
|
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
|
|
}
|
|
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
|
|
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// Malformed PDF bytes that pass the header check but break the parser must be
|
|
// turned into a clean error, never a panic that escapes the package.
|
|
func TestLoadDocumentMalformedDoesNotPanic(t *testing.T) {
|
|
root := t.TempDir()
|
|
bad := []byte("%PDF-1.4\nthis header is valid but the body and xref are garbage\nstartxref\n9\n%%EOF\n")
|
|
if err := os.WriteFile(filepath.Join(root, "bad.pdf"), bad, 0o644); err != nil {
|
|
t.Fatalf("write bad: %v", err)
|
|
}
|
|
_, err := LoadDocument("bad.pdf", root, DocumentOptions{disableExternalTools: true})
|
|
if err == nil {
|
|
t.Fatal("expected an error for malformed PDF bytes")
|
|
}
|
|
}
|
|
|
|
// When the external poppler tools are absent (or disabled), extraction falls
|
|
// back to the pure-Go text path and still succeeds; absence is never an error.
|
|
func TestLoadDocumentFallsBackToPureGo(t *testing.T) {
|
|
root := t.TempDir()
|
|
want := "Pure Go fallback text"
|
|
if err := os.WriteFile(filepath.Join(root, "doc.pdf"), buildMinimalPDF(want), 0o644); err != nil {
|
|
t.Fatalf("write pdf: %v", err)
|
|
}
|
|
doc, err := LoadDocument("doc.pdf", root, DocumentOptions{disableExternalTools: true})
|
|
if err != nil {
|
|
t.Fatalf("LoadDocument (pure-Go): %v", err)
|
|
}
|
|
if !strings.Contains(doc.Text, want) {
|
|
t.Fatalf("pure-Go text %q should contain %q", doc.Text, want)
|
|
}
|
|
}
|
|
|
|
// Vision-mode extraction without an available rasterizer must not error: it
|
|
// degrades to the text layer (a vision model can still read the text block).
|
|
func TestLoadDocumentVisionWithoutRasterizerUsesText(t *testing.T) {
|
|
root := t.TempDir()
|
|
want := "Vision degrade to text"
|
|
if err := os.WriteFile(filepath.Join(root, "doc.pdf"), buildMinimalPDF(want), 0o644); err != nil {
|
|
t.Fatalf("write pdf: %v", err)
|
|
}
|
|
doc, err := LoadDocument("doc.pdf", root, DocumentOptions{Vision: true, disableExternalTools: true})
|
|
if err != nil {
|
|
t.Fatalf("LoadDocument (vision, no raster): %v", err)
|
|
}
|
|
if len(doc.Images) != 0 {
|
|
t.Fatalf("no rasterizer available, expected 0 images, got %d", len(doc.Images))
|
|
}
|
|
if !strings.Contains(doc.Text, want) {
|
|
t.Fatalf("vision-without-raster should keep text, got %q", doc.Text)
|
|
}
|
|
}
|
|
|
|
// capDocumentText must keep the final payload (text + marker) at or under the
|
|
// advertised cap: the marker is counted against MaxDocumentTextBytes, not added
|
|
// on top of it.
|
|
func TestCapDocumentTextRespectsCap(t *testing.T) {
|
|
// Text comfortably over the cap so truncation triggers.
|
|
over := strings.Repeat("a", MaxDocumentTextBytes+1024)
|
|
got, truncated := capDocumentText(over)
|
|
if !truncated {
|
|
t.Fatal("expected truncation for over-cap text")
|
|
}
|
|
if len(got) > MaxDocumentTextBytes {
|
|
t.Fatalf("capped text length %d exceeds cap %d", len(got), MaxDocumentTextBytes)
|
|
}
|
|
if !strings.HasSuffix(got, documentTruncatedMarker) {
|
|
t.Fatal("capped text should end with the truncation marker")
|
|
}
|
|
|
|
// At-or-under the cap is returned unchanged with no marker.
|
|
under := strings.Repeat("b", MaxDocumentTextBytes)
|
|
got, truncated = capDocumentText(under)
|
|
if truncated {
|
|
t.Fatal("text exactly at the cap must not be truncated")
|
|
}
|
|
if got != under {
|
|
t.Fatal("at-cap text must be returned unchanged")
|
|
}
|
|
}
|
|
|
|
// pdfPageCount must report the real page count from PDF bytes (this is what
|
|
// backs Document.Pages on the poppler text path, where pdftotext gives no count)
|
|
// and must return 0 -- not panic -- on garbage.
|
|
func TestPDFPageCount(t *testing.T) {
|
|
if got := pdfPageCount(buildMinimalPDF("one page")); got != 1 {
|
|
t.Fatalf("pdfPageCount = %d, want 1", got)
|
|
}
|
|
if got := pdfPageCount([]byte("not a pdf at all")); got != 0 {
|
|
t.Fatalf("pdfPageCount on garbage = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// LooksLikeDocumentFile sniffs PDF content by magic bytes, so a real PDF with no
|
|
// ".pdf" extension is still recognized while a non-PDF (even named .pdf) is not.
|
|
func TestLooksLikeDocumentFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
// A real PDF named without a .pdf extension.
|
|
if err := os.WriteFile(filepath.Join(root, "spec"), buildMinimalPDF("hi"), 0o644); err != nil {
|
|
t.Fatalf("write pdf: %v", err)
|
|
}
|
|
// A non-PDF named with a .pdf extension.
|
|
if err := os.WriteFile(filepath.Join(root, "fake.pdf"), []byte("not a pdf"), 0o644); err != nil {
|
|
t.Fatalf("write fake: %v", err)
|
|
}
|
|
// A directory must not be treated as a document.
|
|
if err := os.Mkdir(filepath.Join(root, "adir"), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
if !LooksLikeDocumentFile("spec", root) {
|
|
t.Fatal("a real PDF without a .pdf extension should be recognized by content")
|
|
}
|
|
if LooksLikeDocumentFile("fake.pdf", root) {
|
|
t.Fatal("a .pdf-named non-PDF must not be recognized as a document")
|
|
}
|
|
if LooksLikeDocumentFile("nope", root) {
|
|
t.Fatal("a missing file must not be recognized as a document")
|
|
}
|
|
if LooksLikeDocumentFile("adir", root) {
|
|
t.Fatal("a directory must not be recognized as a document")
|
|
}
|
|
}
|
|
|
|
func TestIsProbablyDocumentPath(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"report.pdf": true,
|
|
"REPORT.PDF": true,
|
|
"a/b/spec.Pdf": true,
|
|
"image.png": false,
|
|
"notes.txt": false,
|
|
"noext": false,
|
|
"archive.pdf.zip": false,
|
|
}
|
|
for path, want := range cases {
|
|
if got := IsProbablyDocumentPath(path); got != want {
|
|
t.Fatalf("IsProbablyDocumentPath(%q) = %v, want %v", path, got, want)
|
|
}
|
|
}
|
|
}
|