From bd37de529f402cb5e7db78d0a5c59a4892faa04d Mon Sep 17 00:00:00 2001 From: sysops Date: Mon, 31 Aug 2026 22:10:16 +0200 Subject: [PATCH] SRC-07: ocr-fuer-anhaenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCR-Pipeline für Bild-/PDF-Anhänge, eigener Mail-OCR-Pfad unabhängig vom DMS-Board. Direkte Vorbedingung für SRC-10. - ocr.go: zustandsloses Paket (wie crypto/dedup), kennt weder Mandant noch Speicher. ExtractTextFromImage ruft tesseract (deu+eng) mit fester Argumentliste auf. HasTextLayer/ExtractTextFromPDF nutzen pdftotext zur Erkennung einer vorhandenen Textebene (direkt übernommen, kein unnötiges OCR) und rastern nur bei fehlender Textebene über pdftoppm (300dpi) jede Seite für Tesseract. - Bekannten Fehler vermieden (archivmail-Sprintf-WHERE-Muster): keine SQL-Klauselbildung, ausschließlich exec.CommandContext mit fester Argumentliste, keine Shell. - testpdf_test.go: Testfixtures (Vektor-Text-PDF, Bild-only-PDF mit eingebettetem JPEG) vollständig in Go erzeugt, keine externe Bibliothek, keine Testdateien im Repo. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-07-PRUEFPROTOKOLL.md): 1. TestExtractTextFromImage_KnownTextRecognized: reales gerastertes Bild, Text real korrekt erkannt. 2. TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent: echtes Vektor- Text-PDF, OCR real übersprungen. 3. TestExtractTextFromPDF_ThroughputIsAcceptable: 3,48s/Anhang real gemessen (Ziel 8s/Anhang). Zusätzlich TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer für Akzeptanzkriterium 1 (gescannte PDFs) end-zu-Ende. Kein Umbau: search/dedup/indexworker/storage/crypto/encstorage/ savedsearch unverändert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ --- mail/docs/SRC-07-PRUEFPROTOKOLL.md | 69 +++++++++++ mail/internal/ocr/ocr.go | 187 +++++++++++++++++++++++++++++ mail/internal/ocr/ocr_test.go | 156 ++++++++++++++++++++++++ mail/internal/ocr/testpdf_test.go | 95 +++++++++++++++ 4 files changed, 507 insertions(+) create mode 100644 mail/docs/SRC-07-PRUEFPROTOKOLL.md create mode 100644 mail/internal/ocr/ocr.go create mode 100644 mail/internal/ocr/ocr_test.go create mode 100644 mail/internal/ocr/testpdf_test.go diff --git a/mail/docs/SRC-07-PRUEFPROTOKOLL.md b/mail/docs/SRC-07-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..0169079 --- /dev/null +++ b/mail/docs/SRC-07-PRUEFPROTOKOLL.md @@ -0,0 +1,69 @@ +# SRC-07 – Prüfprotokoll: OCR für Anhänge + +Voraussetzung ARC-01, ARC-03 (beide Fertig). SRC-07 ist die direkte +Vorbedingung für SRC-10 (Spracherkennung & OCR-Qualitätsbewertung), nicht +nur eine nette Ergänzung — ohne SRC-07 gibt es keinen erkannten Text, den +SRC-10 mit Sprache/Konfidenz bewerten könnte. + +## Umsetzung + +- `mail/internal/ocr/ocr.go` — zustandsloses Paket, kennt weder Mandant + noch Speicher (dieselbe Bauart wie `mail/internal/crypto`/`dedup`): + nimmt Anhangs-Bytes entgegen, liefert erkannten Text zurück. Kein + geteilter Zustand zwischen Aufrufen (jeder Aufruf bekommt ein eigenes + Temp-Verzeichnis) — Tenant-Trennung ist dadurch strukturell gegeben, + nicht nur konventionell: ein Mandant kann prinzipbedingt nie Zwischen- + daten eines anderen sehen. Zuordnung des erkannten Textes zum + Mail-Suchdokument (Akzeptanzkriterium 2) erfolgt beim Aufrufer über das + bereits vorhandene `search.Document.AttachmentText`-Feld (SRC-01) — kein + neues Feld nötig. + - `ExtractTextFromImage`: ruft `tesseract` (Sprachen `deu+eng`) mit + fester Argumentliste auf, kein Shell-String-Zusammenbau. + - `HasTextLayer`/`ExtractTextFromPDF`: nutzt `pdftotext`, um eine + vorhandene Textebene zu erkennen und direkt zu übernehmen + (Akzeptanzkriterium 3) — nur wenn keine Textebene vorhanden ist + (< 10 Zeichen), wird über `pdftoppm` (300dpi) jede Seite gerastert und + per Tesseract erkannt (Akzeptanzkriterium 1). +- Bekannten Fehler vermieden (dupliziertes Sprintf-WHERE-Muster aus + archivmail, siehe repos-analyse-mail-reuse.md): dieses Paket baut keine + SQL-Klauseln — ausschließlich externe Kommandozeilenwerkzeuge mit + festen Argumentlisten (`exec.CommandContext`, keine Shell). +- Kein Umbau: `mail/internal/search`/`dedup`/`indexworker`/`storage`/ + `crypto`/`encstorage`/`savedsearch` unverändert. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test: Bildanhang mit bekanntem Text liefert erwartete Texterkennung | **bestanden** – `TestExtractTextFromImage_KnownTextRecognized`: reales, über `pdftoppm` gerastertes Bild mit dem Text "Rechnungsnummer 4711", `ExtractTextFromImage` erkennt real beide Wortbestandteile | +| 2 | Test: PDF mit vorhandener Textebene wird korrekt übersprungen | **bestanden** – `TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent`: real erzeugtes Vektor-Text-PDF (echte PDF-Textebene, kein Bild), `ExtractTextFromPDF` liefert `OCRPerformed=false` und den Text direkt aus der Textebene | +| 3 | Durchsatztest bestätigt akzeptable Verarbeitungszeit je Anhang | **bestanden** – `TestExtractTextFromPDF_ThroughputIsAcceptable`: 3 reale Anhänge (Rasterung 300dpi + OCR) in durchschnittlich 3,48s/Anhang (Ziel 8s/Anhang) | + +Zusätzlich (Akzeptanzkriterium 1, gescannte PDFs end-zu-Ende): +`TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer` — ein reales, +ausschließlich rasterbildbasiertes PDF (kein Textelement, JPEG-Bild via +`/DCTDecode` eingebettet) wird real per OCR erkannt, `OCRPerformed=true`. + +Testfixtures (`testpdf_test.go`) werden vollständig in Go erzeugt (Hand- +gebautes PDF mit Helvetica-Textebene bzw. eingebettetem JPEG) — keine +externe Bibliothek, keine Testdateien im Repository, reproduzierbar. + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./internal/ocr/... -v -> 4/4 bestanden (14,99s gesamt) +TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1 + -> alle Pakete bestanden, keine Regression +``` + +Werkzeugversionen auf 192.168.1.131: `tesseract 5.5.0` (Sprachpakete +`deu`, `eng`), `pdftotext`/`pdftoppm` (poppler-utils) — bereits vorhanden, +keine Installation durch diese Sitzung nötig. + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Entsperrt SRC-10. diff --git a/mail/internal/ocr/ocr.go b/mail/internal/ocr/ocr.go new file mode 100644 index 0000000..a42c5b0 --- /dev/null +++ b/mail/internal/ocr/ocr.go @@ -0,0 +1,187 @@ +// Package ocr implementiert SRC-07: eine OCR-Pipeline für Bild-/PDF- +// Anhänge, als eigener Mail-OCR-Pfad unabhängig vom DMS-Board (siehe +// repos-analyse-mail-reuse.md: dupliziertes Sprintf-WHERE-Muster aus +// archivmail wird NICHT übernommen — dieses Paket baut keine SQL-Klauseln, +// ruft ausschließlich externe Kommandozeilenwerkzeuge mit festen +// Argumentlisten auf, niemals über eine Shell/String-Konkatenation). +// +// Zustandslos: dieses Paket kennt weder Mandanten noch Speicher — es +// nimmt Anhangs-Bytes entgegen und liefert erkannten Text zurück, exakt +// wie mail/internal/crypto und mail/internal/dedup. Tenant-Trennung +// entsteht dadurch strukturell: es gibt keinen geteilten Zustand +// zwischen Aufrufen, den unterschiedliche Mandanten sehen könnten (jeder +// Aufruf bekommt sein eigenes Temp-Verzeichnis, siehe workDir). +// +// Bekannter Fehler vermeiden (siehe ARC-03-Ticket): OCR darf erst NACH +// abgeschlossener Duplikatsprüfung laufen — das ist Sache des Aufrufers +// (spätere Ingest-Tickets), dieses Paket kennt mail/internal/dedup nicht. +package ocr + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// Languages ist die feste Tesseract-Sprachkombination (Akzeptanzkriterium +// 1 aus SRC-10 baut hierauf auf) — Deutsch und Englisch, passend zum +// deutschsprachigen NEXARCH-Einsatzkontext mit englischsprachigen +// Geschäftskorrespondenzen. +const Languages = "deu+eng" + +// ExtractTextFromImage führt Tesseract-OCR auf einem einzelnen Bild aus +// (PNG/JPEG/TIFF/BMP — von Tesseract/Leptonica unterstützte Formate). +func ExtractTextFromImage(ctx context.Context, imageBytes []byte) (string, error) { + workDir, err := os.MkdirTemp("", "mail-ocr-*") + if err != nil { + return "", fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err) + } + defer func() { _ = os.RemoveAll(workDir) }() + + imagePath := filepath.Join(workDir, "input") + if err := os.WriteFile(imagePath, imageBytes, 0o600); err != nil { + return "", fmt.Errorf("ocr: bild schreiben: %w", err) + } + outBase := filepath.Join(workDir, "output") + + cmd := exec.CommandContext(ctx, "tesseract", imagePath, outBase, "-l", Languages) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("ocr: tesseract fehlgeschlagen: %w (%s)", err, stderr.String()) + } + + text, err := os.ReadFile(outBase + ".txt") + if err != nil { + return "", fmt.Errorf("ocr: erkannten text lesen: %w", err) + } + return strings.TrimSpace(string(text)), nil +} + +// minTextLayerLength ist die Mindestanzahl nicht-leerer Zeichen, ab der +// ein PDF als "hat bereits eine Textebene" gilt (Akzeptanzkriterium 3). +// Ein paar Steuerzeichen/Leerzeichen aus pdftotext bei einem rein +// bildbasierten PDF dürfen keine OCR-Umgehung auslösen. +const minTextLayerLength = 10 + +// HasTextLayer prüft über pdftotext, ob pdfBytes bereits durchsuchbaren +// Text enthält (Akzeptanzkriterium 3: bereits durchsuchbare PDFs werden +// nicht unnötig erneut per OCR verarbeitet). +func HasTextLayer(ctx context.Context, pdfBytes []byte) (bool, error) { + text, err := extractPDFText(ctx, pdfBytes) + if err != nil { + return false, err + } + return len(strings.TrimSpace(text)) >= minTextLayerLength, nil +} + +func extractPDFText(ctx context.Context, pdfBytes []byte) (string, error) { + workDir, err := os.MkdirTemp("", "mail-ocr-pdf-*") + if err != nil { + return "", fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err) + } + defer func() { _ = os.RemoveAll(workDir) }() + + pdfPath := filepath.Join(workDir, "input.pdf") + if err := os.WriteFile(pdfPath, pdfBytes, 0o600); err != nil { + return "", fmt.Errorf("ocr: pdf schreiben: %w", err) + } + + cmd := exec.CommandContext(ctx, "pdftotext", pdfPath, "-") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("ocr: pdftotext fehlgeschlagen: %w (%s)", err, stderr.String()) + } + return stdout.String(), nil +} + +// PDFResult ist das Ergebnis von ExtractTextFromPDF. +type PDFResult struct { + Text string + // OCRPerformed ist false, wenn eine vorhandene Textebene genutzt + // wurde (Akzeptanzkriterium 3), true, wenn tatsächlich per Tesseract + // erkannt wurde (Akzeptanzkriterium 1). + OCRPerformed bool +} + +// ExtractTextFromPDF liefert den Text eines PDF-Anhangs: hat das PDF +// bereits eine Textebene, wird sie direkt übernommen (schneller, +// zuverlässiger als OCR, Akzeptanzkriterium 3). Sonst wird jede Seite +// gerastert und per Tesseract erkannt (Akzeptanzkriterium 1: gescannte +// PDF-Anhänge). +func ExtractTextFromPDF(ctx context.Context, pdfBytes []byte) (PDFResult, error) { + existingText, err := extractPDFText(ctx, pdfBytes) + if err != nil { + return PDFResult{}, err + } + if len(strings.TrimSpace(existingText)) >= minTextLayerLength { + return PDFResult{Text: strings.TrimSpace(existingText), OCRPerformed: false}, nil + } + + pageImages, err := rasterizePDF(ctx, pdfBytes) + if err != nil { + return PDFResult{}, err + } + + var pages []string + for _, imageBytes := range pageImages { + pageText, err := ExtractTextFromImage(ctx, imageBytes) + if err != nil { + return PDFResult{}, err + } + pages = append(pages, pageText) + } + return PDFResult{Text: strings.TrimSpace(strings.Join(pages, "\n")), OCRPerformed: true}, nil +} + +// rasterizePDF rastert jede Seite von pdfBytes über pdftoppm in ein PNG +// und liefert die Seiten in Reihenfolge. +func rasterizePDF(ctx context.Context, pdfBytes []byte) ([][]byte, error) { + workDir, err := os.MkdirTemp("", "mail-ocr-raster-*") + if err != nil { + return nil, fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err) + } + defer func() { _ = os.RemoveAll(workDir) }() + + pdfPath := filepath.Join(workDir, "input.pdf") + if err := os.WriteFile(pdfPath, pdfBytes, 0o600); err != nil { + return nil, fmt.Errorf("ocr: pdf schreiben: %w", err) + } + outBase := filepath.Join(workDir, "page") + + cmd := exec.CommandContext(ctx, "pdftoppm", "-png", "-r", "300", pdfPath, outBase) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ocr: pdftoppm fehlgeschlagen: %w (%s)", err, stderr.String()) + } + + entries, err := os.ReadDir(workDir) + if err != nil { + return nil, fmt.Errorf("ocr: gerasterte seiten auflisten: %w", err) + } + var pagePaths []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".png") { + pagePaths = append(pagePaths, filepath.Join(workDir, e.Name())) + } + } + sort.Strings(pagePaths) + + var pages [][]byte + for _, p := range pagePaths { + data, err := os.ReadFile(p) + if err != nil { + return nil, fmt.Errorf("ocr: gerasterte seite lesen: %w", err) + } + pages = append(pages, data) + } + return pages, nil +} diff --git a/mail/internal/ocr/ocr_test.go b/mail/internal/ocr/ocr_test.go new file mode 100644 index 0000000..8de8bd0 --- /dev/null +++ b/mail/internal/ocr/ocr_test.go @@ -0,0 +1,156 @@ +package ocr + +import ( + "bytes" + "context" + "image/png" + "os/exec" + "strings" + "testing" + "time" +) + +func requireTools(t *testing.T) { + t.Helper() + for _, tool := range []string{"tesseract", "pdftotext", "pdftoppm"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s nicht im PATH, Integrationstest übersprungen", tool) + } + } +} + +// knownTextImage rastert ein hand-gebautes Text-PDF (bekannter Inhalt) +// über die echte pdftoppm-Pipeline in ein reales PNG-Bild — liefert damit +// ein Bild mit tatsächlich gerenderten, OCR-fähigen Glyphen, ohne eine +// zusätzliche Font-Rendering-Bibliothek einzuführen. +func knownTextImage(t *testing.T, ctx context.Context, text string) []byte { + t.Helper() + pdfBytes := buildTextLayerPDF(text) + pages, err := rasterizePDF(ctx, pdfBytes) + if err != nil { + t.Fatalf("testbild rastern: %v", err) + } + if len(pages) != 1 { + t.Fatalf("erwartete genau 1 gerasterte seite, habe %d", len(pages)) + } + return pages[0] +} + +// TestExtractTextFromImage_KnownTextRecognized ist die geforderte +// Pflichtprüfung 1: Bildanhang mit bekanntem Text liefert erwartete +// Texterkennung. +func TestExtractTextFromImage_KnownTextRecognized(t *testing.T) { + requireTools(t) + ctx := context.Background() + imageBytes := knownTextImage(t, ctx, "Rechnungsnummer 4711") + + got, err := ExtractTextFromImage(ctx, imageBytes) + if err != nil { + t.Fatalf("extracttextfromimage: %v", err) + } + if !strings.Contains(got, "Rechnungsnummer") || !strings.Contains(got, "4711") { + t.Fatalf("erwarteten text nicht erkannt, habe: %q", got) + } +} + +// TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent ist die geforderte +// Pflichtprüfung 2: PDF mit vorhandener Textebene wird korrekt +// übersprungen. +func TestExtractTextFromPDF_SkipsOCRWhenTextLayerPresent(t *testing.T) { + requireTools(t) + ctx := context.Background() + pdfBytes := buildTextLayerPDF("Vertragsentwurf Version 3") + + hasLayer, err := HasTextLayer(ctx, pdfBytes) + if err != nil { + t.Fatalf("hastextlayer: %v", err) + } + if !hasLayer { + t.Fatal("erwartete erkannte textebene im vektor-pdf") + } + + result, err := ExtractTextFromPDF(ctx, pdfBytes) + if err != nil { + t.Fatalf("extracttextfrompdf: %v", err) + } + if result.OCRPerformed { + t.Fatal("ocr wurde durchgeführt, obwohl eine textebene vorhanden war") + } + if !strings.Contains(result.Text, "Vertragsentwurf") { + t.Fatalf("erwarteten text aus textebene nicht gefunden, habe: %q", result.Text) + } +} + +// TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer deckt +// Akzeptanzkriterium 1 (gescannte PDF-Anhänge) zusätzlich end-zu-Ende ab: +// ein PDF ohne Textebene, das nur ein Rasterbild enthält, wird real per +// OCR erkannt. +func TestExtractTextFromPDF_PerformsOCRWhenNoTextLayer(t *testing.T) { + requireTools(t) + ctx := context.Background() + + pngBytes := knownTextImage(t, ctx, "Gescannter Anhang Test") + img, err := png.Decode(bytes.NewReader(pngBytes)) + if err != nil { + t.Fatalf("testbild dekodieren: %v", err) + } + scannedPDF, err := buildImageOnlyPDF(img) + if err != nil { + t.Fatalf("bild-pdf bauen: %v", err) + } + + hasLayer, err := HasTextLayer(ctx, scannedPDF) + if err != nil { + t.Fatalf("hastextlayer: %v", err) + } + if hasLayer { + t.Fatal("bild-only-pdf wurde fälschlich als textebene erkannt") + } + + result, err := ExtractTextFromPDF(ctx, scannedPDF) + if err != nil { + t.Fatalf("extracttextfrompdf: %v", err) + } + if !result.OCRPerformed { + t.Fatal("erwartete durchgeführte ocr bei fehlender textebene") + } + if !strings.Contains(result.Text, "Gescannter") { + t.Fatalf("erwarteten text aus ocr nicht gefunden, habe: %q", result.Text) + } +} + +// TestExtractTextFromPDF_ThroughputIsAcceptable ist die geforderte +// Pflichtprüfung 3: Durchsatztest bestätigt akzeptable Verarbeitungszeit +// je Anhang. Zielzeit 8s je Anhang (großzügig für Rasterung + OCR bei +// 300dpi, deckt aber real ab, dass kein pathologischer Ausreißer +// entsteht). +func TestExtractTextFromPDF_ThroughputIsAcceptable(t *testing.T) { + requireTools(t) + ctx := context.Background() + + const attachments = 3 + const targetPerAttachment = 8 * time.Second + + pngBytes := knownTextImage(t, ctx, "Durchsatztest Anhang") + img, err := png.Decode(bytes.NewReader(pngBytes)) + if err != nil { + t.Fatalf("testbild dekodieren: %v", err) + } + scannedPDF, err := buildImageOnlyPDF(img) + if err != nil { + t.Fatalf("bild-pdf bauen: %v", err) + } + + start := time.Now() + for i := 0; i < attachments; i++ { + if _, err := ExtractTextFromPDF(ctx, scannedPDF); err != nil { + t.Fatalf("anhang %d: %v", i, err) + } + } + elapsed := time.Since(start) + perAttachment := elapsed / attachments + t.Logf("Durchsatz: %d Anhänge in %s (%s/Anhang, Ziel %s/Anhang)", attachments, elapsed, perAttachment, targetPerAttachment) + if perAttachment > targetPerAttachment { + t.Fatalf("verarbeitung zu langsam: %s/anhang, ziel %s/anhang", perAttachment, targetPerAttachment) + } +} diff --git a/mail/internal/ocr/testpdf_test.go b/mail/internal/ocr/testpdf_test.go new file mode 100644 index 0000000..dafb2d6 --- /dev/null +++ b/mail/internal/ocr/testpdf_test.go @@ -0,0 +1,95 @@ +package ocr + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" +) + +// buildTextLayerPDF erzeugt ein minimales, aber ECHTES PDF mit einer +// vektorbasierten Textebene (Standardfont Helvetica, kein eingebettetes +// Fontprogramm nötig) — von Hand gebaut, keine externe Bibliothek/kein +// externes Werkzeug, damit der Test reproduzierbar und unabhängig von +// Systempaketen bleibt. pdftotext extrahiert text unmittelbar daraus. +func buildTextLayerPDF(text string) []byte { + var buf bytes.Buffer + offsets := make([]int, 0, 6) + + buf.WriteString("%PDF-1.4\n") + + writeObj := func(n int, body string) { + offsets = append(offsets, buf.Len()) + fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", n, body) + } + + writeObj(1, "<< /Type /Catalog /Pages 2 0 R >>") + writeObj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + writeObj(3, "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /MediaBox [0 0 400 200] /Contents 4 0 R >>") + + content := fmt.Sprintf("BT /F1 24 Tf 20 100 Td (%s) Tj ET", text) + stream := fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content) + writeObj(4, stream) + writeObj(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + + xrefStart := buf.Len() + fmt.Fprintf(&buf, "xref\n0 %d\n", len(offsets)+1) + buf.WriteString("0000000000 65535 f \n") + for _, off := range offsets { + fmt.Fprintf(&buf, "%010d 00000 n \n", off) + } + fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", len(offsets)+1, xrefStart) + + return buf.Bytes() +} + +// buildImageOnlyPDF erzeugt ein minimales, ECHTES PDF, dessen einzige +// Seite ausschließlich aus einem eingebetteten Rasterbild besteht (JPEG +// via /DCTDecode — von PDF nativ unterstützt, kein zusätzlicher Codec +// nötig) — simuliert ein gescanntes PDF ohne Textebene +// (Akzeptanzkriterium 1). +func buildImageOnlyPDF(img image.Image) ([]byte, error) { + var jpegBuf bytes.Buffer + if err := jpeg.Encode(&jpegBuf, img, &jpeg.Options{Quality: 90}); err != nil { + return nil, fmt.Errorf("jpeg kodieren: %w", err) + } + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + + var buf bytes.Buffer + offsets := make([]int, 0, 6) + buf.WriteString("%PDF-1.4\n") + + writeObj := func(n int, header string, body []byte) { + offsets = append(offsets, buf.Len()) + fmt.Fprintf(&buf, "%d 0 obj\n%s\n", n, header) + if body != nil { + buf.Write(body) + buf.WriteString("\nendstream\n") + } + buf.WriteString("endobj\n") + } + + writeObj(1, "<< /Type /Catalog /Pages 2 0 R >>", nil) + writeObj(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", nil) + writeObj(3, fmt.Sprintf("<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Im0 5 0 R >> >> /MediaBox [0 0 %d %d] /Contents 4 0 R >>", w, h), nil) + + content := fmt.Sprintf("q %d 0 0 %d 0 0 cm /Im0 Do Q", w, h) + writeObj(4, fmt.Sprintf("<< /Length %d >>\nstream", len(content)), []byte(content)) + + imgHeader := fmt.Sprintf( + "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream", + w, h, jpegBuf.Len(), + ) + writeObj(5, imgHeader, jpegBuf.Bytes()) + + xrefStart := buf.Len() + fmt.Fprintf(&buf, "xref\n0 %d\n", len(offsets)+1) + buf.WriteString("0000000000 65535 f \n") + for _, off := range offsets { + fmt.Fprintf(&buf, "%010d 00000 n \n", off) + } + fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", len(offsets)+1, xrefStart) + + return buf.Bytes(), nil +}