// Package documents macht den Inhalt von Dokumenten durchsuchbar. // // Die Namenssuche (internal/files/search.go) findet "Rechnung_Stadtwerke.pdf". // Ein Scanner nennt seine Ablage aber "scan_0042.pdf" – der Name sagt dann // nichts, und der Beleg ist praktisch verloren. Dieses Paket holt den Text aus // der Datei und legt ihn nach file_text, wo die Suche ihn findet. package documents import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "unicode/utf8" ) // Herkunft des Textes. Der Unterschied zählt bei der Fehlersuche: Findet die // Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen haben kann. const ( SourceLayer = "layer" // aus der Textebene des PDFs – verlustfrei SourceOCR = "ocr" // aus dem Bild erkannt – kann Fehler enthalten ) // minCharsPerPage entscheidet, ob ein PDF schon Text mitbringt. // // Nicht "> 0 Zeichen": Fast jeder Scan trägt eine Kopfzeile, eine Seitenzahl // oder ein Wasserzeichen als echten Text – oft nur zehn Zeichen. Ohne diese // Schwelle gälte er als lesbar, und die eigentliche Rechnung bliebe // unauffindbar. Eine getippte Seite hat mehrere tausend Zeichen; 120 trennt die // beiden Fälle mit reichlich Abstand in beide Richtungen. const minCharsPerPage = 120 // maxTextBytes deckelt, was in die Datenbank geht. Ein 400-seitiges Handbuch // erzeugt sonst mehrere Megabyte Text pro Datei, und der GIN-Index wächst mit. // Für einen Beleg ist das weit jenseits von allem; abgeschnitten wird am Ende, // wo der Inhalt ohnehin am wenigsten aussagt. const maxTextBytes = 1 << 20 // 1 MiB // Extractor holt Text aus einer Datei. Er ruft externe Programme auf – // pdftotext, ocrmypdf, tesseract –, die im Container mitgeliefert werden. type Extractor struct { // Languages sind die Sprachmodelle für tesseract, z. B. "deu+eng". Languages string // MaxPages begrenzt, wie viele Seiten erkannt werden. OCR kostet Sekunden // je Seite; ohne Grenze blockiert ein einzelnes dickes PDF die // Warteschlange für alle anderen. MaxPages int // Timeout je Datei. Greift zusätzlich zu MaxPages, weil auch eine einzelne // Seite entgleisen kann. Timeout time.Duration // TmpDir nimmt die Zwischendateien auf. Leer heißt: das System entscheidet. TmpDir string } // Result ist der gefundene Text mitsamt seiner Herkunft. type Result struct { Text string Source string // Lang sind die verwendeten Sprachmodelle – bei SourceLayer leer, weil // dort nichts erkannt, sondern nur ausgelesen wurde. Lang string Pages int // Chars ist die Länge *vor* dem Kürzen auf maxTextBytes. Chars int } // Supported sagt, ob aus dieser Datei überhaupt Text zu holen ist. // // Bewusst eng: PDFs und Bilder. Word- und Excel-Dateien bräuchten je einen // eigenen Umwandler; sie tragen ihren Text ohnehin meist schon im Namen und // sind selten das, was eingescannt wird. func Supported(mime, name string) bool { m := strings.ToLower(mime) if strings.Contains(m, "pdf") { return true } if strings.HasPrefix(m, "image/") { // SVG ist Text, kein Bild – tesseract kann damit nichts anfangen. return !strings.Contains(m, "svg") } switch strings.ToLower(filepath.Ext(name)) { case ".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp": return true } return false } // Available prüft, ob die Programme da sind. Fehlen sie, läuft die Cloud // weiter – nur ohne Volltext. Ein fehlendes Paket soll den Start nicht // verhindern. func Available() (bool, []string) { var missing []string for _, bin := range []string{"pdftotext", "ocrmypdf", "tesseract"} { if _, err := exec.LookPath(bin); err != nil { missing = append(missing, bin) } } return len(missing) == 0, missing } // Extract liest die Datei unter path. Das Original wird dabei **nicht** // angefasst. // // Das ist die wichtigste Eigenschaft hier: ocrmypdf schreibt normalerweise ein // neues PDF mit eingebetteter Textebene. Täten wir das, änderten sich Größe und // Prüfsumme der Datei – die gespeicherte Prüfsumme passte nicht mehr, die // Versionshistorie bekäme einen Eintrag, den niemand ausgelöst hat, und // WebDAV-Clients hielten die Datei für fremdverändert und lüden sie erneut. Für // ein Archiv ist eine unangetastete Datei mehr wert als ein durchsuchbares PDF. // Der Text landet deshalb daneben in der Datenbank, das PDF bleibt Byte für // Byte, wie es hochgeladen wurde. func (e *Extractor) Extract(ctx context.Context, path, mime, name string) (Result, error) { if e.Timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, e.Timeout) defer cancel() } isPDF := strings.Contains(strings.ToLower(mime), "pdf") || strings.EqualFold(filepath.Ext(name), ".pdf") if isPDF { return e.extractPDF(ctx, path) } return e.extractImage(ctx, path) } func (e *Extractor) extractPDF(ctx context.Context, path string) (Result, error) { pages := e.pageCount(ctx, path) // Erst der billige Weg: Hat das PDF eine Textebene, ist pdftotext in // Millisekunden fertig und liefert den Text exakt so, wie er gesetzt wurde. // OCR wäre hier nicht nur langsamer, sondern schlechter – es würde // fehlerfreien Text durch geratenen ersetzen. limit := pages if e.MaxPages > 0 && (limit == 0 || limit > e.MaxPages) { limit = e.MaxPages } args := []string{"-q"} if limit > 0 { args = append(args, "-l", fmt.Sprint(limit)) } args = append(args, path, "-") layer, err := e.run(ctx, "pdftotext", args...) if err == nil { text := clean(layer) perPage := len(text) if pages > 1 { perPage = len(text) / pages } if perPage >= minCharsPerPage { return finish(text, SourceLayer, "", pages), nil } } // Zu wenig Text – also ein Scan. Jetzt OCR. sidecar, err := os.CreateTemp(e.TmpDir, "ocr-*.txt") if err != nil { return Result{}, fmt.Errorf("sidecar anlegen: %w", err) } sidecarPath := sidecar.Name() sidecar.Close() defer os.Remove(sidecarPath) ocrArgs := []string{ "--quiet", "-l", e.Languages, // Der eigentliche Zweck des Aufrufs: --sidecar schreibt den erkannten // Text als reine Textdatei daneben. "--sidecar", sidecarPath, // Wir landen hier nur, wenn kaum Text da ist. --force-ocr rastert die // Seite und erkennt sie neu, statt an den paar vorhandenen Zeichen zu // scheitern (ocrmypdf bricht sonst mit PriorOcrFoundError ab). "--force-ocr", // Kein Ergebnis-PDF erzeugen. Uns interessiert allein der Sidecar; das // Schreiben (und Optimieren) einer Datei, die sofort wieder // verschwindet, wäre reine Rechenzeit und reiner Plattenplatz. "--output-type", "none", "--optimize", "0", } if e.MaxPages > 0 && pages > e.MaxPages { ocrArgs = append(ocrArgs, "--pages", fmt.Sprintf("1-%d", e.MaxPages)) } // Ausgabe nach stdout, und dank --output-type none kommt dort nichts an: // gemessen 0 Bytes, Sidecar trotzdem vollständig. Damit entsteht gar kein // Ergebnis-PDF – wichtig auf einer Platte, die knapp werden kann, denn ein // force-OCR rastert jede Seite und das Ergebnis kann ein Vielfaches der // Vorlage wiegen. // // /dev/null als Ausgabe geht übrigens **nicht** – ocrmypdf bricht damit // mit Exit 3 ab. Der Bindestrich ist der Weg. ocrArgs = append(ocrArgs, path, "-") if _, err := e.run(ctx, "ocrmypdf", ocrArgs...); err != nil { return Result{}, fmt.Errorf("ocrmypdf: %w", err) } raw, err := os.ReadFile(sidecarPath) if err != nil { return Result{}, fmt.Errorf("sidecar lesen: %w", err) } return finish(clean(string(raw)), SourceOCR, e.Languages, pages), nil } func (e *Extractor) extractImage(ctx context.Context, path string) (Result, error) { out, err := e.run(ctx, "tesseract", path, "stdout", "-l", e.Languages) if err != nil { return Result{}, fmt.Errorf("tesseract: %w", err) } return finish(clean(out), SourceOCR, e.Languages, 1), nil } // pageCount fragt pdfinfo nach der Seitenzahl. Schlägt das fehl, arbeiten wir // mit 0 weiter – die Seitenzahl steuert nur Grenzen, sie ist nicht kritisch. func (e *Extractor) pageCount(ctx context.Context, path string) int { out, err := e.run(ctx, "pdfinfo", path) if err != nil { return 0 } for _, line := range strings.Split(out, "\n") { if !strings.HasPrefix(line, "Pages:") { continue } var n int if _, err := fmt.Sscanf(strings.TrimSpace(line), "Pages: %d", &n); err == nil { return n } } return 0 } func (e *Extractor) run(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) var out, errOut bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errOut if err := cmd.Run(); err != nil { msg := strings.TrimSpace(errOut.String()) if len(msg) > 300 { msg = msg[:300] + "…" } if msg != "" { return "", fmt.Errorf("%s: %w (%s)", name, err, msg) } return "", fmt.Errorf("%s: %w", name, err) } return out.String(), nil } // clean räumt auf, was aus den Werkzeugen kommt. // // **Der Seitenumbruch (\f) bleibt stehen.** Für die Suche ist er bedeutungslos // – ein tsvector kennt keine Seiten –, für den Betrachter ist er das Einzige, // woran sich ablesen lässt, *wo* ein Wort steht. Bei einem Scan liegt im PDF // nur ein Bild; der erkannte Text steht ausschließlich hier. Ohne die Marke // beginnt der Betrachter bei einem zwölfseitigen Beleg wieder auf Seite 1. // // Ein Wust aus Leerzeilen bläht den Text dagegen nur auf. Ungültiges UTF-8 // muss raus, weil Postgres es sonst zurückweist und der ganze Auftrag // scheitert; OCR liefert es bei verrauschten Vorlagen durchaus. func clean(s string) string { s = strings.ReplaceAll(s, "\x00", "") if !utf8.ValidString(s) { s = strings.ToValidUTF8(s, "") } var b strings.Builder b.Grow(len(s)) blank := 0 for _, line := range strings.Split(s, "\n") { line = strings.TrimRight(line, " \t\r") // Die Seitenmarke steht bei pdftotext am Ende der letzten Zeile einer // Seite. Sie überlebt das Trimmen und zählt nicht als leer. if strings.TrimSpace(strings.ReplaceAll(line, "\f", "")) == "" && !strings.Contains(line, "\f") { blank++ if blank > 1 { continue } } else { blank = 0 } b.WriteString(line) b.WriteByte('\n') } return strings.TrimSpace(b.String()) } // finish kürzt auf maxTextBytes und merkt sich die ursprüngliche Länge. func finish(text, source, lang string, pages int) Result { chars := utf8.RuneCountInString(text) if len(text) > maxTextBytes { text = text[:maxTextBytes] // Nicht mitten in einem Zeichen abschneiden – das ergäbe ungültiges // UTF-8, und Postgres lehnte den ganzen Text ab. for len(text) > 0 && !utf8.ValidString(text) { text = text[:len(text)-1] } } return Result{Text: text, Source: source, Lang: lang, Pages: pages, Chars: chars} }