Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 305454eeb5 | |||
| 04dc3c7b72 | |||
| aa9918adba |
+13
-9
@@ -1,8 +1,8 @@
|
|||||||
# AI Agent Briefing — PDF OCR Hotfolder
|
# AI Agent Briefing — PDF OCR Hotfolder
|
||||||
|
|
||||||
**Zuletzt aktualisiert:** 2026-09-22
|
**Zuletzt aktualisiert:** 2026-09-23
|
||||||
**Version:** 0.6.0
|
**Version:** 0.6.3
|
||||||
**Status:** Multi-Instanz-Betrieb, Preflight-Checks, Fehlerzählung, Wiederaufnahme aus `working/` und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation). Test-Suite grün (135 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6 und aus Vorbereitungen auf Debian 13, nicht aus einem belegten Dauerbetrieb.
|
**Status:** Multi-Instanz-Betrieb, Preflight-Checks, Fehlerzählung, Wiederaufnahme aus `working/` und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). Test-Suite grün (152 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6 und aus Vorbereitungen auf Debian 13, nicht aus einem belegten Dauerbetrieb.
|
||||||
|
|
||||||
> **Betriebsabläufe stehen nicht hier**, sondern in:
|
> **Betriebsabläufe stehen nicht hier**, sondern in:
|
||||||
> [README.md](README.md) (Einstieg, Layout, Config-Überblick) ·
|
> [README.md](README.md) (Einstieg, Layout, Config-Überblick) ·
|
||||||
@@ -26,7 +26,7 @@ pdf-ocr-hotfolder/
|
|||||||
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler
|
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler
|
||||||
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
|
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
|
||||||
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
|
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
|
||||||
├── tests/ # pytest-Suite (135 Tests, ocrmypdf wird gemockt)
|
├── tests/ # pytest-Suite (152 Tests, ocrmypdf wird gemockt)
|
||||||
│ ├── conftest.py # Fixtures tmp_config / dummy_pdf
|
│ ├── conftest.py # Fixtures tmp_config / dummy_pdf
|
||||||
│ ├── test_check_config.py # --check-config, Exit 0/1/2
|
│ ├── test_check_config.py # --check-config, Exit 0/1/2
|
||||||
│ ├── test_config_errors.py
|
│ ├── test_config_errors.py
|
||||||
@@ -50,7 +50,7 @@ pdf-ocr-hotfolder/
|
|||||||
├── config.example.toml
|
├── config.example.toml
|
||||||
├── install.sh # Interaktiver Installer + Instanz-Manager
|
├── install.sh # Interaktiver Installer + Instanz-Manager
|
||||||
├── update.sh # Updater (--help, --rebuild-venv), ~860 Zeilen
|
├── update.sh # Updater (--help, --rebuild-venv), ~860 Zeilen
|
||||||
├── requirements.txt # feste Pins (ocrmypdf 16.x!)
|
├── requirements.txt # feste Pins (ocrmypdf 17.x — 16.x ist unbrauchbar, s. 0.6.1)
|
||||||
├── VERSION
|
├── VERSION
|
||||||
├── CHANGELOG.md
|
├── CHANGELOG.md
|
||||||
├── README.md
|
├── README.md
|
||||||
@@ -230,7 +230,7 @@ wenn der installierte Code das Flag noch nicht kennt.
|
|||||||
## 🔄 Verarbeitungs-Flow
|
## 🔄 Verarbeitungs-Flow
|
||||||
|
|
||||||
**Beim Start (`run()` wie `run_once()`), vor allem anderen:**
|
**Beim Start (`run()` wie `run_once()`), vor allem anderen:**
|
||||||
1. `check_preflight()` — `tesseract` und `gs` müssen im PATH sein; ist `pdfa_level` gesetzt, wird zusätzlich die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft
|
1. `check_preflight(pdfa_level, skip_text)` — `tesseract` und `gs` müssen im PATH sein; zusätzlich wird die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft, und zwar unter genau der Bedingung, unter der ocrmypdf selbst abbricht (`_gs_block_reason()`: betroffene GS-Version **und** `skip_text` **und** (`pdfa_level` gesetzt **oder** ocrmypdf < 17))
|
||||||
2. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
|
2. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
|
||||||
3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config)
|
3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config)
|
||||||
4. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`
|
4. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`
|
||||||
@@ -289,7 +289,11 @@ Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool
|
|||||||
|
|
||||||
## ⚠️ Fallstricke
|
## ⚠️ Fallstricke
|
||||||
|
|
||||||
- **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. In Kombination aus `[ocr].pdfa_level` + `skip_text = true` blockiert ocrmypdf komplett (Issue #3). Deshalb ist `pdfa_level = ""` der sichere Default, der Preflight bricht mit Exit 2 ab, wenn `pdfa_level` gesetzt **und** die GS-Version betroffen ist, und `--check-config` warnt bei gesetztem `pdfa_level` grundsätzlich. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an).
|
- **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. ocrmypdf verweigert damit die Arbeit — aber die Bedingung dafür hängt an der **ocrmypdf-Version**, und genau daran ist 0.6.0 gescheitert:
|
||||||
|
- **ocrmypdf ≤ 16.x**: die Prüfung in `builtin_plugins/ghostscript.py::check_options()` läuft **bedingungslos**. `skip_text = true` allein reicht — `output_type` wird nicht geprüft. Auf Debian 12 scheitert damit **jede** Datei.
|
||||||
|
- **ocrmypdf ≥ 17.0**: derselbe Block steckt in einem `if options.output_type.startswith('pdfa'):`. Ohne PDF/A wird Ghostscript nicht angefasst.
|
||||||
|
|
||||||
|
`pdfa_level = ""` ist deshalb **kein** Schutz für sich genommen — es wirkt nur mit ocrmypdf ≥ 17. `requirements.txt` pinnt daher 17.x; ein Downgrade auf 16.x macht jede Debian-12-Instanz unbrauchbar, bei grünem `systemctl status`. Der Preflight bildet die reale Bedingung ab (`_gs_block_reason()`) und bricht mit Exit 2 ab, `--check-config` meldet denselben Zustand als Fehler. `redo_ocr` ist bewusst **nicht** in der Bedingung: die Config kennt keinen solchen Key. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an) oder `skip_text = false`.
|
||||||
- **`[ocr].timeout` ist ein Timeout PRO SEITE**, kein Gesamt-Timeout pro PDF. Der Wert geht als `tesseract_timeout` (ocrmypdf-Option `--tesseract-timeout`) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default `1800` in einer Config stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300, ab 900 warnt `--check-config`. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird bei `0` (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift.
|
- **`[ocr].timeout` ist ein Timeout PRO SEITE**, kein Gesamt-Timeout pro PDF. Der Wert geht als `tesseract_timeout` (ocrmypdf-Option `--tesseract-timeout`) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default `1800` in einer Config stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300, ab 900 warnt `--check-config`. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird bei `0` (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift.
|
||||||
- **`TimeoutStopSec=300` in der Unit ist Absicht.** Ein laufendes OCR soll beim Stoppen zu Ende laufen dürfen — ein `systemctl stop` kann deshalb pro Instanz bis zu 5 Minuten dauern, und `update.sh` (das nacheinander stoppt) entsprechend länger. Bei SIGKILL bliebe das Original in `working/` liegen; das wird zwar wiederaufgenommen, kostet aber den kompletten Durchlauf.
|
- **`TimeoutStopSec=300` in der Unit ist Absicht.** Ein laufendes OCR soll beim Stoppen zu Ende laufen dürfen — ein `systemctl stop` kann deshalb pro Instanz bis zu 5 Minuten dauern, und `update.sh` (das nacheinander stoppt) entsprechend länger. Bei SIGKILL bliebe das Original in `working/` liegen; das wird zwar wiederaufgenommen, kostet aber den kompletten Durchlauf.
|
||||||
- **Die venv hängt an der Python-Version der Distribution.** Nach einem Debian-Major-Upgrade ist `venv/bin/python` tot (systemd: `203/EXEC`) oder eine andere Version als das System-Python. Der Weg dahin und zurück steht in [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md); im Code prüfen `install.sh` und `update.sh` das je mit einem eigenen `venv_is_healthy()` (die Variante in `update.sh` ist die gründlichere und schaut zusätzlich in `pyvenv.cfg`).
|
- **Die venv hängt an der Python-Version der Distribution.** Nach einem Debian-Major-Upgrade ist `venv/bin/python` tot (systemd: `203/EXEC`) oder eine andere Version als das System-Python. Der Weg dahin und zurück steht in [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md); im Code prüfen `install.sh` und `update.sh` das je mit einem eigenen `venv_is_healthy()` (die Variante in `update.sh` ist die gründlichere und schaut zusätzlich in `pyvenv.cfg`).
|
||||||
@@ -312,14 +316,14 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
|
|||||||
|
|
||||||
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
|
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
|
||||||
```bash
|
```bash
|
||||||
pytest # aktuell 135 Tests
|
pytest # aktuell 152 Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
`ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene.
|
`ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene.
|
||||||
|
|
||||||
## 📋 Roadmap / TODO
|
## 📋 Roadmap / TODO
|
||||||
|
|
||||||
- [x] Tests (`pytest`) für `processor` und `uploaders` — 135 Tests
|
- [x] Tests (`pytest`) für `processor` und `uploaders` — 152 Tests
|
||||||
- [x] Wiederaufnahme abgebrochener Läufe aus `working/`
|
- [x] Wiederaufnahme abgebrochener Läufe aus `working/`
|
||||||
- [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater
|
- [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater
|
||||||
- [x] Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback)
|
- [x] Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback)
|
||||||
|
|||||||
+173
@@ -1,5 +1,178 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.6.3] - 2026-09-23
|
||||||
|
|
||||||
|
Reine Doku-Version — kein Code, kein Installer, kein Updater, keine Unit, keine
|
||||||
|
Tests angefasst (ausser dem Versionsstring). Beide Erkenntnisse stammen aus den
|
||||||
|
Testlaeufen auf Debian 12 und Debian 13.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Abschnitt "Systemanforderungen" in `docs/INSTALLATION.md`.** Die
|
||||||
|
Dimensionierung fehlte bisher komplett. Empfohlen werden **mindestens 2 GB
|
||||||
|
RAM**; 512 MB reichen fuer 300-dpi-Scans nachweislich nicht. Gemessen auf
|
||||||
|
einem LXC-Container mit 512 MB RAM + 512 MB Swap, Debian 13,
|
||||||
|
Ghostscript 10.05.1: eine **einzelne A4-Seite in 300 dpi** mit
|
||||||
|
`deskew = true` riss das cgroup-Limit und der Dienst wurde vom OOM-Killer
|
||||||
|
beendet (`oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/201,
|
||||||
|
task=python`, `total-vm:1168764kB, anon-rss:489676kB`). Dieselbe
|
||||||
|
Verarbeitung mit einer kleineren Seite (850x1100 px) lief in ca. 19 s sauber
|
||||||
|
durch.
|
||||||
|
- Dokumentiert ist der Zusammenhang **RAM <-> `max_workers` x `jobs` x
|
||||||
|
Aufloesung**: `max_workers` (Default 2) laesst zwei solcher Seiten
|
||||||
|
gleichzeitig laufen, der Spitzenbedarf multipliziert sich entsprechend.
|
||||||
|
Wer knapp dimensioniert, zieht zuerst `max_workers` herunter.
|
||||||
|
- Dazu, **wie sich ein OOM-Kill aeussert** (Dienst weg bzw. von systemd neu
|
||||||
|
gestartet, `NRestarts` steigt, Abbruch mitten in der Datei ohne Traceback,
|
||||||
|
PDF bleibt in `working/` liegen) und **wie man ihn nachweist**
|
||||||
|
(`/sys/fs/cgroup/memory.events` im Container, `dmesg` auf dem LXC-Host).
|
||||||
|
Ohne diese Pruefung sieht der Fall wie ein Anwendungsfehler aus und man
|
||||||
|
sucht in ocrmypdf, Tesseract oder der Config.
|
||||||
|
- Mit dem Hinweis, dass die Wiederaufnahme aus `working/` seit v0.6.0 den
|
||||||
|
Datenverlust abfaengt — der OOM selbst bleibt aber ein Problem: bei
|
||||||
|
unveraenderter Dimensionierung laeuft dieselbe Datei nach dem Neustart
|
||||||
|
erneut hinein.
|
||||||
|
- `README.md` bekommt im Schnellstart nur einen Einzeiler mit Verweis, keine
|
||||||
|
Dublette.
|
||||||
|
- **Troubleshooting-Eintrag "Keine Logs: No journal files were found" in
|
||||||
|
`docs/INSTALLATION.md`.** Auf einem der Testcontainer war
|
||||||
|
`systemd-journald.service` kaputt (`failed`, `status=243/CREDENTIALS`),
|
||||||
|
`journalctl` lieferte `No journal files were found.` Da der Dienst seit
|
||||||
|
v0.4.1 **ausschliesslich** nach journald loggt (kein FileHandler, kein
|
||||||
|
Logverzeichnis — bewusste Entscheidung), gibt es dann gar keine Dienstlogs:
|
||||||
|
ein blinder Fleck, der vor jeder Fehlersuche per
|
||||||
|
`systemctl status systemd-journald` auszuschliessen ist. Als Notbehelf ist
|
||||||
|
der Vordergrund-Aufruf dokumentiert
|
||||||
|
(`cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m
|
||||||
|
pdf_ocr_hotfolder --config ...`, das `cd` ist zwingend, s. 0.6.2).
|
||||||
|
Ausdruecklich festgehalten: das ist **kein** generelles LXC-Muster — der
|
||||||
|
zweite Testcontainer war in Ordnung, es war Schaden auf genau dieser
|
||||||
|
Maschine.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `AI_AGENT_BRIEFING.md`: der Kommentar zu `requirements.txt` in der
|
||||||
|
Projektstruktur nannte noch "ocrmypdf 16.x!" — genau die Version, die seit
|
||||||
|
0.6.1 als unbrauchbar gilt und gegen die der Pin schuetzt. Korrigiert auf
|
||||||
|
17.x.
|
||||||
|
|
||||||
|
## [0.6.2] - 2026-09-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Der `--check-config`-Befehl, den `update.sh` in der Zusammenfassung ausgibt,
|
||||||
|
lief so wie gedruckt nicht (`No module named pdf_ocr_hotfolder`). Das Paket
|
||||||
|
wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert und
|
||||||
|
nur ueber das Arbeitsverzeichnis gefunden — dem Hinweis fehlte das
|
||||||
|
vorangestellte `cd`. Betraf auch die Beispiele in README.md,
|
||||||
|
docs/INSTALLATION.md, docs/UPDATE.md und docs/OS-UPGRADE.md.
|
||||||
|
Gefunden beim Update-Test v0.3.1 -> v0.6.1 auf Debian 12.
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-09-22
|
||||||
|
|
||||||
|
> **Fuer Bestandsinstallationen wichtig.** Wer 0.6.0 bereits eingespielt hat,
|
||||||
|
> laeuft auf Debian 12 mit hoher Wahrscheinlichkeit im Totalausfall: der Dienst
|
||||||
|
> meldet `active`, `--check-config` meldet "Preflight ok" — und **jede** PDF
|
||||||
|
> landet in `error/`. Nach dem Update auf 0.6.1 nachsehen, ob in `error/`
|
||||||
|
> unverarbeitete Dateien liegen, und diese zurueck nach `incoming/` schieben.
|
||||||
|
> Der Updater faehrt jetzt selbst einen Rauchtest, der so einen Zustand sofort
|
||||||
|
> aufdeckt.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **ocrmypdf-Pin von 16.13.0 auf 17.4.1 korrigiert — das war ein stiller
|
||||||
|
Totalausfall.** 0.6.0 pinnte `ocrmypdf==16.13.0`. Auf Bestandssystemen mit
|
||||||
|
vorher `ocrmypdf>=16.0` war das ein **Downgrade** von 17.4.1, und auf
|
||||||
|
Debian 12 (Ghostscript 10.0.0) bricht ocrmypdf 16.13.0 bei jeder PDF ab:
|
||||||
|
|
||||||
|
```
|
||||||
|
MissingDependencyError: Ghostscript 10.0.0 through 10.02.0 (your version:
|
||||||
|
10.0.0) contain serious regressions that corrupt PDFs with existing text
|
||||||
|
```
|
||||||
|
|
||||||
|
Ursache, verifiziert im Quelltext von
|
||||||
|
`ocrmypdf/builtin_plugins/ghostscript.py::check_options()`: bis
|
||||||
|
einschliesslich 16.x laeuft die Ghostscript-Pruefung **bedingungslos** —
|
||||||
|
`skip_text=true` allein genuegt, `output_type` wird gar nicht geprueft,
|
||||||
|
obwohl die Fehlermeldung selbst `--output-type pdf` empfiehlt. Ab **17.0.0**
|
||||||
|
umschliesst denselben Block ein
|
||||||
|
`if options.output_type.startswith('pdfa'):`; ohne PDF/A wird Ghostscript
|
||||||
|
nicht angefasst. `run_ocr()` setzt bei leerem `pdfa_level` genau
|
||||||
|
`output_type="pdf"` und hielt sich damit faelschlich fuer sicher.
|
||||||
|
|
||||||
|
Betroffen war nicht nur das Update, sondern ebenso jede **Neuinstallation**:
|
||||||
|
`skip_text = true` ist der Default. — 17.4.1 ist die auf Debian 12 + gs 10.0.0
|
||||||
|
real verifizierte Version; `skip_text` bleibt in 17.x als Alias fuer
|
||||||
|
`mode='skip'` unterstuetzt, `run_ocr()` musste nicht angepasst werden.
|
||||||
|
|
||||||
|
- **Preflight prueft jetzt die reale Bedingung.** `check_preflight()` sah die
|
||||||
|
Ghostscript-Version bisher nur bei gesetztem `pdfa_level` an (`if pdfa_level:`)
|
||||||
|
— genau deshalb ging der kaputte Zustand als "Preflight ok" durch. Die neue
|
||||||
|
Bedingung (`_gs_block_reason()`) bildet ocrmypdf nach:
|
||||||
|
|
||||||
|
```
|
||||||
|
betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17)
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Signatur ist jetzt `check_preflight(pdfa_level, skip_text)`; alle
|
||||||
|
Aufrufstellen (`run()`, `run_once()`, `--check-config`) reichen beides durch.
|
||||||
|
`redo_ocr` steht bewusst **nicht** in der Bedingung: die Config kennt keinen
|
||||||
|
solchen Key, und ein erfundener waere schlimmer als ein fehlender.
|
||||||
|
|
||||||
|
Ergebnis: der Dienst bricht beim **Start** mit Exit 2 ab statt bei der ersten
|
||||||
|
Datei, und `--check-config` meldet den Zustand als **Fehler** (Exit 2) — also
|
||||||
|
auch mitten im Update. Die Meldung nennt beide Auswege: Ghostscript >= 10.02.1
|
||||||
|
aus bookworm-backports (der Installer bietet das an) oder
|
||||||
|
`[ocr].skip_text = false`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`update.sh` macht Versionsspruenge der Kernabhaengigkeiten sichtbar.** Die
|
||||||
|
Versionen der in `requirements.txt` gepinnten Pakete werden vor und nach
|
||||||
|
`pip install` gemessen; Downgrades erscheinen als `[WARN]`, Upgrades und neue
|
||||||
|
Pakete als `[INFO]`, beides zusaetzlich in der Abschluss-Zusammenfassung. Das
|
||||||
|
Downgrade 17.4.1 -> 16.13.0 verschwand bisher wortlos hinter
|
||||||
|
`[INFO] Dependencies ok ✓`.
|
||||||
|
- **Rauchtest in `update.sh`.** Nach dem Start jeder Instanz geht eine winzige
|
||||||
|
Test-PDF durch die **echte** Pipeline; der Test gilt als bestanden, wenn sie
|
||||||
|
in `outgoing/` ankommt. Erst das deckt einen Totalausfall auf, den systemd
|
||||||
|
nicht sieht.
|
||||||
|
- Die Test-PDF (694 Bytes, eine Seite) steckt als base64 im Skript — kein
|
||||||
|
Pillow, kein `gs`, kein `convert` noetig.
|
||||||
|
- Eindeutiger Dateiname (`__smoketest_update_<zeitstempel>_<pid>.pdf`), der
|
||||||
|
mit keiner Kundendatei kollidieren kann.
|
||||||
|
- **Raeumt restlos auf** — Testdatei und Ergebnis, in `incoming/`, `working/`
|
||||||
|
(inkl. `__ocr_`-Zwischendatei), `outgoing/`, `error/` und Archiv, auch bei
|
||||||
|
Fehlschlag und Timeout.
|
||||||
|
- **Uebersprungen** bei Instanzen mit aktivem `[upload.nextcloud]`,
|
||||||
|
`[upload.sftp]`, `[notify.email]` oder `[upload.folder]` mit gesetztem
|
||||||
|
`target`: dort wuerde die Testdatei nach aussen gehen, im Zweifel zum
|
||||||
|
Kunden. `[upload.folder]` ohne `target` schreibt nach `outgoing/` und ist
|
||||||
|
harmlos.
|
||||||
|
- Wartezeit `SMOKE_TIMEOUT` (Standard 90 s), danach durchgefallen — das
|
||||||
|
Skript haengt nicht.
|
||||||
|
- Ein Fehlschlag setzt den Exit-Code auf 1 und nennt den `journalctl`-Befehl,
|
||||||
|
rollt aber **nichts** zurueck.
|
||||||
|
- Abschaltbar mit `--no-smoke-test`, dokumentiert in `--help`.
|
||||||
|
- `--check-config` zeigt zusaetzlich `skip_text` sowie die installierte
|
||||||
|
ocrmypdf- und Ghostscript-Version an.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Doku praezisiert.** `config.example.toml`, `config.py`,
|
||||||
|
`docs/INSTALLATION.md` und `docs/UPDATE.md` behaupteten sinngemaess,
|
||||||
|
`pdfa_level = ""` sei der sichere Default gegen den Ghostscript-Bug. Das
|
||||||
|
stimmt so nicht: die Entwarnung haengt an der ocrmypdf-Version und gilt erst
|
||||||
|
ab 17. Der Ghostscript-Abschnitt in `INSTALLATION.md` stellt die Bedingung
|
||||||
|
jetzt je ocrmypdf-Major gegenueber und nennt `skip_text = false` als zweiten
|
||||||
|
Weg; `UPDATE.md` beschreibt Rauchtest und Versionssprung-Meldung.
|
||||||
|
- Kommentarblock in `requirements.txt` korrigiert: der Schutz gilt gegen den
|
||||||
|
naechsten ungewollten Major-Sprung (18), **nicht** gegen 17 — samt
|
||||||
|
Begruendung, warum 16.x fuer uns unbrauchbar ist.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 152 statt 135 Tests. Neu: die Preflight-Matrix aus GS-Version x `skip_text` x
|
||||||
|
`pdfa_level` x ocrmypdf-Major — darunter der Fall, der durchrutschte
|
||||||
|
(betroffene GS-Version + `skip_text=true` + leeres `pdfa_level` +
|
||||||
|
ocrmypdf 16.x muss `PreflightError` ausloesen) und die Gegenprobe, dass
|
||||||
|
dieselbe Config mit ocrmypdf 17.x **nicht** ausloest (sonst startet keine
|
||||||
|
Debian-12-Bestandsinstanz mehr). Dazu `ocrmypdf_checks_gs_always()`, der
|
||||||
|
Abbruch in `run_once()` und Exit 2 bei `--check-config`.
|
||||||
|
|
||||||
## [0.6.0] - 2026-09-22
|
## [0.6.0] - 2026-09-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING
|
|||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
|
**Voraussetzungen:** Debian 12 oder 13, Python 3.11+, root — und **mindestens
|
||||||
|
2 GB RAM** (512 MB reichen für 300-dpi-Scans nachweislich nicht, siehe
|
||||||
|
[Systemanforderungen](docs/INSTALLATION.md#systemanforderungen)).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
|
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
|
||||||
cd pdf-ocr-hotfolder
|
cd pdf-ocr-hotfolder
|
||||||
@@ -93,7 +97,7 @@ deshalb `640 root:<service-gruppe>` und beim Debuggen nicht in Tickets kopieren.
|
|||||||
Config prüfen, ohne etwas zu verarbeiten:
|
Config prüfen, ohne etwas zu verarbeiten:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ gelöscht.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest # 135 Tests
|
pytest # 152 Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in
|
`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in
|
||||||
@@ -158,5 +162,5 @@ MIT — © Sonith UG
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 0.6.0
|
**Version:** 0.6.3
|
||||||
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
||||||
|
|||||||
+11
-3
@@ -21,9 +21,17 @@ skip_text = true
|
|||||||
# Auflösung für gerasterte Seiten
|
# Auflösung für gerasterte Seiten
|
||||||
oversample = 300
|
oversample = 300
|
||||||
# PDF/A-Konformitätsstufe ("1", "2", "3" oder leer für keinen PDF/A-Output)
|
# PDF/A-Konformitätsstufe ("1", "2", "3" oder leer für keinen PDF/A-Output)
|
||||||
# ACHTUNG: Ghostscript 10.0.0 bis 10.02.0 (Debian 12 default!) haben einen Bug,
|
# ACHTUNG: Ghostscript 10.0.0 bis 10.02.0 (Debian 12 default!) haben einen Bug;
|
||||||
# der mit pdfa_level + skip_text=true ocrmypdf komplett blockiert.
|
# ocrmypdf lehnt damit die Kombination pdfa_level + skip_text=true komplett ab.
|
||||||
# Sicherer Default ist "" — nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist.
|
# Nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist.
|
||||||
|
#
|
||||||
|
# pdfa_level = "" ist deshalb der Default — aber KEIN genereller Schutz gegen
|
||||||
|
# den Ghostscript-Bug: das gilt erst zusammen mit ocrmypdf >= 17. Bis
|
||||||
|
# ocrmypdf 16.x läuft dieselbe Prüfung auch ohne PDF/A, und dann scheitert mit
|
||||||
|
# skip_text = true jede einzelne Datei. Die Entwarnung hängt also an der
|
||||||
|
# ocrmypdf-Version, nicht an dieser Zeile; requirements.txt pinnt darum 17.x.
|
||||||
|
# Der Preflight prüft beides zusammen und lässt den Dienst gar nicht erst
|
||||||
|
# starten, wenn die Kombination nicht trägt.
|
||||||
pdfa_level = ""
|
pdfa_level = ""
|
||||||
# Schiefe Scans automatisch begradigen
|
# Schiefe Scans automatisch begradigen
|
||||||
deskew = true
|
deskew = true
|
||||||
|
|||||||
+181
-13
@@ -12,6 +12,7 @@ Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Ma
|
|||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt |
|
| Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt |
|
||||||
| Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution |
|
| Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution |
|
||||||
|
| Arbeitsspeicher | **mindestens 2 GB** für den produktiven Betrieb — siehe [Systemanforderungen](#systemanforderungen) |
|
||||||
| Rechte | `root` (`sudo ./install.sh`) |
|
| Rechte | `root` (`sudo ./install.sh`) |
|
||||||
| Netz | apt-Zugriff für die System-Pakete, PyPI-Zugriff für die venv |
|
| Netz | apt-Zugriff für die System-Pakete, PyPI-Zugriff für die venv |
|
||||||
| Repo | muss dauerhaft liegen bleiben — `update.sh` kopiert daraus (s. [UPDATE.md](UPDATE.md)) |
|
| Repo | muss dauerhaft liegen bleiben — `update.sh` kopiert daraus (s. [UPDATE.md](UPDATE.md)) |
|
||||||
@@ -29,7 +30,7 @@ ca-certificates curl
|
|||||||
```
|
```
|
||||||
|
|
||||||
Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz
|
Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz
|
||||||
nach (siehe [OCR-Sprachen](#3-ocr-sprachen)).
|
nach (siehe [OCR-Sprachen](#4-ocr-sprachen)).
|
||||||
|
|
||||||
Die Python-Abhängigkeiten stehen **fest gepinnt** in `requirements.txt`
|
Die Python-Abhängigkeiten stehen **fest gepinnt** in `requirements.txt`
|
||||||
(ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins
|
(ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins
|
||||||
@@ -37,6 +38,92 @@ anhebt: [OS-UPGRADE.md](OS-UPGRADE.md#pins-in-requirementstxt).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Systemanforderungen
|
||||||
|
|
||||||
|
CPU und Platte sind unkritisch — **der Arbeitsspeicher ist es nicht.** OCR
|
||||||
|
rastert jede Seite in voller Auflösung ins RAM; der Spitzenbedarf hängt an der
|
||||||
|
Seitengröße, nicht an der Dateigröße der PDF.
|
||||||
|
|
||||||
|
**Empfehlung: mindestens 2 GB RAM.** Für Mehr-Instanz-Betrieb oder
|
||||||
|
`max_workers > 2` entsprechend mehr.
|
||||||
|
|
||||||
|
### Warum 512 MB nachweislich nicht reichen
|
||||||
|
|
||||||
|
Gemessen auf einem LXC-Container mit **512 MB RAM + 512 MB Swap**, Debian 13,
|
||||||
|
Ghostscript 10.05.1:
|
||||||
|
|
||||||
|
| Vorgang | Ergebnis |
|
||||||
|
|---------|----------|
|
||||||
|
| eine einzelne **A4-Seite in 300 dpi**, `deskew = true` | cgroup-Limit gerissen, Dienst vom **OOM-Killer** beendet |
|
||||||
|
| dieselbe Verarbeitung mit einer kleineren Seite (850 × 1100 px) | läuft sauber durch, ca. 19 s |
|
||||||
|
|
||||||
|
Beleg aus dem `dmesg` des LXC-Hosts:
|
||||||
|
|
||||||
|
```
|
||||||
|
oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/201, task=python
|
||||||
|
total-vm:1168764kB, anon-rss:489676kB
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine Seite, ein Worker — und schon knapp 500 MB anonymer Speicher. 512 MB sind
|
||||||
|
damit für 300-dpi-Scans keine knappe, sondern eine unzureichende Dimensionierung.
|
||||||
|
|
||||||
|
### RAM ↔ `max_workers` × `jobs` × Auflösung
|
||||||
|
|
||||||
|
Der Spitzenbedarf multipliziert sich über drei Config-Werte aus
|
||||||
|
[`[ocr]`](#ocr):
|
||||||
|
|
||||||
|
| Key | Default | Wirkung auf den Speicher |
|
||||||
|
|-----|---------|--------------------------|
|
||||||
|
| `max_workers` | `2` | **so viele PDFs gleichzeitig** — jede mit eigenem Seitenpuffer. Der direkte Multiplikator |
|
||||||
|
| `jobs` | `4` | Threads **innerhalb** einer PDF; mehrere Seiten gleichzeitig im Speicher |
|
||||||
|
| `oversample` | `300` | Auflösung gerasterter Seiten — der Bedarf wächst quadratisch mit der dpi |
|
||||||
|
|
||||||
|
Der Default `max_workers = 2` erlaubt also, dass **zwei** solcher Seiten
|
||||||
|
parallel verarbeitet werden. Wer knapp dimensioniert, zieht zuerst
|
||||||
|
`max_workers` auf `1` herunter, danach `jobs`. `oversample` unter 300 zu
|
||||||
|
drücken, spart zwar Speicher, kostet aber Erkennungsqualität — das ist der
|
||||||
|
letzte Hebel, nicht der erste.
|
||||||
|
|
||||||
|
### Wie sich ein OOM-Kill äußert
|
||||||
|
|
||||||
|
Von außen sieht ein OOM-Kill wie ein Anwendungsfehler aus — er ist keiner:
|
||||||
|
|
||||||
|
- Der Dienst ist **weg** bzw. wurde von systemd neu gestartet
|
||||||
|
(`Restart=on-failure`); `systemctl show -p NRestarts` steigt.
|
||||||
|
- Im journal bricht die Verarbeitung **mitten in der Datei** ab, ohne
|
||||||
|
Python-Traceback und ohne `ERROR`-Zeile aus dem Tool.
|
||||||
|
- Die betroffene PDF bleibt in `working/` liegen.
|
||||||
|
|
||||||
|
### Wie man ihn nachweist
|
||||||
|
|
||||||
|
**Im Container:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /sys/fs/cgroup/memory.events
|
||||||
|
# oom_kill 1 <- alles über 0 ist ein Treffer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auf dem LXC-Host** (im Container zeigt `dmesg` diese Zeilen nicht):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dmesg -T | grep -i oom-kill
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Prüfung gehört an den **Anfang** der Fehlersuche, wenn Dateien
|
||||||
|
unerklärlich in `working/` liegen bleiben: ohne sie sucht man den Fehler in
|
||||||
|
ocrmypdf, Tesseract oder der Config, wo keiner ist.
|
||||||
|
|
||||||
|
### Datenverlust ist abgefangen, der OOM bleibt
|
||||||
|
|
||||||
|
Seit **v0.6.0** greift der Dienst beim nächsten Start auf, was in `working/`
|
||||||
|
liegen geblieben ist (siehe [UPDATE.md](UPDATE.md#wiederaufnahme-aus-working)).
|
||||||
|
Eine vom OOM-Killer unterbrochene Datei geht also nicht verloren. Behoben ist
|
||||||
|
damit aber nur die Folge: bei unveränderter Dimensionierung läuft dieselbe Datei
|
||||||
|
nach dem Neustart erneut in denselben OOM — bis `max_workers` sinkt oder das
|
||||||
|
System mehr RAM bekommt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -249,19 +336,47 @@ Container-Instanzen reißt.
|
|||||||
## Ghostscript-Bug auf Debian 12
|
## Ghostscript-Bug auf Debian 12
|
||||||
|
|
||||||
Ghostscript 10.0.0 bis einschließlich 10.02.0 — der **Debian-12-Default** —
|
Ghostscript 10.0.0 bis einschließlich 10.02.0 — der **Debian-12-Default** —
|
||||||
zerschießt OCR in der Kombination `[ocr].pdfa_level` + `skip_text = true`:
|
enthält Regressionen, die PDFs mit vorhandenem Text beschädigen. ocrmypdf
|
||||||
ocrmypdf blockiert komplett.
|
verweigert deshalb den Dienst, statt ein kaputtes Ergebnis zu liefern.
|
||||||
|
|
||||||
Deshalb:
|
### Wann genau ocrmypdf abbricht
|
||||||
|
|
||||||
- `pdfa_level = ""` ist der sichere Default (kein PDF/A-Output).
|
Die Bedingung steht in `ocrmypdf/builtin_plugins/ghostscript.py`
|
||||||
- Der Preflight beim Dienststart bricht mit **Exit 2** ab, wenn `pdfa_level`
|
(`check_options()`) und hängt an **zwei** Dingen — an der Config *und* an der
|
||||||
gesetzt **und** die installierte Ghostscript-Version betroffen ist.
|
ocrmypdf-Version:
|
||||||
- `--check-config` meldet ein gesetztes `pdfa_level` als Warnung (siehe
|
|
||||||
|
| ocrmypdf | Die Prüfung greift bei | Heißt für uns |
|
||||||
|
|----------|------------------------|---------------|
|
||||||
|
| **≤ 16.x** | `skip_text` oder `redo_ocr` — **unabhängig vom `output_type`** | Auch ohne PDF/A scheitert **jede** Datei, denn `skip_text = true` ist unser Default |
|
||||||
|
| **≥ 17.0** | dasselbe, aber nur innerhalb von `if options.output_type.startswith('pdfa')` | Ohne PDF/A wird Ghostscript gar nicht angefasst — unkritisch |
|
||||||
|
|
||||||
|
> ⚠️ **`pdfa_level = ""` allein ist damit kein Schutz.** Die Entwarnung gilt nur
|
||||||
|
> zusammen mit **ocrmypdf ≥ 17**. Das war der Fehler in 0.6.0: der Pin stand auf
|
||||||
|
> `ocrmypdf==16.13.0`, und auf Debian 12 landete daraufhin jede PDF in `error/` —
|
||||||
|
> bei grünem `systemctl status` und „Preflight ok".
|
||||||
|
> `requirements.txt` pinnt deshalb 17.x.
|
||||||
|
|
||||||
|
### Was das Tool dagegen tut
|
||||||
|
|
||||||
|
- `pdfa_level = ""` ist der Default (kein PDF/A-Output).
|
||||||
|
- `requirements.txt` pinnt **ocrmypdf 17.x**. Ein Downgrade auf 16.x macht jede
|
||||||
|
Debian-12-Instanz unbrauchbar; `update.sh` weist Versionssprünge der
|
||||||
|
gepinnten Pakete deshalb ausdrücklich aus.
|
||||||
|
- Der Preflight bricht beim **Dienststart** mit **Exit 2** ab, wenn die
|
||||||
|
Ghostscript-Version betroffen ist **und** die Kombination aus `skip_text`,
|
||||||
|
`pdfa_level` und installierter ocrmypdf-Version tatsächlich zum Abbruch
|
||||||
|
führen würde. Der Dienst startet dann gar nicht erst, statt jede Datei
|
||||||
|
einzeln scheitern zu lassen.
|
||||||
|
- `--check-config` meldet denselben Zustand als **Fehler (Exit 2)** und zeigt
|
||||||
|
ocrmypdf- und Ghostscript-Version an (siehe
|
||||||
[UPDATE.md](UPDATE.md#config-prüfung-per---check-config)).
|
[UPDATE.md](UPDATE.md#config-prüfung-per---check-config)).
|
||||||
|
- Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh` schiebt nach dem Update
|
||||||
|
eine Test-PDF durch die echte Pipeline — er hätte den Ausfall sofort gezeigt.
|
||||||
|
|
||||||
Der Installer erkennt betroffene Versionen und bietet auf Debian 12
|
### Abhilfe
|
||||||
bookworm-backports an. Manuell:
|
|
||||||
|
**Weg 1 — Ghostscript anheben** (empfohlen). Der Installer erkennt betroffene
|
||||||
|
Versionen und bietet auf Debian 12 bookworm-backports an. Manuell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo 'deb http://deb.debian.org/debian bookworm-backports main' | \
|
echo 'deb http://deb.debian.org/debian bookworm-backports main' | \
|
||||||
@@ -272,6 +387,10 @@ sudo apt update && sudo apt install -t bookworm-backports ghostscript
|
|||||||
Ab Ghostscript 10.02.1 ist alles in Ordnung; PDF/A kann dann eingeschaltet
|
Ab Ghostscript 10.02.1 ist alles in Ordnung; PDF/A kann dann eingeschaltet
|
||||||
werden.
|
werden.
|
||||||
|
|
||||||
|
**Weg 2 — `skip_text = false` setzen.** Dann wird vorhandener Text neu erkannt
|
||||||
|
statt übersprungen, und die Bedingung greift nicht mehr. Das kostet Laufzeit bei
|
||||||
|
PDFs, die bereits eine Textebene haben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Instanz manuell löschen
|
## Instanz manuell löschen
|
||||||
@@ -325,7 +444,7 @@ startet nicht.
|
|||||||
| `jobs` | `4` | Threads, die ocrmypdf innerhalb **einer** PDF nutzt |
|
| `jobs` | `4` | Threads, die ocrmypdf innerhalb **einer** PDF nutzt |
|
||||||
| `skip_text` | `true` | Seiten, die schon Text haben, nicht neu OCRen |
|
| `skip_text` | `true` | Seiten, die schon Text haben, nicht neu OCRen |
|
||||||
| `oversample` | `300` | Auflösung für gerasterte Seiten |
|
| `oversample` | `300` | Auflösung für gerasterte Seiten |
|
||||||
| `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12) |
|
| `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12). Achtung: leer allein schützt nur zusammen mit ocrmypdf ≥ 17 |
|
||||||
| `deskew` | `true` | schiefe Scans begradigen |
|
| `deskew` | `true` | schiefe Scans begradigen |
|
||||||
| `clean` | `false` | Hintergrund säubern (unpaper) |
|
| `clean` | `false` | Hintergrund säubern (unpaper) |
|
||||||
| `max_workers` | `2` | wie viele PDFs **parallel** verarbeitet werden |
|
| `max_workers` | `2` | wie viele PDFs **parallel** verarbeitet werden |
|
||||||
@@ -439,7 +558,7 @@ Exit 2 heißt immer: Config oder Preflight. Die Ursache steht im journal und
|
|||||||
ausführlicher in:
|
ausführlicher in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -448,6 +567,55 @@ sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
|||||||
Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade.
|
Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade.
|
||||||
Siehe [OS-UPGRADE.md](OS-UPGRADE.md).
|
Siehe [OS-UPGRADE.md](OS-UPGRADE.md).
|
||||||
|
|
||||||
|
### Keine Logs: "No journal files were found"
|
||||||
|
|
||||||
|
**Symptom:** `journalctl -u pdf-ocr-hotfolder@<instanz>` bleibt leer oder meldet
|
||||||
|
`No journal files were found.` — auch dann, wenn der Dienst nachweislich läuft
|
||||||
|
und Dateien verarbeitet.
|
||||||
|
|
||||||
|
**Erste Prüfung:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status systemd-journald
|
||||||
|
```
|
||||||
|
|
||||||
|
Ist `systemd-journald.service` selbst `failed` (beobachtet mit
|
||||||
|
`status=243/CREDENTIALS`), gibt es schlicht kein journal, in das geschrieben
|
||||||
|
werden könnte.
|
||||||
|
|
||||||
|
**Einordnung.** Der Dienst loggt seit v0.4.1 **ausschließlich** nach journald —
|
||||||
|
es gibt bewusst kein eigenes Logfile und kein Logverzeichnis. Ein kaputtes
|
||||||
|
journald ist damit ein blinder Fleck: jede Fehlersuche läuft ins Leere, und der
|
||||||
|
Ausfall sieht aus wie ein stummer Dienst. **Deshalb vor jeder Fehlersuche
|
||||||
|
zuerst journald prüfen**, nicht erst, wenn nichts anderes mehr passt.
|
||||||
|
|
||||||
|
Das ist **kein** generelles LXC-Muster. Von zwei Testcontainern war genau einer
|
||||||
|
betroffen; auf dem zweiten lief journald einwandfrei. Es handelt sich um einen
|
||||||
|
Schaden auf dieser einen Maschine, nicht um eine Eigenschaft von Containern.
|
||||||
|
|
||||||
|
**Notbehelf, solange journald nicht zu retten ist:** die Instanz einmal im
|
||||||
|
Vordergrund laufen lassen — dann geht die Ausgabe direkt ins Terminal, am
|
||||||
|
journal vorbei.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop pdf-ocr-hotfolder@<instanz>
|
||||||
|
cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
|
--config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Das `cd` ist zwingend: das Paket wird nicht pip-installiert, sondern nach
|
||||||
|
`/opt/pdf-ocr-hotfolder` kopiert und nur über das Arbeitsverzeichnis gefunden
|
||||||
|
(ohne `cd` gibt es `No module named pdf_ocr_hotfolder`, v0.6.2). Beenden mit
|
||||||
|
`Strg+C`, danach `sudo systemctl start pdf-ocr-hotfolder@<instanz>`. Wer nur den
|
||||||
|
Bestand abarbeiten und dann aussteigen will, hängt `--once` an (siehe
|
||||||
|
[Manueller Lauf](#manueller-lauf-one-shot)).
|
||||||
|
|
||||||
|
### Dienst bricht mitten in der Verarbeitung weg
|
||||||
|
|
||||||
|
Datei bleibt in `working/`, kein Traceback, `NRestarts` steigt: das ist fast
|
||||||
|
immer der OOM-Killer, kein Anwendungsfehler. Nachweis und Dimensionierung unter
|
||||||
|
[Systemanforderungen](#wie-sich-ein-oom-kill-äußert).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Manueller Lauf (One-Shot)
|
## Manueller Lauf (One-Shot)
|
||||||
@@ -456,7 +624,7 @@ Bestehende PDFs einer Instanz einmalig verarbeiten und beenden — greift auch
|
|||||||
Dateien auf, die in `working/` liegen geblieben sind:
|
Dateien auf, die in `working/` liegen geblieben sind:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u pdfocr /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
--config /etc/pdf-ocr-hotfolder/kunde-a.toml --once
|
--config /etc/pdf-ocr-hotfolder/kunde-a.toml --once
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+28
-11
@@ -153,9 +153,9 @@ Instanz, die vorher lief, auch wieder läuft.
|
|||||||
**Sind die Configs sauber?**
|
**Sind die Configs sauber?**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd /opt/pdf-ocr-hotfolder
|
||||||
for f in /etc/pdf-ocr-hotfolder/*.toml; do
|
for f in /etc/pdf-ocr-hotfolder/*.toml; do
|
||||||
sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
sudo ./venv/bin/python -m pdf_ocr_hotfolder --check-config --config "$f"
|
||||||
--check-config --config "$f"
|
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -191,28 +191,45 @@ Upgrade entfernt. Hintergrund:
|
|||||||
Die Python-Abhängigkeiten sind **bewusst fest gepinnt**:
|
Die Python-Abhängigkeiten sind **bewusst fest gepinnt**:
|
||||||
|
|
||||||
```
|
```
|
||||||
ocrmypdf==16.13.0
|
ocrmypdf==17.4.1
|
||||||
watchdog==6.0.0
|
watchdog==6.0.0
|
||||||
requests==2.33.1
|
requests==2.33.1
|
||||||
paramiko==4.0.0
|
paramiko==4.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Ohne Pins würde ein `pip install --upgrade` bei jedem Update ungefragt eine neue
|
Ohne Pins würde ein `pip install --upgrade` bei jedem Update ungefragt eine neue
|
||||||
Major-Version ziehen — ein Sprung von ocrmypdf **16 auf 17** reißt sonst alle
|
Major-Version ziehen — der nächste Sprung wäre ocrmypdf **17 auf 18**, und der
|
||||||
Instanzen auf einmal, und zwar im Moment des Updates, nicht zu einem Zeitpunkt,
|
reißt sonst alle Instanzen auf einmal, und zwar im Moment des Updates, nicht zu
|
||||||
den man sich ausgesucht hat.
|
einem Zeitpunkt, den man sich ausgesucht hat.
|
||||||
|
|
||||||
|
> ⚠️ **ocrmypdf darf nicht unter 17 fallen.** Bis einschließlich 16.x prüft
|
||||||
|
> ocrmypdf die Ghostscript-Version auch dann, wenn gar kein PDF/A erzeugt wird —
|
||||||
|
> auf Debian 12 (Ghostscript 10.0.0) scheitert damit **jede** PDF, weil
|
||||||
|
> `skip_text = true` unser Default ist. Genau das war der Ausfall in 0.6.0.
|
||||||
|
> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
||||||
|
|
||||||
Die aktuellen Pins sind gegen Python 3.11 (Debian 12) und 3.13 (Debian 13)
|
Die aktuellen Pins sind gegen Python 3.11 (Debian 12) und 3.13 (Debian 13)
|
||||||
geprüft; für beide gibt es fertige Wheels, es wird nichts kompiliert.
|
geprüft; für beide gibt es fertige Wheels, es wird nichts kompiliert. Das gilt
|
||||||
|
auch für die Abhängigkeiten, die ocrmypdf 17 zusätzlich mitbringt (`pydantic`,
|
||||||
|
`pypdfium2`, `fpdf2`, `uharfbuzz`).
|
||||||
|
|
||||||
**Beim Anheben:**
|
**Beim Anheben:**
|
||||||
|
|
||||||
1. **Testmaschine benutzen** — nie direkt auf dem produktiven Hotfolder.
|
1. **Testmaschine benutzen** — nie direkt auf dem produktiven Hotfolder.
|
||||||
2. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch
|
2. Prüfen, dass es für die neue Version auf **beiden** Python-Versionen fertige
|
||||||
|
Wheels gibt, sonst wird auf dem Zielsystem kompiliert:
|
||||||
|
```bash
|
||||||
|
pip install --dry-run --only-binary=:all: --python-version 3.11 \
|
||||||
|
--target /tmp/wheelcheck ocrmypdf==<version>
|
||||||
|
pip install --dry-run --only-binary=:all: --python-version 3.13 \
|
||||||
|
--target /tmp/wheelcheck ocrmypdf==<version>
|
||||||
|
```
|
||||||
|
3. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch
|
||||||
aufgelöst werden.
|
aufgelöst werden.
|
||||||
3. `pytest` muss grün bleiben (135 Tests).
|
4. `pytest` muss grün bleiben (152 Tests).
|
||||||
4. Eine echte PDF durchschieben — die Test-Suite mockt ocrmypdf, ein Major-Sprung
|
5. Eine echte PDF durchschieben — die Test-Suite mockt ocrmypdf, ein Major-Sprung
|
||||||
fällt dort also nicht auf.
|
fällt dort also nicht auf. Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh`
|
||||||
|
macht genau das automatisch.
|
||||||
5. Erst dann committen und auf die produktiven Systeme geben.
|
5. Erst dann committen und auf die produktiven Systeme geben.
|
||||||
|
|
||||||
Der ocrmypdf-Sprung 16 → 17 ist ein **Major-Sprung** und gehört in einen eigenen
|
Der ocrmypdf-Sprung 16 → 17 ist ein **Major-Sprung** und gehört in einen eigenen
|
||||||
|
|||||||
+110
-10
@@ -19,8 +19,9 @@ sudo ./update.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo ./update.sh --help # Optionen anzeigen
|
sudo ./update.sh --help # Optionen anzeigen
|
||||||
sudo ./update.sh --rebuild-venv # venv zwingend neu bauen (nach dist-upgrade)
|
sudo ./update.sh --rebuild-venv # venv zwingend neu bauen (nach dist-upgrade)
|
||||||
|
sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen
|
||||||
```
|
```
|
||||||
|
|
||||||
`update.sh` muss aus dem Repo laufen. Findet es sich nicht selbst im Repo, liest
|
`update.sh` muss aus dem Repo laufen. Findet es sich nicht selbst im Repo, liest
|
||||||
@@ -37,12 +38,13 @@ muss also liegen bleiben**, das Tool kopiert daraus.
|
|||||||
| 4 | **Instanzen stoppen** | nur die, die vorher liefen oder kaputt waren |
|
| 4 | **Instanzen stoppen** | nur die, die vorher liefen oder kaputt waren |
|
||||||
| 5 | **Backup** | [Inhalt und Ort](#backup) |
|
| 5 | **Backup** | [Inhalt und Ort](#backup) |
|
||||||
| 6 | **Code kopieren** | `pdf_ocr_hotfolder/`, `requirements.txt`, `VERSION`, `config.example.toml`, `.repo_path` |
|
| 6 | **Code kopieren** | `pdf_ocr_hotfolder/`, `requirements.txt`, `VERSION`, `config.example.toml`, `.repo_path` |
|
||||||
| 7 | **Dependencies** | `pip install --upgrade -r requirements.txt` — oder venv-Neubau, falls nötig |
|
| 7 | **Dependencies** | `pip install --upgrade -r requirements.txt` — oder venv-Neubau, falls nötig. Versionssprünge der gepinnten Pakete werden [benannt](#versionssprünge-der-kernabhängigkeiten) |
|
||||||
| 8 | **systemd-Units** | Template-Unit aus dem Repo, LXC-Drop-in nachziehen, `daemon-reload` |
|
| 8 | **systemd-Units** | Template-Unit aus dem Repo, LXC-Drop-in nachziehen, `daemon-reload` |
|
||||||
| 9 | **Berechtigungen** | Code gehört dem primären User (i.d.R. `pdfocr`) |
|
| 9 | **Berechtigungen** | Code gehört dem primären User (i.d.R. `pdfocr`) |
|
||||||
| 10 | **Configs prüfen** | `--check-config` je Instanz, siehe [unten](#config-prüfung-per---check-config) |
|
| 10 | **Configs prüfen** | `--check-config` je Instanz, siehe [unten](#config-prüfung-per---check-config) |
|
||||||
| 11 | **Instanzen starten + verifizieren** | mit Wartezeit und Crash-Loop-Erkennung |
|
| 11 | **Instanzen starten + verifizieren** | mit Wartezeit und Crash-Loop-Erkennung |
|
||||||
| 12 | **Zusammenfassung** | Soll gegen Ist |
|
| 12 | **Rauchtest** | eine Test-PDF durch die echte Pipeline, siehe [unten](#rauchtest) |
|
||||||
|
| 13 | **Zusammenfassung** | Soll gegen Ist |
|
||||||
|
|
||||||
Ab Schritt 2 gilt: **System-Pakete werden auch beim Update nachgezogen**, nicht
|
Ab Schritt 2 gilt: **System-Pakete werden auch beim Update nachgezogen**, nicht
|
||||||
nur bei der Installation. Bereits installierte Tesseract-Sprachpakete bleiben
|
nur bei der Installation. Bereits installierte Tesseract-Sprachpakete bleiben
|
||||||
@@ -187,20 +189,112 @@ Ein abgebrochenes Update lässt also keinen Hotfolder stumm gestoppt zurück.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Versionssprünge der Kernabhängigkeiten
|
||||||
|
|
||||||
|
`update.sh` misst die Versionen der in `requirements.txt` gepinnten Pakete
|
||||||
|
**vor** und **nach** `pip install` und benennt jede Änderung:
|
||||||
|
|
||||||
|
```
|
||||||
|
[WARN] DOWNGRADE: ocrmypdf: 17.4.1 -> 16.13.0
|
||||||
|
[INFO] Upgrade: watchdog: 5.0.0 -> 6.0.0
|
||||||
|
[INFO] Neu: requests 2.33.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Beides steht auch noch einmal in der Abschluss-Zusammenfassung, weil es im
|
||||||
|
Fließtext zwischen den pip-Ausgaben untergeht.
|
||||||
|
|
||||||
|
**Downgrades sind der interessante Fall.** Sie entstehen, wenn ein Pin in
|
||||||
|
`requirements.txt` gesenkt wurde. Genau so ist der Totalausfall in 0.6.0
|
||||||
|
entstanden: `ocrmypdf` wurde von 17.4.1 auf 16.13.0 heruntergezogen, das Update
|
||||||
|
lief mit Exit 0 durch, der Dienst meldete `active` — und jede PDF landete in
|
||||||
|
`error/`. Sichtbar war davon nichts außer `[INFO] Dependencies ok ✓`.
|
||||||
|
|
||||||
|
War ein Downgrade nicht beabsichtigt: Pin korrigieren und
|
||||||
|
`sudo ./update.sh --rebuild-venv` erneut fahren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rauchtest
|
||||||
|
|
||||||
|
Nach dem Start schiebt `update.sh` pro Instanz eine winzige Test-PDF durch die
|
||||||
|
**echte** Pipeline und prüft, ob sie in `outgoing/` ankommt.
|
||||||
|
|
||||||
|
Das ist der Schritt, den 0.6.0 gefehlt hat: `systemctl` sagt `active`,
|
||||||
|
`--check-config` sagt `Preflight ok` — und trotzdem scheitert jede einzelne
|
||||||
|
Datei. Ein laufender Dienst ist eben kein Beleg dafür, dass er etwas
|
||||||
|
verarbeitet.
|
||||||
|
|
||||||
|
- Die Test-PDF steckt als base64 **im Skript** (694 Bytes, eine Seite). Es
|
||||||
|
braucht also kein Pillow, kein `gs` und kein `convert` auf dem Zielsystem.
|
||||||
|
- Der Dateiname ist eindeutig (`__smoketest_update_<zeitstempel>_<pid>.pdf`) und
|
||||||
|
kann mit keiner Kundendatei kollidieren.
|
||||||
|
- Wartezeit: `SMOKE_TIMEOUT` Sekunden (Standard 90), danach gilt der Test als
|
||||||
|
durchgefallen. Das Skript hängt nicht.
|
||||||
|
|
||||||
|
### Aufräumen
|
||||||
|
|
||||||
|
Test-PDF **und** Ergebnis werden danach restlos entfernt — in jedem Ausgang,
|
||||||
|
auch bei Fehlschlag und Timeout. Angefasst werden dabei ausschließlich Dateien
|
||||||
|
mit dem Testnamen, in `incoming/`, `working/` (inkl. `__ocr_`-Zwischendatei),
|
||||||
|
`outgoing/`, `error/` und im Archivverzeichnis. In keinem dieser Verzeichnisse
|
||||||
|
bleibt etwas vom Test liegen.
|
||||||
|
|
||||||
|
### Wann der Rauchtest übersprungen wird
|
||||||
|
|
||||||
|
Hat eine Instanz ein aktives Ziel, würde die Testdatei **nach außen** gehen —
|
||||||
|
im Zweifel zum Kunden. Solche Instanzen werden mit klarer Meldung übersprungen:
|
||||||
|
|
||||||
|
| Übersprungen bei | Grund |
|
||||||
|
|------------------|-------|
|
||||||
|
| `[upload.nextcloud].enabled = true` | Testdatei landete in der Nextcloud |
|
||||||
|
| `[upload.sftp].enabled = true` | Testdatei landete auf dem SFTP-Ziel |
|
||||||
|
| `[upload.folder]` mit gesetztem `target` | Zielordner liegt außerhalb von `outgoing/`, oft eine Kundenfreigabe |
|
||||||
|
| `[notify.email].enabled = true` | löst eine Benachrichtigungs-Mail aus |
|
||||||
|
|
||||||
|
`[upload.folder]` **ohne** `target` schreibt nach `outgoing/` und ist damit
|
||||||
|
harmlos — dort läuft der Test normal.
|
||||||
|
|
||||||
|
Für diese Instanzen bleibt der manuelle Weg: eine eigene PDF in `incoming/`
|
||||||
|
legen und `journalctl -u pdf-ocr-hotfolder@<instanz> -f` mitlesen.
|
||||||
|
|
||||||
|
### Wenn der Rauchtest fehlschlägt
|
||||||
|
|
||||||
|
Der Rauchtest setzt den **Exit-Code** des Updates auf 1 und nennt den
|
||||||
|
Journal-Befehl:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ERROR] RAUCHTEST FEHLGESCHLAGEN: kunde1
|
||||||
|
[ERROR] Diese Instanzen laufen, verarbeiten aber keine PDFs.
|
||||||
|
[ERROR] Es wurde NICHT zurueckgerollt. Journal ansehen:
|
||||||
|
[ERROR] journalctl -u pdf-ocr-hotfolder@kunde1.service -n 80 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
**Es wird nichts automatisch zurückgerollt.** Der Code ist getauscht, die
|
||||||
|
Instanzen laufen. Rollback nur von Hand und nur bewusst — siehe
|
||||||
|
[Rollback](#rollback).
|
||||||
|
|
||||||
|
Abschalten: `sudo ./update.sh --no-smoke-test`. Dann fällt ein Totalausfall
|
||||||
|
erst der ersten echten Kundendatei auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Config-Prüfung per `--check-config`
|
## Config-Prüfung per `--check-config`
|
||||||
|
|
||||||
Nach dem Code-Update und vor dem Start prüft `update.sh` jede Instanz-Config mit
|
Nach dem Code-Update und vor dem Start prüft `update.sh` jede Instanz-Config mit
|
||||||
dem neuen Code:
|
dem neuen Code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
cd /opt/pdf-ocr-hotfolder && ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
`--check-config` verarbeitet nichts, es hat sogar Vorrang vor `--once`. Es lädt
|
`--check-config` verarbeitet nichts, es hat sogar Vorrang vor `--once`. Es lädt
|
||||||
die Config, zeigt die vier Pfade (inkl. Hinweis, falls ein Verzeichnis noch
|
die Config, zeigt die vier Pfade (inkl. Hinweis, falls ein Verzeichnis noch
|
||||||
fehlt), Sprachen, Seiten-Timeout und PDF/A-Level, fährt den Preflight
|
fehlt), Sprachen, Seiten-Timeout, PDF/A-Level, `skip_text` sowie die
|
||||||
(`tesseract`, `gs`, Ghostscript-Version bei gesetztem `pdfa_level`) und
|
installierte ocrmypdf- und Ghostscript-Version, fährt den Preflight
|
||||||
|
(`tesseract`, `gs`, und die Ghostscript-Version gegen die tatsächliche
|
||||||
|
ocrmypdf-Bedingung — siehe [Rauchtest](#rauchtest) und
|
||||||
|
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12)) und
|
||||||
validiert die `[output]`-Sektion.
|
validiert die `[output]`-Sektion.
|
||||||
|
|
||||||
| Exit | Bedeutung | Was der Admin tun soll |
|
| Exit | Bedeutung | Was der Admin tun soll |
|
||||||
@@ -257,9 +351,15 @@ ocrmypdf `tesseract_timeout=0` als "OCR komplett überspringen" interpretiert.
|
|||||||
|
|
||||||
`pdfa_level` gehört auf `""` (reines PDF, kein PDF/A). Ist es gesetzt, warnt
|
`pdfa_level` gehört auf `""` (reines PDF, kein PDF/A). Ist es gesetzt, warnt
|
||||||
`--check-config`, weil Ghostscript 10.0.0–10.02.0 — der Debian-12-Default — in
|
`--check-config`, weil Ghostscript 10.0.0–10.02.0 — der Debian-12-Default — in
|
||||||
Kombination mit `skip_text` das OCR blockiert. Der Preflight bricht in dem Fall
|
Kombination mit `skip_text` von ocrmypdf abgelehnt wird. Der Preflight bricht in
|
||||||
mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch. Hintergrund:
|
dem Fall mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch.
|
||||||
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
|
||||||
|
> **`pdfa_level = ""` allein ist kein Schutz gegen den Ghostscript-Bug.** Das
|
||||||
|
> gilt erst zusammen mit **ocrmypdf ≥ 17**. Bis ocrmypdf 16.x läuft dieselbe
|
||||||
|
> Prüfung auch ohne PDF/A, und dann scheitert mit `skip_text = true` jede
|
||||||
|
> einzelne Datei. Die Entwarnung hängt also an der ocrmypdf-Version.
|
||||||
|
> `requirements.txt` pinnt darum 17.x, und der Preflight prüft beides zusammen.
|
||||||
|
> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
||||||
|
|
||||||
### Unbekannte Keys
|
### Unbekannte Keys
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
||||||
|
|
||||||
__version__ = "0.6.0"
|
__version__ = "0.6.3"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from .service import (
|
|||||||
PreflightError,
|
PreflightError,
|
||||||
check_output_config,
|
check_output_config,
|
||||||
check_preflight,
|
check_preflight,
|
||||||
|
detect_ghostscript_version,
|
||||||
|
detect_ocrmypdf_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -70,11 +72,15 @@ def check_config(cfg_path: Path) -> int:
|
|||||||
print(f" OCR-Sprachen = {cfg.ocr.languages}")
|
print(f" OCR-Sprachen = {cfg.ocr.languages}")
|
||||||
print(f" Seiten-Timeout= {cfg.ocr.timeout} s")
|
print(f" Seiten-Timeout= {cfg.ocr.timeout} s")
|
||||||
print(f" PDF/A-Level = {cfg.ocr.pdfa_level or '(aus)'}")
|
print(f" PDF/A-Level = {cfg.ocr.pdfa_level or '(aus)'}")
|
||||||
|
print(f" skip_text = {'an' if cfg.ocr.skip_text else 'aus'}")
|
||||||
|
print(f" ocrmypdf = {detect_ocrmypdf_version() or '(nicht installiert)'}")
|
||||||
|
print(f" Ghostscript = {detect_ghostscript_version() or '(nicht gefunden)'}")
|
||||||
|
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
try:
|
try:
|
||||||
check_preflight(cfg.ocr.pdfa_level)
|
check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text)
|
||||||
print(" Preflight ok (tesseract, gs vorhanden).")
|
print(" Preflight ok (tesseract, gs vorhanden, Ghostscript-Version "
|
||||||
|
"passt zu ocrmypdf + [ocr]-Einstellungen).")
|
||||||
except PreflightError as e:
|
except PreflightError as e:
|
||||||
errors.append(str(e))
|
errors.append(str(e))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ class OcrConfig:
|
|||||||
jobs: int = 4
|
jobs: int = 4
|
||||||
skip_text: bool = True
|
skip_text: bool = True
|
||||||
oversample: int = 300
|
oversample: int = 300
|
||||||
# Default bewusst leer: pdfa_level + skip_text zerschießt OCR mit
|
# Default bewusst leer: mit Ghostscript 10.0.0-10.02.0 (Debian-12-Default)
|
||||||
# Ghostscript 10.0.0-10.02.0 (Debian-12-Default), siehe Issue #3
|
# lehnt ocrmypdf die Kombination pdfa_level + skip_text ab (Issue #3).
|
||||||
|
# ACHTUNG, kein Freibrief: pdfa_level = "" allein schützt nur zusammen mit
|
||||||
|
# ocrmypdf >= 17. Bis 16.x läuft dieselbe Prüfung auch ohne PDF/A und
|
||||||
|
# blockiert dann JEDE Datei — deshalb pinnt requirements.txt 17.x und der
|
||||||
|
# Preflight prüft die installierte ocrmypdf-Version mit (siehe
|
||||||
|
# service._gs_block_reason).
|
||||||
pdfa_level: str = ""
|
pdfa_level: str = ""
|
||||||
deskew: bool = True
|
deskew: bool = True
|
||||||
clean: bool = False
|
clean: bool = False
|
||||||
@@ -251,9 +256,10 @@ def legacy_warnings(cfg: Config) -> list[str]:
|
|||||||
out.append(
|
out.append(
|
||||||
f"[ocr].pdfa_level = {cfg.ocr.pdfa_level!r}: PDF/A-Ausgabe ist "
|
f"[ocr].pdfa_level = {cfg.ocr.pdfa_level!r}: PDF/A-Ausgabe ist "
|
||||||
"aktiv. Ghostscript 10.0.0-10.02.0 (Debian-12-Default) hat einen "
|
"aktiv. Ghostscript 10.0.0-10.02.0 (Debian-12-Default) hat einen "
|
||||||
"Bug, der zusammen mit skip_text das OCR blockiert (Issue #3). Der "
|
"Bug, wegen dem ocrmypdf die Kombination mit skip_text ablehnt "
|
||||||
"Preflight bricht ab, falls die installierte Ghostscript-Version "
|
"(Issue #3). Der Preflight bricht ab, falls die installierte "
|
||||||
"betroffen ist; ab 10.02.1 ist alles in Ordnung."
|
"Ghostscript-Version betroffen ist; ab 10.02.1 ist alles in "
|
||||||
|
"Ordnung."
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
+113
-15
@@ -35,11 +35,20 @@ class PreflightError(RuntimeError):
|
|||||||
# Pflicht-Binaries für ocrmypdf
|
# Pflicht-Binaries für ocrmypdf
|
||||||
_REQUIRED_BINARIES = ("tesseract", "gs")
|
_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.
|
# 10.0.0 .. 10.02.0 (inklusive). Ab 10.02.1 wieder nutzbar.
|
||||||
_GS_BROKEN_MIN = (10, 0, 0)
|
_GS_BROKEN_MIN = (10, 0, 0)
|
||||||
_GS_BROKEN_MAX = (10, 2, 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:
|
def _parse_version(text: str) -> tuple[int, ...] | None:
|
||||||
"""Extrahiert die erste X.Y[.Z] Version aus einem String."""
|
"""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:
|
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.
|
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
|
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,
|
def check_output_config(mode: str, archive_dir: str,
|
||||||
name_mode: str = "prefix") -> None:
|
name_mode: str = "prefix") -> None:
|
||||||
"""Validiert die [output]-Section. Wirft PreflightError bei Problemen."""
|
"""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.
|
"""Prüft externe Abhängigkeiten.
|
||||||
|
|
||||||
- Tesseract und Ghostscript müssen im PATH sein
|
- Tesseract und Ghostscript müssen im PATH sein
|
||||||
- Bei gesetztem pdfa_level wird die Ghostscript-Version gegen den
|
- Die Ghostscript-Version wird gegen den bekannten 10.0.0–10.02.0 Bug
|
||||||
bekannten 10.0.0–10.02.0 Bug geprüft
|
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.
|
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"
|
+ ". 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:
|
if pdfa_level:
|
||||||
gs_version = detect_ghostscript_version()
|
ursache = (
|
||||||
if is_ghostscript_broken(gs_version):
|
f"[ocr].pdfa_level = {pdfa_level!r} (PDF/A-Ausgabe) zusammen mit "
|
||||||
raise PreflightError(
|
"[ocr].skip_text = true"
|
||||||
f"Ghostscript {gs_version} ist mit pdfa_level='{pdfa_level}' nicht "
|
)
|
||||||
"kompatibel (bekannter Bug in 10.0.0–10.02.0). "
|
else:
|
||||||
"Entweder ghostscript auf >=10.02.1 upgraden (z.B. via bookworm-backports) "
|
ursache = (
|
||||||
"oder in der Config [ocr].pdfa_level = \"\" setzen."
|
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:
|
def _is_pdf(path: Path) -> bool:
|
||||||
@@ -201,7 +299,7 @@ class HotfolderService:
|
|||||||
# ---- Lifecycle ----
|
# ---- Lifecycle ----
|
||||||
|
|
||||||
def run(self) -> None:
|
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,
|
check_output_config(self.cfg.output.original_on_success,
|
||||||
self.cfg.output.archive_dir,
|
self.cfg.output.archive_dir,
|
||||||
self.cfg.output.name_mode)
|
self.cfg.output.name_mode)
|
||||||
@@ -228,7 +326,7 @@ class HotfolderService:
|
|||||||
Returns:
|
Returns:
|
||||||
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
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,
|
check_output_config(self.cfg.output.original_on_success,
|
||||||
self.cfg.output.archive_dir,
|
self.cfg.output.archive_dir,
|
||||||
self.cfg.output.name_mode)
|
self.cfg.output.name_mode)
|
||||||
|
|||||||
+17
-2
@@ -1,9 +1,24 @@
|
|||||||
# Feste Pins: ein Update darf nicht ungefragt einen Major-Sprung einziehen
|
# Feste Pins: ein Update darf nicht ungefragt einen Major-Sprung einziehen
|
||||||
# (ein ocrmypdf 16 -> 17 reisst sonst alle Instanzen auf einmal).
|
# (der naechste waere ocrmypdf 18 — der reisst sonst alle Instanzen auf einmal).
|
||||||
# Geprueft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) — fuer beide
|
# Geprueft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) — fuer beide
|
||||||
# gibt es fertige Wheels, es wird nichts kompiliert.
|
# gibt es fertige Wheels, es wird nichts kompiliert.
|
||||||
# Beim Anheben: update.sh --rebuild-venv auf einer Testmaschine fahren.
|
# Beim Anheben: update.sh --rebuild-venv auf einer Testmaschine fahren.
|
||||||
ocrmypdf==16.13.0
|
#
|
||||||
|
# ocrmypdf: MUSS 17.x sein, 16.x ist fuer uns unbrauchbar (v0.6.1).
|
||||||
|
# In ocrmypdf 16.x laeuft die Ghostscript-Pruefung in
|
||||||
|
# builtin_plugins/ghostscript.py:check_options() BEDINGUNGSLOS, also auch bei
|
||||||
|
# output_type="pdf". Auf Debian 12 (Ghostscript 10.0.0) bricht damit
|
||||||
|
# JEDE PDF ab, sobald skip_text=true gesetzt ist — und das ist unser Default:
|
||||||
|
# if Version('10.0.0') <= gs_version < Version('10.02.1') and (
|
||||||
|
# options.skip_text or options.redo_ocr
|
||||||
|
# ): raise MissingDependencyError(...)
|
||||||
|
# Ab 17.0.0 steckt genau dieser Block in einem
|
||||||
|
# `if options.output_type.startswith('pdfa'):` — bei pdfa_level = "" wird
|
||||||
|
# Ghostscript gar nicht erst angefasst und die Pruefung greift nicht mehr.
|
||||||
|
# Deshalb hier 17.x. 17.4.1 ist die im Feld auf Debian 12 + gs 10.0.0
|
||||||
|
# verifizierte Version; 17.12.1 traegt denselben Guard und waere der
|
||||||
|
# naechste Kandidat, ist aber noch nicht auf einer Testmaschine gefahren.
|
||||||
|
ocrmypdf==17.4.1
|
||||||
watchdog==6.0.0
|
watchdog==6.0.0
|
||||||
requests==2.33.1
|
requests==2.33.1
|
||||||
paramiko==4.0.0
|
paramiko==4.0.0
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 PDF/A-Bug-Erkennung."""
|
"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 Bug-Erkennung.
|
||||||
|
|
||||||
|
Seit v0.6.1 bildet der Preflight die reale ocrmypdf-Bedingung ab:
|
||||||
|
|
||||||
|
betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17)
|
||||||
|
|
||||||
|
Der letzte Teil ist der Fall, der in v0.6.0 durchrutschte: mit ocrmypdf 16.x
|
||||||
|
greift die Ghostscript-Prüfung auch ohne PDF/A, und der Dienst meldete
|
||||||
|
trotzdem "Preflight ok", während jede einzelne PDF in error/ landete.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,9 +19,21 @@ from pdf_ocr_hotfolder.service import (
|
|||||||
PreflightError,
|
PreflightError,
|
||||||
check_preflight,
|
check_preflight,
|
||||||
is_ghostscript_broken,
|
is_ghostscript_broken,
|
||||||
|
ocrmypdf_checks_gs_always,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _env(gs_version: str, ocrmypdf_version: str):
|
||||||
|
"""Binaries vorhanden, Ghostscript- und ocrmypdf-Version vorgegeben."""
|
||||||
|
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
||||||
|
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
||||||
|
return_value=gs_version), \
|
||||||
|
patch("pdf_ocr_hotfolder.service.detect_ocrmypdf_version",
|
||||||
|
return_value=ocrmypdf_version):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("version,expected", [
|
@pytest.mark.parametrize("version,expected", [
|
||||||
# Betroffene Versionen
|
# Betroffene Versionen
|
||||||
("10.0.0", True),
|
("10.0.0", True),
|
||||||
@@ -36,29 +58,92 @@ def test_is_ghostscript_broken(version, expected) -> None:
|
|||||||
assert is_ghostscript_broken(version) is expected
|
assert is_ghostscript_broken(version) is expected
|
||||||
|
|
||||||
|
|
||||||
def test_check_preflight_without_pdfa_passes_with_broken_gs() -> None:
|
@pytest.mark.parametrize("version,expected", [
|
||||||
"""Ohne pdfa_level darf der betroffene GS verwendet werden."""
|
("16.13.0", True), # der Pin aus v0.6.0, der den Ausfall ausgeloest hat
|
||||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
("16.0.0", True),
|
||||||
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
("15.4.4", True),
|
||||||
return_value="10.0.0"):
|
("17.0.0", False), # ab hier steckt die Pruefung hinter output_type
|
||||||
check_preflight(pdfa_level="") # darf nicht werfen
|
("17.4.1", False), # unser Pin
|
||||||
|
("17.12.1", False),
|
||||||
|
("18.0.0", False),
|
||||||
|
(None, False), # unbekannt -> kein Fehlalarm
|
||||||
|
("", False),
|
||||||
|
("garbage", False),
|
||||||
|
])
|
||||||
|
def test_ocrmypdf_checks_gs_always(version, expected) -> None:
|
||||||
|
assert ocrmypdf_checks_gs_always(version) is expected
|
||||||
|
|
||||||
|
|
||||||
def test_check_preflight_with_pdfa_fails_on_broken_gs() -> None:
|
# ---------------- Preflight: ocrmypdf 17.x (unser Pin) ----------------
|
||||||
"""Mit pdfa_level + kaputtem GS → PreflightError mit hilfreicher Meldung."""
|
|
||||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
def test_broken_gs_skip_text_without_pdfa_passes_on_ocrmypdf_17() -> None:
|
||||||
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
"""Der Debian-12-Standardfall: ohne PDF/A fasst ocrmypdf 17 gs nicht an.
|
||||||
return_value="10.0.0"):
|
|
||||||
|
Das ist die Default-Config (skip_text=true, pdfa_level="") auf Debian 12 —
|
||||||
|
sie muss laufen, sonst startet keine einzige Bestandsinstanz mehr.
|
||||||
|
"""
|
||||||
|
with _env("10.0.0", "17.4.1"):
|
||||||
|
check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_gs_with_pdfa_and_skip_text_fails() -> None:
|
||||||
|
"""Mit pdfa_level + skip_text + kaputtem GS → PreflightError."""
|
||||||
|
with _env("10.0.0", "17.4.1"):
|
||||||
with pytest.raises(PreflightError, match="Ghostscript 10.0.0"):
|
with pytest.raises(PreflightError, match="Ghostscript 10.0.0"):
|
||||||
check_preflight(pdfa_level="2")
|
check_preflight(pdfa_level="2", skip_text=True)
|
||||||
|
|
||||||
|
|
||||||
def test_check_preflight_with_pdfa_passes_on_fixed_gs() -> None:
|
def test_broken_gs_with_pdfa_without_skip_text_passes() -> None:
|
||||||
"""Mit pdfa_level + gefixtem GS → ok."""
|
"""Ohne skip_text greift die ocrmypdf-Bedingung nicht — kein Abbruch."""
|
||||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
with _env("10.0.0", "17.4.1"):
|
||||||
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
check_preflight(pdfa_level="2", skip_text=False) # darf nicht werfen
|
||||||
return_value="10.02.1"):
|
|
||||||
check_preflight(pdfa_level="2") # darf nicht werfen
|
|
||||||
|
def test_healthy_gs_with_pdfa_and_skip_text_passes() -> None:
|
||||||
|
"""Nicht betroffene GS-Version → nie ein Abbruch."""
|
||||||
|
with _env("10.02.1", "17.4.1"):
|
||||||
|
check_preflight(pdfa_level="2", skip_text=True) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Preflight: ocrmypdf 16.x (der Ausfall aus v0.6.0) ----------------
|
||||||
|
|
||||||
|
def test_broken_gs_skip_text_without_pdfa_fails_on_ocrmypdf_16() -> None:
|
||||||
|
"""DER Fall, der in v0.6.0 durchrutschte.
|
||||||
|
|
||||||
|
ocrmypdf 16.13.0 + Ghostscript 10.0.0 + skip_text=true + pdfa_level="":
|
||||||
|
"Preflight ok", Dienst active — und jede PDF landete in error/.
|
||||||
|
Jetzt muss der Dienst beim START abbrechen.
|
||||||
|
"""
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
with pytest.raises(PreflightError) as exc_info:
|
||||||
|
check_preflight(pdfa_level="", skip_text=True)
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "10.0.0" in msg
|
||||||
|
assert "16.13.0" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_gs_without_skip_text_passes_on_ocrmypdf_16() -> None:
|
||||||
|
"""skip_text=false → auch 16.x prüft Ghostscript nicht."""
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
check_preflight(pdfa_level="", skip_text=False) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthy_gs_passes_on_ocrmypdf_16() -> None:
|
||||||
|
"""Nicht betroffene GS-Version → auch mit 16.x kein Abbruch."""
|
||||||
|
with _env("10.02.1", "16.13.0"):
|
||||||
|
check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Meldungstext ----------------
|
||||||
|
|
||||||
|
def test_error_message_names_both_remedies() -> None:
|
||||||
|
"""Der Admin muss aus der Meldung heraus handeln können."""
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
with pytest.raises(PreflightError) as exc_info:
|
||||||
|
check_preflight(pdfa_level="", skip_text=True)
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "backports" in msg, "Weg 1: Ghostscript aus bookworm-backports"
|
||||||
|
assert "skip_text = false" in msg, "Weg 2: skip_text abschalten"
|
||||||
|
|
||||||
|
|
||||||
def test_default_config_pdfa_level_is_empty() -> None:
|
def test_default_config_pdfa_level_is_empty() -> None:
|
||||||
@@ -70,3 +155,47 @@ def test_default_config_pdfa_level_is_empty() -> None:
|
|||||||
data = tomllib.load(f)
|
data = tomllib.load(f)
|
||||||
assert data["ocr"]["pdfa_level"] == "", \
|
assert data["ocr"]["pdfa_level"] == "", \
|
||||||
"config.example.toml muss pdfa_level='' als sicheren Default haben"
|
"config.example.toml muss pdfa_level='' als sicheren Default haben"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Der Dienst muss beim START abbrechen ----------------
|
||||||
|
|
||||||
|
def test_run_once_aborts_on_ocrmypdf_16_with_broken_gs(tmp_config) -> None:
|
||||||
|
"""Abbruch beim Start statt Totalausfall bei der ersten Datei."""
|
||||||
|
from pdf_ocr_hotfolder.service import HotfolderService
|
||||||
|
|
||||||
|
assert tmp_config.ocr.skip_text is True
|
||||||
|
assert tmp_config.ocr.pdfa_level == ""
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
try:
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
with pytest.raises(PreflightError):
|
||||||
|
service.run_once()
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_config_returns_2_on_ocrmypdf_16_with_broken_gs(
|
||||||
|
tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||||
|
"""--check-config meldet den Zustand als Fehler (Exit 2) — auch mitten im Update."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main
|
||||||
|
|
||||||
|
cfg_file = tmp_path / "cfg.toml"
|
||||||
|
cfg_file.write_text(f"""
|
||||||
|
[paths]
|
||||||
|
incoming = "{tmp_config.paths.incoming}"
|
||||||
|
outgoing = "{tmp_config.paths.outgoing}"
|
||||||
|
working = "{tmp_config.paths.working}"
|
||||||
|
error = "{tmp_config.paths.error}"
|
||||||
|
|
||||||
|
[ocr]
|
||||||
|
skip_text = true
|
||||||
|
pdfa_level = ""
|
||||||
|
""")
|
||||||
|
monkeypatch.setattr(sys, "argv",
|
||||||
|
["pdf-ocr-hotfolder", "--config", str(cfg_file),
|
||||||
|
"--check-config"])
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
assert main() == CHECK_ERROR
|
||||||
|
assert "Ghostscript" in capsys.readouterr().err
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
# Aufruf:
|
# Aufruf:
|
||||||
# sudo ./update.sh # normales Update
|
# sudo ./update.sh # normales Update
|
||||||
# sudo ./update.sh --rebuild-venv # venv-Neubau erzwingen (nach dist-upgrade)
|
# sudo ./update.sh --rebuild-venv # venv-Neubau erzwingen (nach dist-upgrade)
|
||||||
|
# sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen
|
||||||
#
|
#
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
|
|||||||
: "${TAR_ROOT:=/}"
|
: "${TAR_ROOT:=/}"
|
||||||
: "${VERIFY_WAIT:=6}"
|
: "${VERIFY_WAIT:=6}"
|
||||||
: "${BACKUP_KEEP:=5}"
|
: "${BACKUP_KEEP:=5}"
|
||||||
|
# Max. Sekunden, die der Rauchtest auf das fertige OCR-PDF wartet. Eine
|
||||||
|
# Mini-PDF braucht ein paar Sekunden (Datei-Stabilisierung + OCR), unter Last
|
||||||
|
# auch mal laenger.
|
||||||
|
: "${SMOKE_TIMEOUT:=90}"
|
||||||
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
|
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
|
||||||
UNIT_GLOB='pdf-ocr-hotfolder@*.service'
|
UNIT_GLOB='pdf-ocr-hotfolder@*.service'
|
||||||
LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d"
|
LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d"
|
||||||
@@ -44,6 +49,7 @@ LXC_SYNCED=0
|
|||||||
BACKUP_FILE=""
|
BACKUP_FILE=""
|
||||||
PRIMARY_USER="pdfocr"
|
PRIMARY_USER="pdfocr"
|
||||||
TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde
|
TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde
|
||||||
|
SMOKE_TEST=1 # per --no-smoke-test abschaltbar
|
||||||
|
|
||||||
UNITS_ALL=() # alle bekannten Instanz-Units
|
UNITS_ALL=() # alle bekannten Instanz-Units
|
||||||
PREV_OK=() # liefen vorher sauber -> muessen nachher laufen
|
PREV_OK=() # liefen vorher sauber -> muessen nachher laufen
|
||||||
@@ -53,6 +59,12 @@ CFG_WARN=() # Instanzen mit Config-Warnungen (Exit 1)
|
|||||||
CFG_ERR=() # Instanzen mit Config-Fehlern (Exit 2)
|
CFG_ERR=() # Instanzen mit Config-Fehlern (Exit 2)
|
||||||
STARTED_OK=()
|
STARTED_OK=()
|
||||||
STARTED_FAIL=()
|
STARTED_FAIL=()
|
||||||
|
DEP_UPGRADES=() # Paket: alt -> neu (hoeher)
|
||||||
|
DEP_DOWNGRADES=() # Paket: alt -> neu (niedriger) <- das Gefaehrliche
|
||||||
|
DEP_NEW=() # Paket war vorher nicht installiert
|
||||||
|
SMOKE_OK=() # Rauchtest bestanden
|
||||||
|
SMOKE_FAIL=() # Rauchtest fehlgeschlagen
|
||||||
|
SMOKE_SKIP=() # Rauchtest uebersprungen (mit Begruendung)
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -65,10 +77,25 @@ Optionen:
|
|||||||
installieren. Das ist der Aufruf nach einem Debian-
|
installieren. Das ist der Aufruf nach einem Debian-
|
||||||
Major-Upgrade (apt full-upgrade, z.B. 12 -> 13), wenn das
|
Major-Upgrade (apt full-upgrade, z.B. 12 -> 13), wenn das
|
||||||
System-Python eine neue Version bekommen hat.
|
System-Python eine neue Version bekommen hat.
|
||||||
|
--no-smoke-test Den Rauchtest nach dem Start auslassen.
|
||||||
-h, --help Diese Hilfe.
|
-h, --help Diese Hilfe.
|
||||||
|
|
||||||
Ohne Option prueft das Skript selbst, ob die venv noch zum System-Python
|
Ohne Option prueft das Skript selbst, ob die venv noch zum System-Python
|
||||||
passt, und baut sie bei Bedarf neu.
|
passt, und baut sie bei Bedarf neu.
|
||||||
|
|
||||||
|
Rauchtest (Standard: an)
|
||||||
|
Nach dem Start schiebt das Skript pro Instanz eine winzige Test-PDF durch
|
||||||
|
die echte Pipeline und prueft, ob sie in outgoing/ ankommt. Erst das zeigt
|
||||||
|
einen Totalausfall, den systemd nicht sieht (Dienst active, aber jede PDF
|
||||||
|
landet in error/). Test-PDF und Ergebnis werden danach restlos wieder
|
||||||
|
entfernt, auch im Fehlerfall.
|
||||||
|
|
||||||
|
Uebersprungen wird der Test bei Instanzen mit aktivem Upload-Ziel
|
||||||
|
(Nextcloud, SFTP, Ordner-Ziel ausserhalb von outgoing) oder aktivem
|
||||||
|
E-Mail-Notify: dort wuerde die Testdatei nach aussen gehen.
|
||||||
|
|
||||||
|
Ein fehlgeschlagener Rauchtest setzt den Exit-Code, rollt aber NICHTS
|
||||||
|
zurueck. Wartezeit pro Instanz: ${SMOKE_TIMEOUT}s (SMOKE_TIMEOUT=...).
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +250,97 @@ pip_install_requirements() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Versionen der Kernabhaengigkeiten
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# Ein Downgrade (z.B. ocrmypdf 17.4.1 -> 16.13.0, weil requirements.txt neu
|
||||||
|
# gepinnt wurde) verschwand bis v0.6.0 wortlos hinter "[INFO] Dependencies ok".
|
||||||
|
# Genau so ist der Totalausfall aus Issue #7 entstanden. Deshalb: vorher und
|
||||||
|
# nachher messen und jede Aenderung benennen.
|
||||||
|
|
||||||
|
# Paketnamen aus einer requirements.txt (normalisiert: klein, Bindestriche).
|
||||||
|
pinned_package_names() {
|
||||||
|
local req
|
||||||
|
for req in "$@"; do
|
||||||
|
[ -f "$req" ] || continue
|
||||||
|
sed -n 's/^[[:space:]]*\([A-Za-z0-9._-]\{1,\}\)[[:space:]]*[<>=!~].*/\1/p' "$req"
|
||||||
|
done | tr '[:upper:]_' '[:lower:]-' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
# "paket version" je Zeile — nur fuer die in $2.. gepinnten Pakete.
|
||||||
|
snapshot_pinned_versions() {
|
||||||
|
local venv="$1"; shift
|
||||||
|
local line name ver n
|
||||||
|
local -a names=()
|
||||||
|
mapfile -t names < <(pinned_package_names "$@")
|
||||||
|
[ "${#names[@]}" -gt 0 ] || return 0
|
||||||
|
[ -x "$venv/bin/pip" ] || return 0
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in (*==*) ;; (*) continue ;; esac
|
||||||
|
name="$(printf '%s' "${line%%==*}" | tr '[:upper:]_' '[:lower:]-')"
|
||||||
|
ver="${line#*==}"
|
||||||
|
for n in "${names[@]}"; do
|
||||||
|
[ "$n" = "$name" ] && printf '%s %s\n' "$name" "$ver"
|
||||||
|
done
|
||||||
|
done < <("$venv/bin/pip" list --format=freeze 2>/dev/null || true)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 0, wenn $1 eine aeltere Version als $2 ist.
|
||||||
|
version_is_older() {
|
||||||
|
[ "$1" = "$2" ] && return 1
|
||||||
|
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$1" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vergleicht zwei Snapshots und fuellt DEP_UPGRADES/DEP_DOWNGRADES/DEP_NEW.
|
||||||
|
diff_pinned_versions() {
|
||||||
|
local before="$1" after="$2"
|
||||||
|
local name new old
|
||||||
|
DEP_UPGRADES=(); DEP_DOWNGRADES=(); DEP_NEW=()
|
||||||
|
|
||||||
|
while read -r name new; do
|
||||||
|
[ -n "$name" ] || continue
|
||||||
|
old="$(awk -v p="$name" '$1==p {print $2; exit}' "$before" 2>/dev/null || true)"
|
||||||
|
if [ -z "$old" ]; then
|
||||||
|
DEP_NEW+=("$name $new")
|
||||||
|
elif [ "$old" = "$new" ]; then
|
||||||
|
continue
|
||||||
|
elif version_is_older "$new" "$old"; then
|
||||||
|
DEP_DOWNGRADES+=("$name: $old -> $new")
|
||||||
|
else
|
||||||
|
DEP_UPGRADES+=("$name: $old -> $new")
|
||||||
|
fi
|
||||||
|
done < "$after"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gibt die Aenderungen aus. Downgrades als WARN — die sind das Gefaehrliche.
|
||||||
|
report_dependency_changes() {
|
||||||
|
local entry
|
||||||
|
if [ "${#DEP_DOWNGRADES[@]}" -eq 0 ] && [ "${#DEP_UPGRADES[@]}" -eq 0 ] \
|
||||||
|
&& [ "${#DEP_NEW[@]}" -eq 0 ]; then
|
||||||
|
log_info "Kernabhaengigkeiten unveraendert."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for entry in "${DEP_DOWNGRADES[@]:-}"; do
|
||||||
|
[ -n "$entry" ] || continue
|
||||||
|
log_warn "DOWNGRADE: $entry"
|
||||||
|
done
|
||||||
|
if [ "${#DEP_DOWNGRADES[@]}" -gt 0 ]; then
|
||||||
|
log_warn " Ein Downgrade kommt aus einem gesenkten Pin in requirements.txt."
|
||||||
|
log_warn " Pruefen, ob das beabsichtigt ist — die aeltere Version kann auf"
|
||||||
|
log_warn " diesem System unbrauchbar sein, ohne dass systemd es merkt."
|
||||||
|
fi
|
||||||
|
for entry in "${DEP_UPGRADES[@]:-}"; do
|
||||||
|
[ -n "$entry" ] || continue
|
||||||
|
log_info "Upgrade: $entry"
|
||||||
|
done
|
||||||
|
for entry in "${DEP_NEW[@]:-}"; do
|
||||||
|
[ -n "$entry" ] || continue
|
||||||
|
log_info "Neu: $entry"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Baut die venv neu: alte wegsichern, neue bauen, Requirements installieren,
|
# Baut die venv neu: alte wegsichern, neue bauen, Requirements installieren,
|
||||||
# und erst bei Erfolg die alte entfernen. Scheitert etwas, wird die alte
|
# und erst bei Erfolg die alte entfernen. Scheitert etwas, wird die alte
|
||||||
# zurueckgerollt und hart abgebrochen.
|
# zurueckgerollt und hart abgebrochen.
|
||||||
@@ -534,6 +652,266 @@ check_all_configs() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Rauchtest
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# systemd sagt "active", --check-config sagt "ok" — und trotzdem landet jede
|
||||||
|
# PDF in error/ (Issue #7). Das sieht man nur, wenn man wirklich eine Datei
|
||||||
|
# durch die Pipeline schiebt. Deshalb: eine Mini-PDF nach incoming/, warten,
|
||||||
|
# bis sie in outgoing/ ankommt, danach restlos aufraeumen.
|
||||||
|
#
|
||||||
|
# Die Test-PDF steckt als base64 hier drin — kein Pillow, kein gs, kein
|
||||||
|
# convert, damit der Test keine Abhaengigkeit mitbringt, die auf dem Zielsystem
|
||||||
|
# fehlen kann. 694 Bytes, eine Seite, eine Textzeile.
|
||||||
|
smoke_pdf_base64() {
|
||||||
|
cat <<'SMOKEPDF'
|
||||||
|
JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4K
|
||||||
|
ZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4K
|
||||||
|
ZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAg
|
||||||
|
MCA1OTUgODQyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA0IDAgUiA+PiA+PiAvQ29udGVu
|
||||||
|
dHMgNSAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUx
|
||||||
|
IC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggMTQ0ID4+
|
||||||
|
CnN0cmVhbQpCVCAvRjEgMjQgVGYgNzIgNzAwIFRkIChQREYgT0NSIEhPVEZPTERFUiBTTU9LRVRF
|
||||||
|
U1QpIFRqIEVUCkJUIC9GMSAxMSBUZiA3MiA2NzAgVGQgKEF1dG9tYXRpc2NoIGVyemV1Z3Qgdm9u
|
||||||
|
IHVwZGF0ZS5zaCAtIGJpdHRlIGlnbm9yaWVyZW4uKSBUaiBFVAplbmRzdHJlYW0KZW5kb2JqCnhy
|
||||||
|
ZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAwMDY0
|
||||||
|
IDAwMDAwIG4gCjAwMDAwMDAxMjEgMDAwMDAgbiAKMDAwMDAwMDI0NyAwMDAwMCBuIAowMDAwMDAw
|
||||||
|
MzE3IDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYK
|
||||||
|
NTExCiUlRU9GCg==
|
||||||
|
SMOKEPDF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Liest die fuer den Rauchtest noetigen Werte aus einer Instanz-Config.
|
||||||
|
# Ausgabe: "SCHLUESSEL<TAB>Wert" je Zeile.
|
||||||
|
smoke_read_config() {
|
||||||
|
local py="$1" cfg="$2"
|
||||||
|
"$py" - "$cfg" <<'SMOKECFG'
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
with open(sys.argv[1], "rb") as fh:
|
||||||
|
data = tomllib.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def sec(*keys):
|
||||||
|
cur = data
|
||||||
|
for key in keys:
|
||||||
|
cur = cur.get(key, {}) if isinstance(cur, dict) else {}
|
||||||
|
return cur if isinstance(cur, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
paths = sec("paths")
|
||||||
|
out = sec("output")
|
||||||
|
folder = sec("upload", "folder")
|
||||||
|
nextcloud = sec("upload", "nextcloud")
|
||||||
|
sftp = sec("upload", "sftp")
|
||||||
|
mail = sec("notify", "email")
|
||||||
|
|
||||||
|
# Alles, was die Testdatei nach aussen tragen wuerde. Defaults wie in config.py.
|
||||||
|
blockers = []
|
||||||
|
if nextcloud.get("enabled", False):
|
||||||
|
blockers.append("upload.nextcloud")
|
||||||
|
if sftp.get("enabled", False):
|
||||||
|
blockers.append("upload.sftp")
|
||||||
|
if mail.get("enabled", False):
|
||||||
|
blockers.append("notify.email")
|
||||||
|
# Ordner-Upload ohne target schreibt nach outgoing/ (harmlos); mit target
|
||||||
|
# geht die Datei irgendwo anders hin - oft eine Kundenfreigabe.
|
||||||
|
if folder.get("enabled", True) and str(folder.get("target", "")).strip():
|
||||||
|
blockers.append("upload.folder (target gesetzt)")
|
||||||
|
|
||||||
|
mode = str(out.get("original_on_success", "delete"))
|
||||||
|
archive = str(out.get("archive_dir", "")) if mode == "archive" else ""
|
||||||
|
|
||||||
|
for key, value in (
|
||||||
|
("INCOMING", paths.get("incoming", "")),
|
||||||
|
("OUTGOING", paths.get("outgoing", "")),
|
||||||
|
("WORKING", paths.get("working", "")),
|
||||||
|
("ERROR", paths.get("error", "")),
|
||||||
|
("ARCHIVE", archive),
|
||||||
|
("NAME_MODE", out.get("name_mode", "prefix")),
|
||||||
|
("NAME_TAG", out.get("name_tag", "OCR_")),
|
||||||
|
("BLOCKERS", ", ".join(blockers)),
|
||||||
|
):
|
||||||
|
print(f"{key}\t{value}")
|
||||||
|
SMOKECFG
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bildet build_output_name() aus processor.py nach.
|
||||||
|
smoke_output_name() {
|
||||||
|
local src="$1" mode="$2" tag="$3"
|
||||||
|
if [ "$mode" = "none" ] || [ -z "$tag" ]; then
|
||||||
|
printf '%s' "$src"; return 0
|
||||||
|
fi
|
||||||
|
case "$mode" in
|
||||||
|
prefix) printf '%s%s' "$tag" "$src" ;;
|
||||||
|
suffix) printf '%s%s%s' "${src%.*}" "$tag" ".${src##*.}" ;;
|
||||||
|
*) printf '%s' "$src" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Raeumt alle Spuren des Rauchtests weg — in JEDEM Ausgang. Nach dieser
|
||||||
|
# Funktion darf in keinem der Verzeichnisse etwas vom Test liegen bleiben.
|
||||||
|
smoke_cleanup() {
|
||||||
|
local in_dir="$1" out_dir="$2" work_dir="$3" err_dir="$4" arch_dir="$5"
|
||||||
|
local src_name="$6" out_name="$7"
|
||||||
|
local f
|
||||||
|
for f in "$in_dir/$src_name" \
|
||||||
|
"$work_dir/$src_name" \
|
||||||
|
"$work_dir/__ocr_$out_name" \
|
||||||
|
"$out_dir/$out_name" \
|
||||||
|
"$err_dir/$src_name" \
|
||||||
|
"$err_dir/$out_name" \
|
||||||
|
"${arch_dir:+$arch_dir/$src_name}"; do
|
||||||
|
[ -n "$f" ] || continue
|
||||||
|
[ -e "$f" ] && rm -f "$f" 2>/dev/null
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Schiebt eine Test-PDF durch eine laufende Instanz.
|
||||||
|
# 0 = bestanden, 1 = durchgefallen, 2 = uebersprungen
|
||||||
|
smoke_test_instance() {
|
||||||
|
local name="$1"
|
||||||
|
local py="$INSTALL_DIR/venv/bin/python"
|
||||||
|
local cfg="$CONFIG_DIR/$name.toml"
|
||||||
|
local key value
|
||||||
|
local INCOMING="" OUTGOING="" WORKING="" ERROR="" ARCHIVE=""
|
||||||
|
local NAME_MODE="prefix" NAME_TAG="OCR_" BLOCKERS=""
|
||||||
|
|
||||||
|
[ -f "$cfg" ] || { SMOKE_SKIP+=("$name (keine Config)"); return 2; }
|
||||||
|
[ -x "$py" ] || { SMOKE_SKIP+=("$name (kein venv-Python)"); return 2; }
|
||||||
|
|
||||||
|
local cfg_out
|
||||||
|
if ! cfg_out="$(smoke_read_config "$py" "$cfg" 2>&1)"; then
|
||||||
|
log_warn " ⚠ $name — Config liess sich fuer den Rauchtest nicht lesen:"
|
||||||
|
printf '%s\n' "$cfg_out" | tail -n 5
|
||||||
|
SMOKE_SKIP+=("$name (Config nicht lesbar)")
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
while IFS="$(printf '\t')" read -r key value; do
|
||||||
|
case "$key" in
|
||||||
|
INCOMING) INCOMING="$value" ;;
|
||||||
|
OUTGOING) OUTGOING="$value" ;;
|
||||||
|
WORKING) WORKING="$value" ;;
|
||||||
|
ERROR) ERROR="$value" ;;
|
||||||
|
ARCHIVE) ARCHIVE="$value" ;;
|
||||||
|
NAME_MODE) NAME_MODE="$value" ;;
|
||||||
|
NAME_TAG) NAME_TAG="$value" ;;
|
||||||
|
BLOCKERS) BLOCKERS="$value" ;;
|
||||||
|
esac
|
||||||
|
done <<< "$cfg_out"
|
||||||
|
|
||||||
|
if [ -n "$BLOCKERS" ]; then
|
||||||
|
log_info " ⏭ $name — Rauchtest uebersprungen: aktive Ziele ($BLOCKERS)."
|
||||||
|
log_info " Eine Testdatei wuerde sonst beim Empfaenger landen."
|
||||||
|
SMOKE_SKIP+=("$name (aktive Ziele: $BLOCKERS)")
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
if [ -z "$INCOMING" ] || [ -z "$OUTGOING" ]; then
|
||||||
|
SMOKE_SKIP+=("$name ([paths] unvollstaendig)")
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
if [ ! -d "$INCOMING" ]; then
|
||||||
|
log_warn " ⚠ $name — $INCOMING existiert nicht; Rauchtest uebersprungen."
|
||||||
|
SMOKE_SKIP+=("$name (incoming fehlt)")
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Eindeutiger Name, der mit keiner Kundendatei kollidieren kann.
|
||||||
|
local src_name out_name
|
||||||
|
src_name="__smoketest_update_$(date +%Y%m%d-%H%M%S)_$$.pdf"
|
||||||
|
out_name="$(smoke_output_name "$src_name" "$NAME_MODE" "$NAME_TAG")"
|
||||||
|
|
||||||
|
# Als Instanz-User anlegen, damit Rechte und Eigentuemer denen einer
|
||||||
|
# echten Scan-Datei entsprechen.
|
||||||
|
local unit_user
|
||||||
|
unit_user="$(systemctl show -p User --value "pdf-ocr-hotfolder@${name}.service" 2>/dev/null || true)"
|
||||||
|
[ -n "$unit_user" ] || unit_user="$PRIMARY_USER"
|
||||||
|
|
||||||
|
local tmp_pdf
|
||||||
|
tmp_pdf="$(mktemp)"
|
||||||
|
if ! smoke_pdf_base64 | base64 -d > "$tmp_pdf" 2>/dev/null; then
|
||||||
|
rm -f "$tmp_pdf"
|
||||||
|
log_warn " ⚠ $name — Test-PDF liess sich nicht erzeugen (base64 fehlt?)."
|
||||||
|
SMOKE_SKIP+=("$name (base64 fehlt)")
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Erst fertig schreiben, dann hineinbewegen: der Watcher soll keine
|
||||||
|
# halb geschriebene Datei sehen.
|
||||||
|
local staged="$INCOMING/.$src_name.part"
|
||||||
|
cp "$tmp_pdf" "$staged" 2>/dev/null || {
|
||||||
|
rm -f "$tmp_pdf" "$staged"
|
||||||
|
log_warn " ⚠ $name — nach $INCOMING liess sich nicht schreiben."
|
||||||
|
SMOKE_SKIP+=("$name (incoming nicht beschreibbar)")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
rm -f "$tmp_pdf"
|
||||||
|
chown "$unit_user":"$unit_user" "$staged" 2>/dev/null || true
|
||||||
|
chmod 644 "$staged" 2>/dev/null || true
|
||||||
|
if ! mv "$staged" "$INCOMING/$src_name" 2>/dev/null; then
|
||||||
|
rm -f "$staged"
|
||||||
|
log_warn " ⚠ $name — Testdatei liess sich nicht in $INCOMING einstellen."
|
||||||
|
SMOKE_SKIP+=("$name (incoming nicht beschreibbar)")
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warten: fertiges PDF in outgoing/ oder Abbruch in error/.
|
||||||
|
local waited=0 result=1
|
||||||
|
while [ "$waited" -lt "$SMOKE_TIMEOUT" ]; do
|
||||||
|
if [ -f "$OUTGOING/$out_name" ]; then result=0; break; fi
|
||||||
|
if [ -n "$ERROR" ] && { [ -f "$ERROR/$src_name" ] || [ -f "$ERROR/$out_name" ]; }; then
|
||||||
|
result=1; break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
smoke_cleanup "$INCOMING" "$OUTGOING" "$WORKING" "$ERROR" "$ARCHIVE" \
|
||||||
|
"$src_name" "$out_name"
|
||||||
|
|
||||||
|
if [ "$result" -eq 0 ]; then
|
||||||
|
log_info " ✅ $name — Test-PDF kam nach ${waited}s in outgoing/ an"
|
||||||
|
SMOKE_OK+=("$name")
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$waited" -ge "$SMOKE_TIMEOUT" ]; then
|
||||||
|
log_error " ❌ $name — Test-PDF war nach ${SMOKE_TIMEOUT}s nicht in $OUTGOING"
|
||||||
|
else
|
||||||
|
log_error " ❌ $name — Test-PDF landete in $ERROR statt in $OUTGOING"
|
||||||
|
fi
|
||||||
|
log_error " Der Dienst laeuft, verarbeitet aber nichts. Ursache im Journal:"
|
||||||
|
log_error " journalctl -u pdf-ocr-hotfolder@${name}.service -n 80 --no-pager"
|
||||||
|
SMOKE_FAIL+=("$name")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Faehrt den Rauchtest fuer alle Instanzen, die nach dem Update laufen.
|
||||||
|
run_smoke_tests() {
|
||||||
|
SMOKE_OK=(); SMOKE_FAIL=(); SMOKE_SKIP=()
|
||||||
|
if [ "$SMOKE_TEST" -ne 1 ]; then
|
||||||
|
log_step "Rauchtest"
|
||||||
|
log_warn "Uebersprungen (--no-smoke-test). Ein Totalausfall bei der"
|
||||||
|
log_warn "Verarbeitung faellt damit erst der ersten echten Datei auf."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "${#STARTED_OK[@]}" -eq 0 ]; then
|
||||||
|
log_step "Rauchtest"
|
||||||
|
log_info "Keine laufende Instanz — nichts zu testen."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "Rauchtest (Test-PDF durch die echte Pipeline)"
|
||||||
|
local unit name
|
||||||
|
for unit in "${STARTED_OK[@]}"; do
|
||||||
|
name="${unit#pdf-ocr-hotfolder@}"
|
||||||
|
name="${name%.service}"
|
||||||
|
smoke_test_instance "$name" || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Backup
|
# Backup
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -672,6 +1050,7 @@ fi
|
|||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--rebuild-venv) REBUILD_VENV=1 ;;
|
--rebuild-venv) REBUILD_VENV=1 ;;
|
||||||
|
--no-smoke-test) SMOKE_TEST=0 ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) log_error "Unbekannte Option: $1"; echo; usage; exit 1 ;;
|
*) log_error "Unbekannte Option: $1"; echo; usage; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
@@ -754,6 +1133,14 @@ stop_instances
|
|||||||
|
|
||||||
create_backup
|
create_backup
|
||||||
|
|
||||||
|
# Versionen der gepinnten Pakete festhalten, SOLANGE die alte
|
||||||
|
# requirements.txt noch liegt — sonst verschwindet ein Downgrade
|
||||||
|
# (z.B. ocrmypdf 17.4.1 -> 16.13.0) wortlos hinter "Dependencies ok ✓".
|
||||||
|
DEP_BEFORE="$(mktemp)"
|
||||||
|
DEP_AFTER="$(mktemp)"
|
||||||
|
snapshot_pinned_versions "$INSTALL_DIR/venv" \
|
||||||
|
"$INSTALL_DIR/requirements.txt" "$REPO_DIR/requirements.txt" > "$DEP_BEFORE" || true
|
||||||
|
|
||||||
log_step "Code aktualisieren"
|
log_step "Code aktualisieren"
|
||||||
TOUCHED=1
|
TOUCHED=1
|
||||||
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
||||||
@@ -772,6 +1159,12 @@ else
|
|||||||
log_info "Dependencies ok ✓"
|
log_info "Dependencies ok ✓"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
snapshot_pinned_versions "$INSTALL_DIR/venv" \
|
||||||
|
"$INSTALL_DIR/requirements.txt" "$REPO_DIR/requirements.txt" > "$DEP_AFTER" || true
|
||||||
|
diff_pinned_versions "$DEP_BEFORE" "$DEP_AFTER"
|
||||||
|
report_dependency_changes
|
||||||
|
rm -f "$DEP_BEFORE" "$DEP_AFTER"
|
||||||
|
|
||||||
install_units
|
install_units
|
||||||
|
|
||||||
log_step "Berechtigungen setzen"
|
log_step "Berechtigungen setzen"
|
||||||
@@ -785,10 +1178,16 @@ check_all_configs
|
|||||||
log_step "Instanzen starten"
|
log_step "Instanzen starten"
|
||||||
start_instances
|
start_instances
|
||||||
|
|
||||||
|
# Ab hier wird nichts mehr getauscht. Der Rauchtest laeuft bewusst OHNE
|
||||||
|
# ERR-Trap: ein durchgefallener Test setzt den Exit-Code, loest aber keinen
|
||||||
|
# Abbruch und kein Zurueckrollen aus.
|
||||||
|
trap - ERR INT TERM
|
||||||
|
|
||||||
|
run_smoke_tests
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Zusammenfassung: Ist gegen Soll
|
# Zusammenfassung: Ist gegen Soll
|
||||||
# ============================================================
|
# ============================================================
|
||||||
trap - ERR INT TERM
|
|
||||||
|
|
||||||
RC=0
|
RC=0
|
||||||
echo
|
echo
|
||||||
@@ -844,13 +1243,51 @@ if [ "${#CFG_WARN[@]}" -gt 0 ]; then
|
|||||||
log_warn "Config-Warnungen (Exit 1) bei: ${CFG_WARN[*]}"
|
log_warn "Config-Warnungen (Exit 1) bei: ${CFG_WARN[*]}"
|
||||||
log_warn " Kein Abbruchgrund, aber bitte nachsehen:"
|
log_warn " Kein Abbruchgrund, aber bitte nachsehen:"
|
||||||
for name in "${CFG_WARN[@]}"; do
|
for name in "${CFG_WARN[@]}"; do
|
||||||
log_warn " $INSTALL_DIR/venv/bin/python -m pdf_ocr_hotfolder --check-config --config $CONFIG_DIR/$name.toml"
|
# Das Paket ist nicht pip-installiert, sondern liegt in $INSTALL_DIR —
|
||||||
|
# ohne das cd findet Python das Modul nicht.
|
||||||
|
log_warn " cd $INSTALL_DIR && ./venv/bin/python -m pdf_ocr_hotfolder --check-config --config $CONFIG_DIR/$name.toml"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
if [ "$APT_WARN" -eq 1 ]; then
|
if [ "$APT_WARN" -eq 1 ]; then
|
||||||
log_warn "System-Pakete konnten nicht vollstaendig abgeglichen werden (siehe oben)."
|
log_warn "System-Pakete konnten nicht vollstaendig abgeglichen werden (siehe oben)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Versionsspruenge der Kernabhaengigkeiten hier noch einmal — im Log weiter
|
||||||
|
# oben gehen sie zwischen pip-Ausgaben unter.
|
||||||
|
if [ "${#DEP_DOWNGRADES[@]}" -gt 0 ]; then
|
||||||
|
log_warn "Abhaengigkeiten DOWNGEGRADED:"
|
||||||
|
for entry in "${DEP_DOWNGRADES[@]}"; do
|
||||||
|
log_warn " $entry"
|
||||||
|
done
|
||||||
|
log_warn " Das kommt aus requirements.txt. War es nicht beabsichtigt: Pin"
|
||||||
|
log_warn " korrigieren und update.sh --rebuild-venv erneut fahren."
|
||||||
|
fi
|
||||||
|
if [ "${#DEP_UPGRADES[@]}" -gt 0 ]; then
|
||||||
|
log_info "Abhaengigkeiten angehoben:"
|
||||||
|
for entry in "${DEP_UPGRADES[@]}"; do
|
||||||
|
log_info " $entry"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ "${#DEP_NEW[@]}" -gt 0 ]; then
|
||||||
|
log_info "Abhaengigkeiten neu dazugekommen: ${DEP_NEW[*]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$SMOKE_TEST" -ne 1 ]; then
|
||||||
|
log_warn "Rauchtest: uebersprungen (--no-smoke-test)"
|
||||||
|
else
|
||||||
|
[ "${#SMOKE_OK[@]}" -gt 0 ] && log_info "Rauchtest bestanden: ${SMOKE_OK[*]}"
|
||||||
|
[ "${#SMOKE_SKIP[@]}" -gt 0 ] && log_info "Rauchtest uebersprungen: ${SMOKE_SKIP[*]}"
|
||||||
|
if [ "${#SMOKE_FAIL[@]}" -gt 0 ]; then
|
||||||
|
log_error "RAUCHTEST FEHLGESCHLAGEN: ${SMOKE_FAIL[*]}"
|
||||||
|
log_error " Diese Instanzen laufen, verarbeiten aber keine PDFs."
|
||||||
|
log_error " Es wurde NICHT zurueckgerollt. Journal ansehen:"
|
||||||
|
for name in "${SMOKE_FAIL[@]}"; do
|
||||||
|
log_error " journalctl -u pdf-ocr-hotfolder@${name}.service -n 80 --no-pager"
|
||||||
|
done
|
||||||
|
RC=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ "$RC" -eq 0 ]; then
|
if [ "$RC" -eq 0 ]; then
|
||||||
log_info "Update auf $NEW_VERSION abgeschlossen ✓"
|
log_info "Update auf $NEW_VERSION abgeschlossen ✓"
|
||||||
|
|||||||
Reference in New Issue
Block a user