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>
303 lines
10 KiB
Go
303 lines
10 KiB
Go
// 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}
|
||
}
|