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
96 lines
3.3 KiB
Go
96 lines
3.3 KiB
Go
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
|
|
}
|