SRC-07: ocr-fuer-anhaenge

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
sysops
2026-08-31 22:10:16 +02:00
co-authored by Claude Sonnet 5
parent ff4d716b94
commit bd37de529f
4 changed files with 507 additions and 0 deletions
+187
View File
@@ -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
}
+156
View File
@@ -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)
}
}
+95
View File
@@ -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
}