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
157 lines
4.7 KiB
Go
157 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|