fix: ocrmypdf-Pin auf 17.4.1, Preflight erkennt den GS-Fall, Rauchtest (v0.6.1)

v0.6.0 hat ocrmypdf auf 16.13.0 gepinnt, um einen ungewollten Major-Sprung
zu verhindern. Auf Bestandsinstallationen war das ein DOWNGRADE (dort lief
via ">=16.0" bereits 17.x) — und 16.13.0 bricht auf Debian 12 mit dem
Bord-Ghostscript 10.0.0 bei JEDER PDF ab, sobald skip_text gesetzt ist.
Im Test auf CT 200 lief das Update mit Exit 0 durch, der Dienst blieb
"active", --check-config meldete "Preflight ok" — und jede Datei landete
in error/. Stiller Totalausfall.

- requirements.txt: ocrmypdf==17.4.1 (real auf Debian 12 + gs 10.0.0
  verifiziert). Ab 17.0.0 steht die GS-Pruefung in ocrmypdf unter einem
  `if options.output_type.startswith('pdfa')`; bis 16.x lief sie ohne
  diesen Guard und schlug auch bei output_type="pdf" zu.
- check_preflight() prueft Ghostscript nicht mehr nur bei gesetztem
  pdfa_level, sondern bildet die reale Bedingung ab:
  betroffene GS-Version UND skip_text UND (PDF/A ODER ocrmypdf < 17).
  Der Dienst bricht damit beim Start ab statt bei der ersten Datei.
- update.sh zeigt Versionsspruenge der gepinnten Pakete; Downgrades als
  WARN, auch in der Abschluss-Zusammenfassung.
- update.sh faehrt nach dem Start einen Rauchtest (eingebettete Mini-PDF
  durch die echte Pipeline) und raeumt restlos auf. Uebersprungen, wenn
  Upload-Ziele oder E-Mail-Notify aktiv sind, damit kein Testmuell zum
  Kunden geht. Abschaltbar mit --no-smoke-test.
- Doku korrigiert: pdfa_level = "" allein ist keine Entwarnung, die haengt
  an der ocrmypdf-Version.

