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
188 lines
6.5 KiB
Go
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
|
|
}
|