"""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)