cd803a3dfe
Vor dem Rollout durchgesehen und die verbliebenen Stellen geschlossen, an denen etwas schiefgehen konnte, ohne dass es irgendwo sichtbar wurde. Datenverlust: - veraPDF: das in [verapdf].binary konfigurierte Programm wird im Preflight geprueft. Bisher galt bei falschem Pfad JEDE Datei als "nicht konform" — Ergebnis nach error/, Original geloescht (Default delete). run_verapdf() trennt jetzt ausserdem ein echtes FAIL-Urteil von einer Stoerung (VeraPdfUnavailable: nicht startbar, abgestuerzt, kein PASS/FAIL in der Ausgabe). Bei Stoerung wandern Original UND Ergebnis nach error/, das Original wird nicht entsorgt. - Gleichnamige Dateien wurden in outgoing/, error/ und beim Ordner-Upload mit abweichendem target kommentarlos ueberschrieben. Jetzt Zeitstempel daneben, mit Warnung; ProcessResult.output traegt den echten Pfad. Robustheit: - Kaputtes oder nicht lesbares TOML beim Start: Exit 2 statt Traceback. - RestartPreventExitStatus=2 in der Unit — Exit 2 (Config/Preflight) laeuft nicht mehr endlos neu, die Instanz bleibt sichtbar failed stehen. - Toter watchdog-Observer wird erkannt: Exit 3, systemd setzt den Watch neu auf. Vorher blieb die Unit "active" und verarbeitete nichts mehr. - Relative Pfade in [paths]/archive_dir/target sind ein Config-Fehler statt still unter /opt zu landen. - Fehler beim Archivieren entwertet den Durchlauf nicht mehr: Upload und Mail laufen, Sichtbarkeit ueber log.error + "OK mit Warnung"-Mail. - Nicht-PDFs in incoming/ werden beim Start-Scan gesammelt gemeldet. - Logging explizit nach stdout (die Doku versprach das schon). Struktur: - Neue lib/common.sh, von install.sh und update.sh gesourct. Die doppelte venv_is_healthy() gibt es nur noch einmal, in der gruendlichen Fassung — die schlanke in install.sh haette eine nach einem Distro-Sprung kaputte venv als gesund durchgewunken (nachgewiesen). - install.sh warnt in Containern, wenn systemd-journald nicht laeuft. Doku: Dateisystem-Festlegung (ext4/xfs/zfs, kein CIFS/NFS wegen inotify), Debian 13 in LXC auf Proxmox scheitert an journald (243/CREDENTIALS, AppArmor blockiert sd-mkdcreds) inkl. Abhilfe, echte Speicher-Messwerte, Exit-Code-Tabelle. 254 Tests gruen (vorher 152). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
385 lines
15 KiB
Python
385 lines
15 KiB
Python
"""OCR-Verarbeitung einer einzelnen PDF mit ocrmypdf + optional veraPDF."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from .config import OcrConfig, OutputConfig, VeraPdfConfig
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft
|
|
VALID_NAME_MODES = ("prefix", "suffix", "none")
|
|
|
|
# Präfix der Zwischendatei, in die ocrmypdf schreibt. Bleibt sie nach einem
|
|
# harten Stopp in working/ liegen, ist sie ein unvollständiges Fragment.
|
|
OCR_TEMP_PREFIX = "__ocr_"
|
|
|
|
|
|
def build_output_name(src_name: str, mode: str, tag: str) -> str:
|
|
"""Erzeugt den Ziel-Dateinamen für ein OCR-PDF.
|
|
|
|
Args:
|
|
src_name: Original-Dateiname (z.B. "scan.pdf")
|
|
mode: "prefix" | "suffix" | "none"
|
|
tag: Einzufügender String (verbatim, leer = kein Tag)
|
|
|
|
Beispiele:
|
|
prefix "OCR_": "scan.pdf" -> "OCR_scan.pdf"
|
|
suffix "_OCR": "scan.pdf" -> "scan_OCR.pdf"
|
|
suffix "_OCR": "scan.tar.gz.pdf" -> "scan.tar.gz_OCR.pdf"
|
|
none: "scan.pdf" -> "scan.pdf"
|
|
"""
|
|
if mode == "none" or not tag:
|
|
return src_name
|
|
if mode == "prefix":
|
|
return f"{tag}{src_name}"
|
|
if mode == "suffix":
|
|
# Nur die letzte Extension abspalten, sonst "foo.bar.pdf" kaputt gemacht
|
|
p = Path(src_name)
|
|
stem, ext = p.stem, p.suffix
|
|
return f"{stem}{tag}{ext}"
|
|
raise ValueError(f"Unbekannter name_mode: {mode!r}")
|
|
|
|
|
|
class VeraPdfUnavailable(RuntimeError):
|
|
"""veraPDF konnte nicht befragt werden — Programm fehlt, startet nicht, Timeout.
|
|
|
|
Ausdrücklich KEIN inhaltliches Urteil über die PDF. Der Unterschied ist
|
|
existenziell: ein nicht startbares veraPDF, das wie ein FAIL behandelt
|
|
wird, schiebt JEDES OCR-Ergebnis nach error/ und entsorgt das Original
|
|
laut [output].original_on_success — bei dessen Default `delete` also
|
|
Scan für Scan die Vorlage, während der Dienst als "läuft" dasteht.
|
|
"""
|
|
|
|
|
|
# veraPDF schreibt mit `--format text` pro Datei eine Zeile, die mit dem
|
|
# Urteil beginnt. Steht in der Ausgabe weder PASS noch FAIL, hat veraPDF gar
|
|
# nichts geprüft (fehlendes Java, kaputter Wrapper, falsches Flavour) — das
|
|
# ist kein "nicht konform", sondern ein fehlendes Urteil.
|
|
_VERAPDF_VERDICTS = ("PASS", "FAIL")
|
|
|
|
# Sekunden, die veraPDF pro Datei laufen darf
|
|
VERAPDF_TIMEOUT = 300
|
|
|
|
|
|
@dataclass
|
|
class ProcessResult:
|
|
source: Path
|
|
output: Path
|
|
success: bool
|
|
error: str = ""
|
|
verapdf_passed: bool | None = None
|
|
# Gesetzt, wenn der Durchlauf erfolgreich war, aber etwas Nennenswertes
|
|
# danebenlief (aktuell: das Original ließ sich nicht entsorgen).
|
|
warning: str = ""
|
|
|
|
|
|
def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
|
"""Führt ocrmypdf als Library-Call aus (kein Subprozess-Overhead)."""
|
|
import ocrmypdf # lazy, damit Tests ohne ocrmypdf laufen
|
|
|
|
kwargs: dict = {
|
|
"language": cfg.languages,
|
|
"jobs": cfg.jobs,
|
|
"deskew": cfg.deskew,
|
|
"clean": cfg.clean,
|
|
"oversample": cfg.oversample,
|
|
"progress_bar": False,
|
|
"skip_text": cfg.skip_text,
|
|
}
|
|
if cfg.pdfa_level:
|
|
kwargs["output_type"] = f"pdfa-{cfg.pdfa_level}"
|
|
else:
|
|
kwargs["output_type"] = "pdf"
|
|
|
|
# [ocr].timeout = max. Sekunden, die Tesseract pro Seite laufen darf.
|
|
# ocrmypdf kennt kein Gesamt-Timeout für ein Dokument, nur `tesseract_timeout`
|
|
# (pro Seite). ACHTUNG: ocrmypdf interpretiert tesseract_timeout=0 als
|
|
# "OCR komplett überspringen" — deshalb wird 0 bei uns als "kein eigenes
|
|
# Limit" behandelt und gar nicht erst durchgereicht (dann gilt der
|
|
# ocrmypdf-Default).
|
|
if cfg.timeout and cfg.timeout > 0:
|
|
kwargs["tesseract_timeout"] = float(cfg.timeout)
|
|
|
|
log.info("OCR start: %s", src.name)
|
|
ocrmypdf.ocr(str(src), str(dst), **kwargs)
|
|
log.info("OCR done: %s", dst.name)
|
|
|
|
|
|
def resolve_verapdf_binary(binary: str) -> str | None:
|
|
"""Sucht das veraPDF-Programm und prüft, ob es ausführbar ist.
|
|
|
|
Beide Schreibweisen sind zulässig: ein Pfad (`/opt/verapdf/verapdf`, der
|
|
Default) wird direkt geprüft, ein nackter Name (`verapdf`) im PATH
|
|
gesucht.
|
|
|
|
Returns:
|
|
Der aufrufbare Pfad oder None.
|
|
"""
|
|
if not binary:
|
|
return None
|
|
if os.sep in binary:
|
|
p = Path(binary)
|
|
return str(p) if p.is_file() and os.access(p, os.X_OK) else None
|
|
return shutil.which(binary)
|
|
|
|
|
|
def run_verapdf(pdf: Path, cfg: VeraPdfConfig) -> bool:
|
|
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform.
|
|
|
|
Returns:
|
|
True = konform (PASS), False = nicht konform (FAIL).
|
|
|
|
Raises:
|
|
VeraPdfUnavailable: veraPDF ließ sich nicht befragen. Das ist kein
|
|
FAIL — siehe Klassen-Docstring.
|
|
"""
|
|
if not cfg.enabled:
|
|
return True
|
|
binary = resolve_verapdf_binary(cfg.binary)
|
|
if binary is None:
|
|
raise VeraPdfUnavailable(
|
|
f"[verapdf].binary = {cfg.binary!r} existiert nicht oder ist nicht "
|
|
"ausführbar"
|
|
)
|
|
try:
|
|
result = subprocess.run(
|
|
[binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
|
|
capture_output=True, text=True, timeout=VERAPDF_TIMEOUT,
|
|
)
|
|
except subprocess.TimeoutExpired as e:
|
|
raise VeraPdfUnavailable(
|
|
f"veraPDF hat für {pdf.name} nach {VERAPDF_TIMEOUT} s nicht "
|
|
"geantwortet"
|
|
) from e
|
|
except OSError as e:
|
|
raise VeraPdfUnavailable(f"veraPDF ({binary}) nicht startbar: {e}") from e
|
|
|
|
if not any(v in result.stdout for v in _VERAPDF_VERDICTS):
|
|
ausgabe = (result.stdout + result.stderr).strip().replace("\n", " ")
|
|
raise VeraPdfUnavailable(
|
|
f"veraPDF ({binary}) hat kein Urteil geliefert "
|
|
f"(Exit {result.returncode}): {ausgabe[:300] or '(keine Ausgabe)'}"
|
|
)
|
|
|
|
ok = result.returncode == 0 and "PASS" in result.stdout
|
|
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
|
|
return ok
|
|
|
|
|
|
def process_pdf(
|
|
src: Path,
|
|
working_dir: Path,
|
|
outgoing_dir: Path,
|
|
error_dir: Path,
|
|
ocr_cfg: OcrConfig,
|
|
vera_cfg: VeraPdfConfig,
|
|
output_cfg: OutputConfig,
|
|
) -> ProcessResult:
|
|
"""Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
|
|
out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag)
|
|
work_src = working_dir / src.name
|
|
work_out = working_dir / f"{OCR_TEMP_PREFIX}{out_name}" # Temp-Name, damit er != src.name ist
|
|
final_out = outgoing_dir / out_name
|
|
|
|
if _is_same_file(src, work_src):
|
|
# Wiederaufnahme: die Datei liegt bereits in working/, weil ein
|
|
# früherer Lauf hart abgebrochen wurde. Kein zweiter Move — der würde
|
|
# die Datei bestenfalls auf sich selbst schieben.
|
|
log.warning("Wiederaufnahme aus %s: %s wird erneut per OCR verarbeitet",
|
|
working_dir, src.name)
|
|
elif work_src.exists():
|
|
# Gleicher Dateiname, andere Datei: ein Move würde den laufenden bzw.
|
|
# wiederaufgenommenen Vorgang in working/ stillschweigend überschreiben.
|
|
return ProcessResult(
|
|
src, final_out, False,
|
|
f"in {working_dir} liegt bereits eine andere Datei namens "
|
|
f"{src.name} — Original bleibt in {src.parent} liegen und wird "
|
|
"beim nächsten Lauf erneut versucht",
|
|
)
|
|
else:
|
|
try:
|
|
shutil.move(str(src), str(work_src))
|
|
except OSError as e:
|
|
return ProcessResult(src, final_out, False, f"move to working failed: {e}")
|
|
|
|
try:
|
|
run_ocr(work_src, work_out, ocr_cfg)
|
|
except Exception as e: # noqa: BLE001 - ocrmypdf wirft viele Typen
|
|
log.exception("OCR fehlgeschlagen für %s", src.name)
|
|
_move_to_error(work_src, error_dir)
|
|
return ProcessResult(src, final_out, False, f"ocr failed: {e}")
|
|
|
|
vera_ok: bool | None = None
|
|
if vera_cfg.enabled:
|
|
try:
|
|
vera_ok = run_verapdf(work_out, vera_cfg)
|
|
except VeraPdfUnavailable as e:
|
|
# Kein Urteil über die Datei — also darf auch nichts entsorgt
|
|
# werden. Original UND OCR-Ergebnis gehen nach error/; das
|
|
# Original bleibt damit unabhängig von
|
|
# [output].original_on_success erhalten.
|
|
log.error(
|
|
"veraPDF nicht aufrufbar (%s) — %s wird NICHT als ungültig "
|
|
"gewertet: Original und OCR-Ergebnis liegen in %s, das "
|
|
"Original wurde weder gelöscht noch archiviert. "
|
|
"[verapdf].binary prüfen (--check-config)",
|
|
e, src.name, error_dir,
|
|
)
|
|
_move_to_error(work_out, error_dir)
|
|
_move_to_error(work_src, error_dir)
|
|
return ProcessResult(src, final_out, False,
|
|
f"veraPDF nicht aufrufbar: {e}")
|
|
if not vera_ok:
|
|
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
|
|
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
|
|
# [output].original_on_success-Regel wie im Erfolgsfall, sonst
|
|
# verliert man es ausgerechnet im Fehlerfall (archive!).
|
|
_move_to_error(work_out, error_dir)
|
|
_dispose_original(work_src, src.name, output_cfg)
|
|
log.error(
|
|
"veraPDF FAIL: %s — OCR-Ergebnis nach %s verschoben, Original %s",
|
|
src.name, error_dir,
|
|
"archiviert" if output_cfg.original_on_success == "archive" else "gelöscht",
|
|
)
|
|
return ProcessResult(src, final_out, False,
|
|
"verapdf validation failed", verapdf_passed=False)
|
|
|
|
outgoing_dir.mkdir(parents=True, exist_ok=True)
|
|
# Liegt in outgoing/ schon eine Datei desselben Namens (Scanner liefert
|
|
# denselben Dateinamen ein zweites Mal, oder das Vorgängerergebnis wurde
|
|
# noch nicht abgeholt), würde der move sie kommentarlos überschreiben.
|
|
# Stattdessen derselbe Zeitstempel-Ausweg wie im Archiv.
|
|
final_out = _collision_free_path(final_out)
|
|
if final_out.name != out_name:
|
|
log.warning(
|
|
"In %s liegt bereits eine Datei %s — das neue OCR-Ergebnis wird "
|
|
"als %s abgelegt, damit das ältere nicht überschrieben wird",
|
|
outgoing_dir, out_name, final_out.name,
|
|
)
|
|
shutil.move(str(work_out), str(final_out))
|
|
# Scheitert die Entsorgung des Originals (Platte voll, read-only), ist der
|
|
# Durchlauf trotzdem gelungen: das fertige PDF liegt bereits in outgoing/.
|
|
# Der Fehler darf ihn deshalb nicht entwerten — sonst unterbleibt der
|
|
# Upload und das Ergebnis bleibt liegen. Er wird als Warnung
|
|
# weitergereicht und landet in der Benachrichtigung.
|
|
warning = _dispose_original(work_src, src.name, output_cfg)
|
|
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok,
|
|
warning=warning)
|
|
|
|
|
|
def _is_same_file(a: Path, b: Path) -> bool:
|
|
"""Zeigen beide Pfade auf dieselbe Datei? (verträgt fehlende Dateien)"""
|
|
try:
|
|
return a.resolve() == b.resolve()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _collision_free_path(dest: Path) -> Path:
|
|
"""Weicht einem schon belegten Zielnamen per Zeitstempel-Suffix aus.
|
|
|
|
Einheitlich für outgoing/ und Archiv: `scan.pdf` wird zu
|
|
`scan_20260923-081500.pdf`. Ist auch der Zeitstempel-Name belegt (zwei
|
|
Dateien innerhalb derselben Sekunde, z.B. bei mehreren Workern), wird
|
|
zusätzlich hochgezählt — sonst überschriebe der anschließende `move` doch
|
|
wieder still.
|
|
|
|
Der Rest bleibt unverändert: existiert das Ziel nicht, kommt es
|
|
unverändert zurück.
|
|
"""
|
|
if not dest.exists():
|
|
return dest
|
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
candidate = dest.with_name(f"{dest.stem}_{ts}{dest.suffix}")
|
|
counter = 2
|
|
while candidate.exists():
|
|
candidate = dest.with_name(f"{dest.stem}_{ts}-{counter}{dest.suffix}")
|
|
counter += 1
|
|
return candidate
|
|
|
|
|
|
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> str:
|
|
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
|
|
|
|
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
|
|
Validierung ablehnt: auch dann soll `archive` das Original erhalten.
|
|
|
|
Wirft bewusst NICHT: zum Aufrufzeitpunkt liegt das fertige PDF schon in
|
|
outgoing/. Eine Exception von hier würde den gelungenen Durchlauf im
|
|
Catch-all des Service in einen Fehler verwandeln — mitsamt
|
|
ausgefallenem Upload.
|
|
|
|
Returns:
|
|
Leerer String = erledigt. Sonst die Fehlermeldung (bereits geloggt).
|
|
"""
|
|
if not work_src.exists():
|
|
return ""
|
|
mode = cfg.original_on_success
|
|
if mode == "archive" and cfg.archive_dir:
|
|
archive = Path(cfg.archive_dir)
|
|
try:
|
|
archive.mkdir(parents=True, exist_ok=True)
|
|
# Bei Namens-Kollision mit Timestamp umbenennen (gleicher Weg wie
|
|
# für das Ergebnis in outgoing/)
|
|
dest = _collision_free_path(archive / original_name)
|
|
shutil.move(str(work_src), str(dest))
|
|
except OSError as e:
|
|
return _disposal_failed(work_src, original_name,
|
|
f"nicht nach {archive} archiviert", e)
|
|
log.info("Original archiviert: %s", dest)
|
|
return ""
|
|
|
|
if mode == "archive":
|
|
log.error("original_on_success=archive aber archive_dir ist leer — "
|
|
"lösche stattdessen")
|
|
elif mode != "delete":
|
|
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
|
|
try:
|
|
work_src.unlink(missing_ok=True)
|
|
except OSError as e:
|
|
return _disposal_failed(work_src, original_name, "nicht gelöscht", e)
|
|
return ""
|
|
|
|
|
|
def _disposal_failed(work_src: Path, original_name: str, was: str,
|
|
exc: OSError) -> str:
|
|
"""Einheitliche Meldung, wenn das Original nicht entsorgt werden konnte."""
|
|
msg = (
|
|
f"Original {original_name} konnte {was} werden ({exc}). Das OCR-PDF ist "
|
|
f"fertig und wird normal ausgeliefert, das Original liegt aber "
|
|
f"weiterhin in {work_src.parent} — es wird beim nächsten Start dort "
|
|
f"aufgegriffen und ein zweites Mal durch das OCR geschickt. Bitte "
|
|
f"{work_src} von Hand aufräumen und die Ursache beheben "
|
|
f"(Plattenplatz, Schreibrechte)."
|
|
)
|
|
log.error("%s", msg)
|
|
return msg
|
|
|
|
|
|
def _move_to_error(p: Path, error_dir: Path) -> None:
|
|
"""Verschiebt eine Datei ins error-Verzeichnis, ohne dort etwas zu überschreiben.
|
|
|
|
Scheitert dieselbe `scan.pdf` zweimal, ersetzte die zweite bisher still die
|
|
erste — dieselbe Datenverlust-Klasse wie in outgoing/. Deshalb derselbe
|
|
Zeitstempel-Ausweg über `_collision_free_path()`.
|
|
"""
|
|
error_dir.mkdir(parents=True, exist_ok=True)
|
|
dest = _collision_free_path(error_dir / p.name)
|
|
if dest.name != p.name:
|
|
log.warning(
|
|
"In %s liegt bereits eine Datei %s — die neue wird als %s abgelegt, "
|
|
"damit die ältere nicht überschrieben wird",
|
|
error_dir, p.name, dest.name,
|
|
)
|
|
try:
|
|
shutil.move(str(p), str(dest))
|
|
except OSError:
|
|
log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p)
|