Files
nexarch/mail/internal/ocr/ocr.go
T
sysopsandClaude Sonnet 5 bd37de529f 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
2026-08-31 22:10:16 +02:00

188 lines
6.5 KiB
Go

// 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
}