152 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:55:47 +02:00
parent 3e24aa2ecd
commit aa9918adba
15 changed files with 1044 additions and 86 deletions
+113 -15
View File
@@ -35,11 +35,20 @@ class PreflightError(RuntimeError):
# Pflicht-Binaries für ocrmypdf
_REQUIRED_BINARIES = ("tesseract", "gs")
# Ghostscript-Versionen mit bekanntem PDF/A+skip_text Bug (Issue #3):
# Ghostscript-Versionen mit bekanntem Bug (Issue #3):
# 10.0.0 .. 10.02.0 (inklusive). Ab 10.02.1 wieder nutzbar.
_GS_BROKEN_MIN = (10, 0, 0)
_GS_BROKEN_MAX = (10, 2, 0)
# Ab dieser ocrmypdf-Major steht die Ghostscript-Pruefung hinter
# `if options.output_type.startswith('pdfa')` — mit pdfa_level = "" wird
# Ghostscript gar nicht angefasst und die Pruefung greift nicht.
# Darunter (16.x und aelter) laeuft sie BEDINGUNGSLOS, also auch bei
# output_type="pdf": dort reicht skip_text=true, um auf Debian 12 jede
# einzelne PDF scheitern zu lassen. Quelle jeweils
# ocrmypdf/builtin_plugins/ghostscript.py::check_options().
_OCRMYPDF_GS_GUARD_MAJOR = 17
def _parse_version(text: str) -> tuple[int, ...] | None:
"""Extrahiert die erste X.Y[.Z] Version aus einem String."""
@@ -50,7 +59,7 @@ def _parse_version(text: str) -> tuple[int, ...] | None:
def is_ghostscript_broken(version: str | None) -> bool:
"""Prüft, ob eine Ghostscript-Version vom PDF/A+skip_text Bug betroffen ist.
"""Prüft, ob eine Ghostscript-Version vom bekannten Bug betroffen ist.
Betrifft 10.0.0 bis einschließlich 10.02.0. Ab 10.02.1 wieder sicher.
"""
@@ -79,6 +88,36 @@ def detect_ghostscript_version() -> str | None:
return result.stdout.strip() or None
def detect_ocrmypdf_version() -> str | None:
"""Liest die installierte ocrmypdf-Version aus den Paket-Metadaten.
Bewusst über `importlib.metadata` statt über einen Import: das ist
billiger und funktioniert auch in den Tests, in denen ocrmypdf gar nicht
installiert ist (dann None).
"""
try:
from importlib.metadata import version
return version("ocrmypdf")
except Exception: # noqa: BLE001 - fehlende Metadaten dürfen nichts umwerfen
return None
def ocrmypdf_checks_gs_always(version: str | None) -> bool:
"""True, wenn ocrmypdf die Ghostscript-Pruefung unabhaengig vom output_type fährt.
Das ist bei 16.x und aelter der Fall (siehe `_OCRMYPDF_GS_GUARD_MAJOR`).
Ist die Version unbekannt, wird `False` angenommen: der Pin in
requirements.txt steht auf 17.x, und ein Fehlalarm, der den Dienst nicht
starten laesst, waere schlimmer als die fehlende Warnung.
"""
if not version:
return False
parsed = _parse_version(version)
if parsed is None:
return False
return parsed[0] < _OCRMYPDF_GS_GUARD_MAJOR
def check_output_config(mode: str, archive_dir: str,
name_mode: str = "prefix") -> None:
"""Validiert die [output]-Section. Wirft PreflightError bei Problemen."""
@@ -101,12 +140,13 @@ def check_output_config(mode: str, archive_dir: str,
)
def check_preflight(pdfa_level: str = "") -> None:
def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None:
"""Prüft externe Abhängigkeiten.
- Tesseract und Ghostscript müssen im PATH sein
- Bei gesetztem pdfa_level wird die Ghostscript-Version gegen den
bekannten 10.0.0–10.02.0 Bug geprüft
- Die Ghostscript-Version wird gegen den bekannten 10.0.0–10.02.0 Bug
geprüft, und zwar genau unter der Bedingung, unter der ocrmypdf selbst
abbricht (siehe `_gs_block_reason`).
Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript.
"""
@@ -117,15 +157,73 @@ def check_preflight(pdfa_level: str = "") -> None:
+ ". Bitte installieren: sudo apt install tesseract-ocr ghostscript"
)
reason = _gs_block_reason(pdfa_level, skip_text)
if reason:
raise PreflightError(reason)
def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None:
"""Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht.
Abgebildet wird die reale Bedingung aus
`ocrmypdf/builtin_plugins/ghostscript.py::check_options()`:
betroffene GS-Version UND (skip_text ODER redo_ocr)
UND (PDF/A-Ausgabe ODER ocrmypdf < 17)
Die letzte Klammer ist der Teil, der v0.6.0 durchrutschen ließ: bis
einschließlich ocrmypdf 16.x steht die Pruefung ohne jeden Guard in
`check_options()` und schlaegt deshalb auch bei `output_type="pdf"` zu.
Ab 17.0.0 umschliesst sie ein
`if options.output_type.startswith('pdfa'):` — ohne PDF/A wird
Ghostscript nicht angefasst.
`redo_ocr` kennt unsere Config nicht (es gibt keinen entsprechenden Key in
`OcrConfig`), deshalb steht es hier bewusst nicht in der Bedingung.
Returns:
Fehlermeldung oder None, wenn die Kombination unkritisch ist.
"""
if not skip_text:
# Weder skip_text noch redo_ocr — ocrmypdf fasst den Pfad nicht an.
return None
gs_version = detect_ghostscript_version()
if not is_ghostscript_broken(gs_version):
return None
ocrmypdf_version = detect_ocrmypdf_version()
always = ocrmypdf_checks_gs_always(ocrmypdf_version)
if not pdfa_level and not always:
return None
if pdfa_level:
gs_version = detect_ghostscript_version()
if is_ghostscript_broken(gs_version):
raise PreflightError(
f"Ghostscript {gs_version} ist mit pdfa_level='{pdfa_level}' nicht "
"kompatibel (bekannter Bug in 10.0.0–10.02.0). "
"Entweder ghostscript auf >=10.02.1 upgraden (z.B. via bookworm-backports) "
"oder in der Config [ocr].pdfa_level = \"\" setzen."
)
ursache = (
f"[ocr].pdfa_level = {pdfa_level!r} (PDF/A-Ausgabe) zusammen mit "
"[ocr].skip_text = true"
)
else:
ursache = (
f"[ocr].skip_text = true und ocrmypdf {ocrmypdf_version} — bis "
f"einschließlich {_OCRMYPDF_GS_GUARD_MAJOR - 1}.x prüft ocrmypdf "
"Ghostscript auch dann, wenn gar kein PDF/A erzeugt wird. Jede "
"einzelne PDF würde in error/ landen"
)
return (
f"Ghostscript {gs_version} ist von einem bekannten Fehler betroffen "
"(10.0.0–10.02.0, der Debian-12-Standard) und wird von ocrmypdf "
f"abgelehnt: {ursache}. "
"Abhilfe — eines von beidem: "
"(1) Ghostscript >= 10.02.1 aus bookworm-backports installieren "
"(install.sh bietet das an): "
"echo 'deb http://deb.debian.org/debian bookworm-backports main' | "
"sudo tee /etc/apt/sources.list.d/bookworm-backports.list && "
"sudo apt update && sudo apt install -t bookworm-backports ghostscript — "
"oder (2) in der Config [ocr].skip_text = false setzen "
"(dann wird vorhandener Text neu erkannt statt übersprungen)"
+ (" bzw. [ocr].pdfa_level = \"\"." if pdfa_level else ".")
)
def _is_pdf(path: Path) -> bool:
@@ -201,7 +299,7 @@ class HotfolderService:
# ---- Lifecycle ----
def run(self) -> None:
check_preflight(self.cfg.ocr.pdfa_level)
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
check_output_config(self.cfg.output.original_on_success,
self.cfg.output.archive_dir,
self.cfg.output.name_mode)
@@ -228,7 +326,7 @@ class HotfolderService:
Returns:
Anzahl fehlgeschlagener PDFs (0 = alles ok).
"""
check_preflight(self.cfg.ocr.pdfa_level)
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
check_output_config(self.cfg.output.original_on_success,
self.cfg.output.archive_dir,
self.cfg.output.name_mode)