Files
Aether/backend/internal/documents/extract.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

303 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}
}