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