6 Commits

Author SHA1 Message Date
techadmin 2062476252 feat: Installer fragt OCR-Sprachen und Archiv pro Instanz ab (v0.5.0)
- Sprach-Abfrage pro Instanz (Default-Vorschlag deu+eng), bewusst
  instanz-lokal: ein Hotfolder kann mit "deu" laufen, ein anderer mit
  "deu+eng+fra". Hinweis im Prompt, dass jede zusaetzliche Sprache
  Laufzeit und Erkennungsqualitaet kostet.
- Jeder Sprachcode wird gegen "tesseract --list-langs" geprueft, fehlende
  Pakete (tesseract-ocr-<code>) werden zur Installation angeboten; lehnt
  der User ab oder scheitert apt, wird gewarnt und erneut gefragt.
- Abfrage "Original archivieren?" mit $BASE/archive als Default; Archiv
  ausserhalb von $BASE wird eigens angelegt und gechownt. Ein Pfad auf
  incoming/outgoing/working/error wird abgewiesen.
- sed-Kette der Config-Erzeugung jetzt verankert (^key =) und escaped,
  setzt zusaetzlich languages, original_on_success und archive_dir;
  die erzeugte Config wird gegen die Eingabe nachgeprueft.
- README und Briefing um "Sprachen pro Instanz" ergaenzt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:24:56 +02:00
techadmin 8da0b7da1c fix: veraPDF-FAIL respektiert original_on_success, Logverzeichnis raus (v0.4.1)
- Bei fehlgeschlagener veraPDF-Validierung wurde das Original bisher
  bedingungslos geloescht. Es folgt jetzt derselben
  [output].original_on_success-Regel wie im Erfolgsfall, damit "archive"
  das Original nicht ausgerechnet im Fehlerfall verliert.
- Das nie benutzte Logverzeichnis /var/log/pdf-ocr-hotfolder/ wird nicht
  mehr angelegt; der Dienst loggt ausschliesslich nach journald.
  README und Briefing nennen stattdessen die journalctl-Kommandos.
- 3 neue Tests (95 gesamt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:10:27 +02:00
techadmin 578472872e fix: Fehlerzaehlung, Upload-Fehler und ocr.timeout scharf (v0.4.0)
- [ocr].timeout wird als tesseract_timeout (pro Seite) an ocrmypdf
  durchgereicht; Default 1800 -> 300, 0 = ocrmypdf-Default
- Exceptions nach dem OCR zaehlen als Fehler, Datei wird nach error/ gerettet
- Fehlgeschlagene Uploads zaehlen als Fehler und loesen Fehler-Mail aus
- name_mode wird im Preflight geprueft, nicht erst pro Datei
- Fehlende [paths]-Sektion -> ConfigError mit klarer Meldung statt KeyError
- Stabilitaets-Timeout zaehlt als Fehler (--once liefert Exit 1)
- upload_folder nutzt shutil.copyfile statt read_bytes/write_bytes
- OcrConfig.pdfa_level Default "2" -> "" (Ghostscript-Bug, Issue #3)
- 35 neue Tests (92 gesamt), pytest.ini
- AI_AGENT_BRIEFING.md auf Stand 0.4.0 gebracht

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:01:38 +02:00
techadmin cbdc9d6664 Fix Issues #4, #5, #6: LXC-Kompatibilität, WorkingDirectory, GS-Backports
- #4: LXC/Container Drop-in (lxc-compat.conf) deaktiviert systemd-Hardening;
  Installer erkennt Container automatisch und bietet Drop-in an
- #5: WorkingDirectory=/opt/pdf-ocr-hotfolder in Template-Unit ergänzt
- #6: Installer bietet auf Debian 12 bei betroffenen GS-Versionen
  automatisch bookworm-backports Upgrade an (statt nur Warnung)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 01:41:54 +02:00
techadmin a23a3968ef feat: konfigurierbarer Dateiname + Archiv-Modus für Original (v0.3.0)
Neue [output]-Section:
- name_mode: prefix | suffix | none (suffix wird vor Extension eingefügt)
- name_tag: verbatim einfügbarer String
- original_on_success: delete | archive
- archive_dir mit Kollisions-Schutz (Timestamp-Suffix)

20 neue Tests (50 insgesamt, alle grün).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:32:41 +02:00
techadmin 9cdc9ae443 fix: Ghostscript 10.0.0-10.02.0 PDF/A-Bug abfangen (v0.2.2)
- config.example.toml: pdfa_level="" als sicherer Default
- check_preflight(pdfa_level) erkennt betroffene GS-Versionen und bricht ab
- install.sh warnt bei betroffenen GS-Versionen
- 19 neue Tests (parametrisiert über Versions-Matrix)

Closes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 07:29:18 +02:00
23 changed files with 1869 additions and 96 deletions
+142 -33
View File
@@ -1,8 +1,8 @@
# AI Agent Briefing — PDF OCR Hotfolder # AI Agent Briefing — PDF OCR Hotfolder
**Zuletzt aktualisiert:** 2026-04-08 **Zuletzt aktualisiert:** 2026-09-22
**Version:** 0.2.0 **Version:** 0.5.0
**Status:** Multi-Instanz-Support, nicht produktiv getestet **Status:** Multi-Instanz-Betrieb, Preflight-Checks und Fehlerzählung vorhanden, Test-Suite grün (95 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, nicht aus einem belegten Dauerbetrieb.
## 🎯 Projektziel ## 🎯 Projektziel
@@ -13,16 +13,28 @@ Eingehende gescannte PDFs werden automatisch durch OCR (ocrmypdf + Tesseract) in
``` ```
pdf-ocr-hotfolder/ pdf-ocr-hotfolder/
├── pdf_ocr_hotfolder/ ├── pdf_ocr_hotfolder/
│ ├── __init__.py # Versionsstring │ ├── __init__.py # Versionsstring (__version__)
│ ├── __main__.py # CLI-Entrypoint (argparse, --once, --config) │ ├── __main__.py # CLI (argparse: --config, --once, --version); Exit 0/1/2
│ ├── config.py # TOML-Loader, Dataclasses │ ├── config.py # TOML-Loader, Dataclasses, ConfigError
│ ├── service.py # Hauptservice (watchdog + ThreadPool) │ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Zähler
│ ├── processor.py # ocrmypdf + veraPDF │ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, email │ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
├── tests/ # pytest-Suite (95 Tests, ocrmypdf wird gemockt)
│ ├── conftest.py # Fixtures tmp_config / dummy_pdf
│ ├── test_config_errors.py
│ ├── test_error_counting.py
│ ├── test_ghostscript_version.py
│ ├── test_ocr_timeout.py
│ ├── test_once_exit_code.py
│ ├── test_output_naming.py
│ ├── test_preflight.py
│ └── test_upload_folder.py
├── systemd/ ├── systemd/
│ └── pdf-ocr-hotfolder@.service # systemd Template-Unit (Instanz = %i) │ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i)
│ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten
├── pytest.ini # testpaths = tests
├── config.example.toml ├── config.example.toml
├── install.sh # Interaktiver Installer ├── install.sh # Interaktiver Installer + Instanz-Manager
├── update.sh # Update aus Repo ├── update.sh # Update aus Repo
├── requirements.txt ├── requirements.txt
├── VERSION ├── VERSION
@@ -35,26 +47,37 @@ pdf-ocr-hotfolder/
| Komponente | Technologie | | Komponente | Technologie |
|------------|-------------| |------------|-------------|
| Sprache | Python 3.11+ (für `tomllib` aus stdlib) | | Sprache | Python 3.11+ (für `tomllib` aus stdlib) |
| OCR | `ocrmypdf` (als Library, nicht via Subprozess) | | OCR | `ocrmypdf` (als Library, nicht via Subprozess; Import ist lazy) |
| Engine | Tesseract | | Engine | Tesseract |
| Watcher | `watchdog` | | Watcher | `watchdog` |
| HTTP | `requests` (Nextcloud WebDAV) | | HTTP | `requests` (Nextcloud WebDAV) |
| SFTP | `paramiko` | | SFTP | `paramiko` |
| Email | `smtplib` (stdlib) | | Email | `smtplib` (stdlib) |
| Service | systemd | | Tests | `pytest` |
| Service | systemd (Template-Unit) |
## 🖥️ Installations-Layout (Multi-Instanz) ## 🖥️ Installations-Layout (Multi-Instanz)
| Pfad | Inhalt | | Pfad | Inhalt |
|------|--------| |------|--------|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) | | `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
| `/opt/pdf-ocr-hotfolder/.repo_path` | Pfad zum Repo, aus dem installiert wurde (nutzt `update.sh`) |
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (mode 640, root:<service-group>) | | `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (mode 640, root:<service-group>) |
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | Template-Unit | | `/etc/systemd/system/pdf-ocr-hotfolder@.service` | Template-Unit |
| `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf` | Drop-in für Container (optional) |
| `/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf` | Drop-in für abweichenden User (optional) | | `/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf` | Drop-in für abweichenden User (optional) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/{incoming,working,outgoing,error}/` | Daten pro Instanz | | `/var/lib/pdf-ocr-hotfolder/<instanz>/{incoming,working,outgoing,error}/` | Daten pro Instanz |
| `/var/log/pdf-ocr-hotfolder/` | Logs |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups | | `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom
Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne
FileHandler, alles geht nach stdout → journald.
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
```
## 👤 Service-User ## 👤 Service-User
- Basis-Install legt Default-User `pdfocr` an (als System-User, falls nicht schon vorhanden) - Basis-Install legt Default-User `pdfocr` an (als System-User, falls nicht schon vorhanden)
@@ -68,9 +91,42 @@ pdf-ocr-hotfolder/
`install.sh` ist gleichzeitig **Installer und Instanz-Manager**: `install.sh` ist gleichzeitig **Installer und Instanz-Manager**:
- Erster Lauf: Basis-Install + erste Instanz anlegen (Pflicht) - Erster Lauf: Basis-Install + erste Instanz anlegen (Pflicht)
- Folgender Lauf: Basis-Install wird übersprungen, bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden - Folgender Lauf: Basis-Install wird übersprungen (erkannt an `venv` + Template-Unit), bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden
- Eingaben pro Instanz: Name (`[a-z0-9-]+`), Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/<name>`), Service-User - Eingaben pro Instanz (seit 0.5.0 fünf statt drei):
- `config.toml` wird aus `config.example.toml` mit sed-substituierten Pfaden generiert 1. Name (`[a-z0-9][a-z0-9-]*`)
2. Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/<name>`)
3. Service-User (default `pdfocr`)
4. **OCR-Sprachen** (default `deu+eng`) — Format `^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`,
bei Unsinn wird erneut gefragt. Jeder Code wird gegen `tesseract --list-langs`
geprüft; fehlt einer, bietet der Installer `tesseract-ocr-<code>` an
(Unterstrich → Bindestrich, `chi_sim` → `tesseract-ocr-chi-sim`). Ablehnung
oder fehlgeschlagene Installation → Warnung, dass OCR mit dieser Sprache
**pro Datei** scheitert, und die Sprach-Abfrage beginnt von vorn (kein
harter Abbruch). Ist `tesseract` nicht aufrufbar, wird die Prüfung
übersprungen und die Eingabe unverändert übernommen.
5. **Original nach erfolgreichem OCR archivieren?** (default **nein** →
`original_on_success = "delete"`). Bei ja wird der Archiv-Pfad abgefragt
(Vorschlag `$BASE/archive`, absoluter Pfad Pflicht), angelegt und auf
`$SVC_USER:$SVC_GROUP` gechownt — innerhalb von `$BASE` erledigt das
bestehende `chown -R` das schon, nur ein Archiv **außerhalb** bekommt ein
eigenes `chown -R`.
- **Sprachen sind bewusst instanz-lokal**, nicht global: ein Hotfolder
`buchhaltung` läuft mit `deu`, ein Hotfolder `export` mit `deu+eng+fra`.
`LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in `create_instance()` — jeder
Durchlauf fragt neu, `deu+eng` ist nur der vorgeschlagene Default. Die Liste
gehört eng gehalten: jede zusätzliche Sprache kostet Laufzeit **und**
Erkennungsqualität.
- Basis-Install prüft zusätzlich die Ghostscript-Version und bietet auf Debian 12 bookworm-backports an; erkennt Container (`systemd-detect-virt --container`) und bietet das LXC-Drop-in an
- `<instanz>.toml` wird aus `config.example.toml` per `sed` generiert. Substituiert
werden die vier `[paths]`-Zeilen **sowie** (seit 0.5.0) `[ocr].languages`,
`[output].original_on_success` und `[output].archive_dir`. Die Ausdrücke sind
am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die deutschen
Kommentarzeilen über den Keys nicht getroffen werden (im Beispiel steht z.B.
`"archive" : Original wird in archive_dir verschoben` als Kommentar);
Pfad-Variablen laufen vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`).
Nach dem sed-Lauf liest `config_value()` die drei Keys zurück und vergleicht
sie mit der Eingabe; erst wenn das passt, nennt die Zusammenfassung Sprachen
und Archiv-Verzeichnis.
- Instanz wird sofort `enable --now` gestartet - Instanz wird sofort `enable --now` gestartet
Manuelles Löschen einer Instanz: Manuelles Löschen einer Instanz:
@@ -85,29 +141,65 @@ systemctl daemon-reload
## 🔄 Update-Verhalten ## 🔄 Update-Verhalten
`update.sh`: `update.sh`:
1. Ermittelt alle aktiven `pdf-ocr-hotfolder@*.service` Units 1. Findet das Repo (eigenes Verzeichnis oder `/opt/pdf-ocr-hotfolder/.repo_path`)
2. Stoppt diese 2. Ermittelt alle **aktiven** `pdf-ocr-hotfolder@*.service` Units und stoppt sie
3. Backup nach `/var/backups/pdf-ocr-hotfolder/` 3. Backup nach `/var/backups/pdf-ocr-hotfolder/` (tar.gz, ohne venv/`__pycache__`)
4. Kopiert Code + requirements + VERSION + config.example aus dem Repo 4. Kopiert Code + requirements + VERSION + config.example aus dem Repo
5. `pip install --upgrade` im venv 5. `pip install --upgrade` im venv
6. Aktualisiert Template-Unit + `daemon-reload` 6. Aktualisiert Template-Unit + `daemon-reload`
7. Startet alle zuvor aktiven Instanzen wieder 7. Setzt den Code-Eigentümer auf den User, dem `venv` gehört (default `pdfocr`)
8. Exit 1 wenn eine Instanz nicht mehr hochkommt 8. Startet alle zuvor aktiven Instanzen wieder, Exit 1 wenn eine nicht mehr hochkommt
Config-Dateien werden **nie** überschrieben. Config-Dateien werden **nie** überschrieben. Das Repo muss erhalten bleiben — `update.sh` kopiert daraus.
## ⚙️ Konfiguration (Überblick)
Vollständiges Beispiel mit Kommentaren: `config.example.toml`. Sektionen:
| Sektion | Zweck |
|---------|-------|
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, fehlt einer → `ConfigError` + Exit 2 |
| `[ocr]` | `languages`, `jobs`, `skip_text`, `oversample`, `pdfa_level`, `deskew`, `clean`, `max_workers`, `timeout` (Sekunden **pro Seite**) |
| `[output]` | `name_mode` (`prefix`/`suffix`/`none`), `name_tag`, `original_on_success` (`delete`/`archive`), `archive_dir` |
| `[verapdf]` | `enabled`, `binary`, `flavour` — optionale PDF/A-Validierung per CLI |
| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op) |
| `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` |
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
| `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` |
| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR |
Unbekannte Keys in einer Sektion werden beim Laden **still verworfen** (`config.py` filtert gegen die Dataclass-Annotationen) — Tippfehler in Key-Namen fallen also nicht auf.
## 🔄 Verarbeitungs-Flow ## 🔄 Verarbeitungs-Flow
1. `watchdog` triggert auf Datei-Event in `incoming/` **Beim Start (`run()` wie `run_once()`), vor allem anderen:**
2. `_wait_until_stable()` wartet, bis Datei nicht mehr wächst (Scanner schreibt mehrmals) 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
3. Move nach `working/` 2. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF — schneller) 3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config)
5. Optional: veraPDF-Validierung (CLI-Subprozess)
6. Move nach `outgoing/` als `OCR_<originalname>.pdf`
7. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
8. Optional E-Mail-Notify
Fehler → Move nach `error/`, Service läuft weiter (kein `exit 1` wie im alten Bash-Tool). **Pro Datei:**
1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/` (beim Start greift `_scan_existing()` bereits liegende PDFs auf)
2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s)
3. Move nach `working/`
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF)
5. Optional: veraPDF-Validierung (CLI-Subprozess) — bei FAIL geht das OCR-Ergebnis nach `error/`, das Original folgt `original_on_success` (wird also bei `archive` **nicht** gelöscht)
6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`: `prefix`/`suffix`/`none` + `name_tag` — das harte `OCR_`-Präfix aus 0.1.0 ist nur noch der Default)
7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix)
8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
9. E-Mail-Notify je nach `[notify.email].on`
**Fehlerbehandlung (Stand 0.4.1):**
| Fehlerfall | Zählt als Fehler | Wo liegt die Datei danach |
|------------|------------------|----------------------------|
| Stabilitäts-Check läuft in den Timeout | ja | bleibt in `incoming/`, wird beim nächsten Lauf erneut versucht |
| Datei verschwindet vor der Verarbeitung | nein | — |
| OCR wirft (ocrmypdf) | ja | `error/` |
| veraPDF FAIL | ja | OCR-Ergebnis nach `error/`, Original laut `original_on_success` (`delete` → weg, `archive` → `archive_dir`; seit 0.4.1) |
| Beliebige Exception aus `process_pdf()` (z.B. `shutil.move` nach `outgoing/`) | ja | `_rescue_to_error()` sucht in `incoming/` und `working/` und verschiebt nach `error/` |
| Mindestens ein Upload-Ziel schlägt fehl | ja | PDF bleibt **bewusst in `outgoing/`** (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele |
Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool). Im `--once`-Modus liefert die CLI **Exit-Code 1**, sobald `error_count > 0` ist, sonst 0.
## 🧠 Performance-Entscheidungen ## 🧠 Performance-Entscheidungen
@@ -116,8 +208,17 @@ Fehler → Move nach `error/`, Service läuft weiter (kein `exit 1` wie im alten
- **`--jobs` an ocrmypdf**: Tesseract parallelisiert Seiten innerhalb eines PDFs - **`--jobs` an ocrmypdf**: Tesseract parallelisiert Seiten innerhalb eines PDFs
- **`skip_text=True`**: bereits OCR-haltige Seiten werden nicht neu verarbeitet - **`skip_text=True`**: bereits OCR-haltige Seiten werden nicht neu verarbeitet
- **Stabilitäts-Check** statt magic-file `new` (alte Bash-Krücke) - **Stabilitäts-Check** statt magic-file `new` (alte Bash-Krücke)
- **`upload_folder()` nutzt `shutil.copyfile()`** statt `read_bytes()`/`write_bytes()` — große PDFs landen nicht komplett im RAM
- veraPDF nur wenn `enabled=true` (JVM-Start ist teuer) - veraPDF nur wenn `enabled=true` (JVM-Start ist teuer)
## ⚠️ 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, und der Preflight bricht mit Exit 2 ab, wenn `pdfa_level` gesetzt **und** die GS-Version betroffen ist. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an).
- **`[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. 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.
- **systemd-Hardening bricht in LXC-Containern** (`Error 226/NAMESPACE` durch `PrivateTmp`, `ProtectSystem` usw., Issue #4). Gegenmittel ist das Drop-in `systemd/lxc-compat.conf` nach `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/`; der Installer erkennt Container via `systemd-detect-virt --container` und bietet es an.
- **Das Paket wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert.** Gestartet wird per `python -m pdf_ocr_hotfolder`, gefunden wird das Modul nur über das Arbeitsverzeichnis — `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der Unit ist daher Pflicht, nicht Kosmetik (Issue #5).
- **Klartext-Passwörter in der Instanz-Config**: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in `/etc/pdf-ocr-hotfolder/<instanz>.toml`. Deshalb `chmod 640` und `chown root:<service-gruppe>`, und `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr`. Beim Debuggen nicht versehentlich in ein Ticket oder Log kopieren.
## 🛠️ Entwicklung ## 🛠️ Entwicklung
Lokaler Test ohne Installation: Lokaler Test ohne Installation:
@@ -130,9 +231,17 @@ cp config.example.toml /tmp/config.toml
python -m pdf_ocr_hotfolder --config /tmp/config.toml python -m pdf_ocr_hotfolder --config /tmp/config.toml
``` ```
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
```bash
pytest # aktuell 95 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.
## 📋 Roadmap / TODO ## 📋 Roadmap / TODO
- [ ] Tests (`pytest`) für `processor` und `uploaders` - [x] Tests (`pytest`) für `processor` und `uploaders` — 95 Tests
- [ ] Test-Lücken schließen: der watchdog-Eventpfad (`_Handler`/`Observer`) wird nirgends getestet, `run_verapdf()` ebenso wenig (der FAIL-*Pfad* in `process_pdf()` ist getestet, die veraPDF-CLI-Anbindung selbst nicht), und `run_ocr()` nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Auch `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`).
- [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit) - [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>` - [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
- [ ] Optional: S3/MinIO Upload-Target - [ ] Optional: S3/MinIO Upload-Target
+166
View File
@@ -1,5 +1,171 @@
# Changelog # Changelog
## [0.5.0] - 2026-09-22
### Added
- Der Installer weist einen Archiv-Pfad ab, der auf `incoming/`, `outgoing/`,
`working/` oder `error/` der Instanz zeigt — im Eingang wuerde das Original
sonst endlos neu aufgegriffen.
- **`install.sh` fragt beim Anlegen einer Instanz die OCR-Sprachen ab**
(`Tesseract-Sprachen [deu+eng]:`). Die Wahl gilt bewusst **pro Instanz** —
ein Hotfolder `buchhaltung` kann mit `deu` laufen, ein Hotfolder `export` mit
`deu+eng+fra`. Der Installer weist vorher darauf hin, dass jede zusaetzliche
Sprache Laufzeit **und** Erkennungsqualitaet kostet, die Liste also eng
gehalten werden sollte. Das Eingabeformat wird geprueft (Sprachcodes mit `+`
verbunden, `chi_sim` & Co. erlaubt); bei Unsinn wird erneut gefragt statt
abzubrechen.
- **Sprachpakete werden nachinstalliert.** Jeder eingegebene Code wird gegen
`tesseract --list-langs` geprueft. Fehlt eine Sprachdatei, bietet der
Installer das passende apt-Paket an (`tesseract-ocr-<code>`, Unterstrich wird
zum Bindestrich: `chi_sim` → `tesseract-ocr-chi-sim`). Lehnt der User ab oder
laesst sich das Paket nicht installieren, warnt der Installer, dass OCR mit
dieser Sprache **bei jeder Datei** scheitern wuerde, und fragt die Sprachen
erneut ab — so kann die Sprache einfach wieder rausgeworfen werden. Ist
`tesseract` nicht aufrufbar, wird die Pruefung uebersprungen und die Eingabe
unveraendert uebernommen.
- **Abfrage `Original nach erfolgreichem OCR archivieren? [j/N]:`** — Default
nein, also weiterhin `original_on_success = "delete"`. Bei ja wird der
Archiv-Pfad abgefragt (Vorschlag `<basis>/archive`), angelegt und auf den
Service-User gechownt; ein Archiv ausserhalb des Instanz-Basis-Pfads bekommt
ein eigenes `chown -R`.
### Changed
- Die Instanz-Config wird weiterhin per `sed` aus `config.example.toml`
erzeugt, substituiert jetzt aber zusaetzlich `[ocr].languages`,
`[output].original_on_success` und `[output].archive_dir` — bisher waren das
die Beispiel-Defaults, `archive_dir` musste von Hand nachgetragen werden.
Die Ausdruecke sind am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die
deutschen Kommentarzeilen ueber den Keys unangetastet bleiben, und
Pfad-Variablen laufen durch `sed_escape_repl()` (maskiert `\`, `&`, `|`) —
Pfade mit Sonderzeichen landen damit korrekt in der Config.
- Nach dem sed-Lauf liest der Installer die drei Keys aus der erzeugten Config
zurueck und vergleicht sie mit der Eingabe. Erst wenn das passt, nennt die
Abschluss-Zusammenfassung zusaetzlich die gewaehlten **Sprachen** und (bei
Archivierung) das **Archiv-Verzeichnis**; sonst gibt es eine Warnung.
## [0.4.1] - 2026-09-22
### Fixed
- **veraPDF-FAIL hat das Original immer gelöscht.** Schlug die PDF/A-Validierung
fehl, wanderte das OCR-Ergebnis nach `error/` und das Original wurde per
`unlink()` entfernt — unabhängig von `[output].original_on_success`. Wer
`archive` konfiguriert hatte, verlor die Datei also ausgerechnet im
Fehlerfall. Der FAIL-Pfad nutzt jetzt dieselbe `_dispose_original()`-Logik
wie der Erfolgsfall: `archive` legt das Original samt
Timestamp-Kollisionsschutz im `archive_dir` ab, `delete` verhält sich wie
bisher. Die Log-Meldung nennt jetzt beides — wohin das OCR-Ergebnis ging und
was mit dem Original passiert ist.
### Removed
- Das nie benutzte Logverzeichnis `/var/log/pdf-ocr-hotfolder/` wird nicht mehr
vom Installer angelegt und ist aus README und Briefing entfernt. Es hat nie
ein Logfile enthalten: `_setup_logging()` nutzt `logging.basicConfig()` ohne
FileHandler, der Dienst loggt nach stdout → journald. **journald ist damit die
einzige Log-Quelle** (`journalctl -u pdf-ocr-hotfolder@<instanz> -f`).
Weder Installer noch Updater fassen das Verzeichnis an: ein vorhandenes,
leeres `/var/log/pdf-ocr-hotfolder/` kann auf bestehenden Installationen
gefahrlos von Hand entfernt werden (`sudo rmdir /var/log/pdf-ocr-hotfolder`).
### Added
- 3 neue Tests für den veraPDF-FAIL-Pfad (`delete`, `archive`,
Archiv-Namenskollision); veraPDF wird dabei gemockt. Suite jetzt 95 Tests.
## [0.4.0] - 2026-09-22
### Added
- `[ocr].timeout` ist jetzt wirksam: der Wert wird als `tesseract_timeout`
(Sekunden pro Seite) an ocrmypdf durchgereicht. Bisher war der Key zwar
dokumentiert, wurde aber nirgends gelesen.
- `check_output_config()` validiert zusätzlich `[output].name_mode`. Ein Tippfehler
führt jetzt beim Start zum Abbruch mit Exit-Code 2, statt erst pro Datei
zuzuschlagen — und zwar bisher **nach** dem Verschieben nach `working/`,
wo die Datei dann liegen blieb.
- Neue Exception `ConfigError` in `pdf_ocr_hotfolder.config` — fehlende
`[paths]`-Sektion oder ein fehlender Pfad-Eintrag liefern eine deutsche
Fehlermeldung mit Datei- und Key-Nennung statt eines nackten `KeyError`-Tracebacks.
Die CLI bricht damit sauber mit Exit-Code 2 ab.
- `pytest.ini` mit `testpaths = tests`, damit `pytest` aus dem Repo-Root läuft.
- 35 neue Tests: Fehlerzählung (Exception, Upload, Stabilitäts-Timeout),
Config-Fehlermeldungen, `tesseract_timeout`-Durchreichung (ocrmypdf gemockt)
und `upload_folder()`.
### Changed
- **`[ocr].timeout` hat eine neue Bedeutung — für bestehende Installationen relevant!**
Der Wert ist kein (nie implementiertes) Gesamt-Timeout pro PDF mehr, sondern
das Limit **pro Seite** für Tesseract. Der Default sinkt entsprechend von
`1800` auf `300`. Wer den alten Wert `1800` in seiner `config.toml` stehen hat,
gibt Tesseract damit 30 Minuten **je Seite** — bitte auf einen Seiten-Wert
anpassen (Richtwert 300).
`0` bedeutet "kein eigenes Limit": der Wert wird dann gar nicht erst
durchgereicht, weil ocrmypdf `tesseract_timeout=0` als "OCR komplett
überspringen" interpretiert.
- `_dispatch_uploads()` liefert jetzt die Namen der fehlgeschlagenen Upload-Ziele
zurück; die doppelte `enabled`-Prüfung (Service + Uploader) ist entfallen —
die Uploader prüfen das selbst.
- `upload_folder()` kopiert mit `shutil.copyfile()` statt
`read_bytes()`/`write_bytes()` — große PDFs landen nicht mehr komplett im
Speicher. Die Selbst-Ziel-Erkennung bleibt unverändert.
### Fixed
- `OcrConfig.pdfa_level` hatte im Code noch den Default `"2"`, obwohl
`config.example.toml` seit 0.2.2 bewusst `""` setzt (Ghostscript-Bug, Issue #3).
Eine Config ohne `[ocr]`-Sektion bzw. ohne den Key lief damit ungewollt in
PDF/A. Default im Code jetzt ebenfalls `""`.
- Eine Exception **nach** dem OCR (z.B. ein fehlgeschlagener
`shutil.move()` nach `outgoing/`) wurde nur im Worker-Callback geloggt.
`error_count` blieb 0 und `--once` lieferte trotz Fehlschlag Exit-Code 0.
Jede Exception aus `process_pdf()` zählt jetzt als Fehler, wird geloggt,
löst eine Fehler-Mail aus und die Datei wandert — soweit noch auffindbar
(`incoming/` oder `working/`) — nach `error/`.
- Fehlgeschlagene Uploads waren folgenlos: die Rückgabewerte der Uploader wurden
verworfen, es ging sogar eine Erfolgs-Mail raus. Jetzt zählt mindestens ein
fehlgeschlagenes Ziel als Fehler und die E-Mail geht als **FEHLER** raus, mit
Nennung der betroffenen Ziele. Das OCR-PDF bleibt bewusst in `outgoing/`
liegen (das OCR selbst war ja erfolgreich) — das steht so auch im Log.
- Lief der Stabilitäts-Check einer Datei in den 60-Sekunden-Timeout, gab es nur
ein `log.warning`; `--once` meldete Exit-Code 0. Jetzt `log.error` +
`error_count`. Die Datei bleibt bewusst in `incoming/` liegen und wird beim
nächsten Lauf erneut versucht. Eine zwischenzeitlich *verschwundene* Datei
wird davon unterschieden und zählt weiterhin nicht als Fehler.
## [0.3.1] - 2026-04-10
### Fixed
- **Issue #4**: LXC/Container-Kompatibilität — systemd-Hardening (`PrivateTmp`, `ProtectSystem`, etc.)
verursacht Error 226/NAMESPACE in LXC-Containern. Installer erkennt Container-Umgebung automatisch
und bietet ein Drop-in an. Zusätzlich liegt `systemd/lxc-compat.conf` als Vorlage im Repo.
- **Issue #5**: `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der systemd Template-Unit ergänzt —
ohne diesen Eintrag konnte das Python-Modul nicht gefunden werden.
- **Issue #6**: Auf Debian 12 bietet der Installer bei betroffenen Ghostscript-Versionen (10.0.0–10.02.0)
jetzt automatisch an, bookworm-backports zu aktivieren und GS zu upgraden (statt nur zu warnen).
## [0.3.0] - 2026-04-09
### Added
- Neue Config-Sektion `[output]` mit:
- `name_mode` — Platzierung des Tags im Dateinamen: `"prefix"`, `"suffix"` (vor Extension), `"none"`
- `name_tag` — verbatim einzufügender String, z.B. `"OCR_"` oder `"_OCR"`
- `original_on_success` — `"delete"` (alter Default) oder `"archive"`
- `archive_dir` — Zielverzeichnis für `"archive"`, mit Kollisions-Schutz (Timestamp-Suffix)
- Runtime-Validierung der Output-Config in `check_output_config()`
- 20 neue Tests für `build_output_name()`, `check_output_config()` und `process_pdf()`
mit allen Kombinationen aus Modus + Original-Behandlung
### Changed
- `process_pdf()` nimmt jetzt `output_cfg: OutputConfig` als Pflicht-Argument
## [0.2.2] - 2026-04-09
### Fixed
- **Issue #3**: Ghostscript 10.0.0–10.02.0 (Debian 12 default) zerschießen OCR mit PDF/A + `skip_text=true`.
- `config.example.toml`: `pdfa_level = ""` als sicherer Default
- Runtime-Preflight: Prüft `gs --version` wenn `pdfa_level` gesetzt ist, bricht mit klarer Fehlermeldung ab
- `install.sh`: warnt bei betroffenen GS-Versionen mit Upgrade-Hinweis auf bookworm-backports
### Added
- `is_ghostscript_broken()` / `detect_ghostscript_version()` in `pdf_ocr_hotfolder.service`
- 19 weitere pytest-Tests für GS-Versions-Detection (parametrisiert) und Preflight-Kombinationen
## [0.2.1] - 2026-04-09 ## [0.2.1] - 2026-04-09
### Fixed ### Fixed
+82 -6
View File
@@ -25,7 +25,14 @@ sudo ./install.sh
Der Installer: Der Installer:
1. Installiert einmalig Code + venv + systemd-Template-Unit 1. Installiert einmalig Code + venv + systemd-Template-Unit
2. Fragt nach Instanz-Name, Basis-Pfad, Service-User 2. Fragt **pro Instanz** ab:
- Instanz-Name
- Basis-Pfad für die Daten
- Service-User
- **OCR-Sprachen** (Tesseract, Default `deu+eng`) — fehlende Sprachpakete
(`tesseract-ocr-<code>`) werden erkannt und auf Wunsch nachinstalliert
- **Original nach erfolgreichem OCR archivieren?** (Default nein = löschen;
bei ja zusätzlich der Archiv-Pfad, vorgeschlagen `<basis>/archive`)
3. Legt so viele Hotfolder-Instanzen an, wie du willst (`Weitere Instanz anlegen? [j/N]`) 3. Legt so viele Hotfolder-Instanzen an, wie du willst (`Weitere Instanz anlegen? [j/N]`)
Bei jedem erneuten Aufruf erkennt der Installer bestehende Instanzen und fragt nur nach neuen. Bei jedem erneuten Aufruf erkennt der Installer bestehende Instanzen und fragt nur nach neuen.
@@ -47,6 +54,27 @@ Das Tool arbeitet komplett **instanzbasiert** über eine systemd Template-Unit `
- eigene Datenverzeichnisse: `/var/lib/pdf-ocr-hotfolder/<name>/{incoming,working,outgoing,error}/` - eigene Datenverzeichnisse: `/var/lib/pdf-ocr-hotfolder/<name>/{incoming,working,outgoing,error}/`
- eigene systemd-Unit: `pdf-ocr-hotfolder@<name>.service` - eigene systemd-Unit: `pdf-ocr-hotfolder@<name>.service`
- optional eigenen Service-User (via Drop-in `/etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d/user.conf`) - optional eigenen Service-User (via Drop-in `/etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d/user.conf`)
- **eigene OCR-Sprachen und eigene Original-Behandlung** (löschen oder archivieren)
### Sprachen pro Instanz
Die Tesseract-Sprachen werden bewusst **je Instanz** abgefragt, nicht global:
Hotfolder haben unterschiedliche Post. Ein Buchhaltungs-Hotfolder sieht nur
deutsche Belege, ein Export-Hotfolder internationale Korrespondenz:
```toml
# /etc/pdf-ocr-hotfolder/buchhaltung.toml
languages = "deu"
# /etc/pdf-ocr-hotfolder/export.toml
languages = "deu+eng+fra"
```
**Die Liste so eng wie möglich halten.** Jede zusätzliche Sprache kostet
Laufzeit *und* Erkennungsqualität: Tesseract muss mehr Modelle gegeneinander
abwägen und verwechselt dabei Wörter, die in der einen Sprache eindeutig wären.
`deu+eng+fra` auf reinen Deutsch-Scans ist also kein Sicherheitsnetz, sondern
ein Rückschritt.
Beispiel für 3 Hotfolder: Beispiel für 3 Hotfolder:
@@ -71,22 +99,42 @@ Manuell eine weitere Instanz anlegen geht auch — einfach `install.sh` erneut s
| `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR | | `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) | | `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene PDFs | | `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene PDFs |
| `/var/log/pdf-ocr-hotfolder/` | Logs (zusätzlich zu journald) |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups | | `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
## Konfiguration ## Konfiguration
Vollständiges Beispiel: [`config.example.toml`](config.example.toml). Wichtigste Sektionen: Vollständiges Beispiel: [`config.example.toml`](config.example.toml). Wichtigste Sektionen:
Der Installer fragt `[ocr].languages`, `[output].original_on_success` und
`[output].archive_dir` pro Instanz ab und schreibt sie direkt in die
Instanz-Config — die Werte unten sind nur die Beispiel-Defaults.
### `[ocr]` ### `[ocr]`
```toml ```toml
languages = "deu+eng" # Tesseract-Sprachen languages = "deu+eng" # Tesseract-Sprachen (Installer fragt pro Instanz)
jobs = 4 # Threads pro PDF jobs = 4 # Threads pro PDF
skip_text = true # bereits OCR-haltige Seiten überspringen skip_text = true # bereits OCR-haltige Seiten überspringen
pdfa_level = "2" # "1", "2", "3" oder "" für reines PDF pdfa_level = "" # "1", "2", "3" oder "" für reines PDF (Default "" wegen Ghostscript-Bug, s.u.)
deskew = true deskew = true
max_workers = 2 # parallele PDFs max_workers = 2 # parallele PDFs
timeout = 1800 timeout = 300 # max. Sekunden pro SEITE (Tesseract), 0 = ocrmypdf-Default
```
### `[output]`
```toml
# Dateiname im outgoing/:
# "prefix" → OCR_scan.pdf
# "suffix" → scan_OCR.pdf (vor der Extension)
# "none" → scan.pdf (unverändert)
name_mode = "prefix"
name_tag = "OCR_"
# Nach erfolgreichem OCR mit dem Original:
# "delete" → löschen
# "archive" → in archive_dir verschieben
# Beides fragt der Installer beim Anlegen der Instanz ab:
original_on_success = "delete"
archive_dir = "" # absoluter Pfad, Pflicht bei "archive"
``` ```
### `[upload.nextcloud]` ### `[upload.nextcloud]`
@@ -132,6 +180,16 @@ sudo systemctl status 'pdf-ocr-hotfolder@*'
sudo systemctl restart 'pdf-ocr-hotfolder@*' sudo systemctl restart 'pdf-ocr-hotfolder@*'
``` ```
### Logs
Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit
ins journal:
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
```
## Update ## Update
```bash ```bash
@@ -174,6 +232,24 @@ Service-User braucht **rw** auf alle vier Verzeichnisse unter `/var/lib/pdf-ocr-
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder
``` ```
### LXC/Container: Error 226/NAMESPACE
In LXC-Containern schlagen systemd-Hardening-Optionen fehl. Der Installer erkennt Container automatisch und bietet ein Drop-in an. Manuell:
```bash
sudo mkdir -p /etc/systemd/system/pdf-ocr-hotfolder@.service.d/
sudo cp /opt/pdf-ocr-hotfolder/systemd/lxc-compat.conf \
/etc/systemd/system/pdf-ocr-hotfolder@.service.d/
sudo systemctl daemon-reload
sudo systemctl restart 'pdf-ocr-hotfolder@*'
```
### Ghostscript PDF/A-Bug auf Debian 12
GS 10.00.0–10.02.0 (Debian 12 Default) zerstört OCR bei `pdfa_level` + `skip_text=true`. Der Installer bietet automatisch bookworm-backports an. Manuell:
```bash
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
```
### veraPDF-Validierung schlägt immer fehl ### veraPDF-Validierung schlägt immer fehl
veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `enabled = false`. veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `enabled = false`.
@@ -205,5 +281,5 @@ MIT — © Sonith UG
--- ---
**Version:** 0.2.0 **Version:** 0.5.0
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
+1 -1
View File
@@ -1 +1 @@
0.2.1 0.5.0
+27 -3
View File
@@ -21,15 +21,39 @@ 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)
pdfa_level = "2" # 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.
# Sicherer Default ist "" — nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist.
pdfa_level = ""
# Schiefe Scans automatisch begradigen # Schiefe Scans automatisch begradigen
deskew = true deskew = true
# Hintergrund säubern # Hintergrund säubern
clean = false clean = false
# Maximale parallele PDFs (Hauptsystem hat selten mehr als 1-2 gleichzeitig) # Maximale parallele PDFs (Hauptsystem hat selten mehr als 1-2 gleichzeitig)
max_workers = 2 max_workers = 2
# Timeout pro PDF in Sekunden # Max. Sekunden, die Tesseract pro SEITE laufen darf (0 = kein Limit).
timeout = 1800 # ocrmypdf kennt kein Gesamt-Timeout pro Dokument, nur dieses Seiten-Limit
# (ocrmypdf-Option --tesseract-timeout). Läuft eine Seite in den Timeout,
# wird sie ohne Textebene ins Ergebnis übernommen; die Verarbeitung der
# restlichen Seiten läuft weiter.
# 0 = wir geben kein Limit vor und überlassen es dem ocrmypdf-Default.
timeout = 300
[output]
# Wie soll die Ziel-Datei im outgoing/-Ordner benannt werden?
# "prefix" : name_tag wird vor den Dateinamen gestellt (OCR_scan.pdf)
# "suffix" : name_tag wird vor die Extension gestellt (scan_OCR.pdf)
# "none" : Dateiname bleibt wie das Original
name_mode = "prefix"
# Verbatim einzufügender String. Leerer String = kein Tag (wie mode="none").
# Beispiele: "OCR_", "[OCR]_", "_OCR", "_searchable"
name_tag = "OCR_"
# Was passiert mit dem Original, wenn OCR erfolgreich war?
# "delete" : Original wird gelöscht (alter Standard)
# "archive" : Original wird in archive_dir verschoben
original_on_success = "delete"
# Absoluter Pfad; nur relevant wenn original_on_success = "archive"
archive_dir = ""
[verapdf] [verapdf]
# PDF/A-Validierung (optional) # PDF/A-Validierung (optional)
+217 -7
View File
@@ -26,7 +26,6 @@ fi
INSTALL_DIR="/opt/pdf-ocr-hotfolder" INSTALL_DIR="/opt/pdf-ocr-hotfolder"
CONFIG_DIR="/etc/pdf-ocr-hotfolder" CONFIG_DIR="/etc/pdf-ocr-hotfolder"
DATA_ROOT="/var/lib/pdf-ocr-hotfolder" DATA_ROOT="/var/lib/pdf-ocr-hotfolder"
LOG_DIR="/var/log/pdf-ocr-hotfolder"
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
DEFAULT_USER="pdfocr" DEFAULT_USER="pdfocr"
@@ -52,6 +51,60 @@ install_base() {
icc-profiles-free ca-certificates curl icc-profiles-free ca-certificates curl
log_info "System-Pakete ok ✓" log_info "System-Pakete ok ✓"
# Ghostscript-Versions-Check (Issue #3 + Issue #6)
if command -v gs >/dev/null 2>&1; then
GS_VER="$(gs --version 2>/dev/null || echo 0.0)"
log_info "Ghostscript: $GS_VER"
case "$GS_VER" in
10.0.0|10.00.0|10.01.*|10.02.0)
echo
log_warn "═══════════════════════════════════════════════════════════════"
log_warn "Ghostscript $GS_VER ist vom PDF/A-Bug betroffen (10.0.0–10.02.0)."
log_warn "Mit pdfa_level + skip_text=true kann ocrmypdf KEINE PDFs verarbeiten."
log_warn "═══════════════════════════════════════════════════════════════"
echo
# Prüfe ob Debian bookworm (12) — Backports anbieten
if grep -q 'bookworm' /etc/os-release 2>/dev/null; then
read -r -p "Ghostscript via bookworm-backports upgraden? [J/n]: " UPGRADE_GS
UPGRADE_GS="${UPGRADE_GS:-J}"
if [[ "$UPGRADE_GS" =~ ^[JjYy]$ ]]; then
log_info "Aktiviere bookworm-backports..."
if ! grep -q 'bookworm-backports' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then
echo 'deb http://deb.debian.org/debian bookworm-backports main' \
> /etc/apt/sources.list.d/bookworm-backports.list
apt-get update -qq
fi
apt-get install -y -t bookworm-backports ghostscript
GS_VER_NEW="$(gs --version 2>/dev/null || echo '?')"
log_info "Ghostscript aktualisiert: $GS_VER → $GS_VER_NEW ✓"
else
log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
fi
else
log_warn "Kein Debian bookworm erkannt — manuelles Upgrade nötig."
log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
fi
echo
;;
esac
fi
# LXC/Container-Erkennung (Issue #4)
if systemd-detect-virt --container -q 2>/dev/null; then
VIRT_TYPE="$(systemd-detect-virt --container 2>/dev/null || echo 'container')"
log_warn "Container-Umgebung erkannt ($VIRT_TYPE)."
log_warn "systemd-Hardening kann in Containern fehlschlagen (Error 226/NAMESPACE)."
read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX
LXC_FIX="${LXC_FIX:-J}"
if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then
local LXC_DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@.service.d"
mkdir -p "$LXC_DROPIN_DIR"
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN_DIR/lxc-compat.conf"
systemctl daemon-reload
log_info "LXC-Kompatibilitäts-Drop-in installiert ✓"
fi
fi
log_step "Default-User '$DEFAULT_USER' prüfen" log_step "Default-User '$DEFAULT_USER' prüfen"
if id "$DEFAULT_USER" &>/dev/null; then if id "$DEFAULT_USER" &>/dev/null; then
log_info "'$DEFAULT_USER' existiert bereits" log_info "'$DEFAULT_USER' existiert bereits"
@@ -61,7 +114,7 @@ install_base() {
fi fi
log_step "Verzeichnisse anlegen" log_step "Verzeichnisse anlegen"
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT" "$LOG_DIR" mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT"
chown root:"$DEFAULT_USER" "$CONFIG_DIR" chown root:"$DEFAULT_USER" "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR" chmod 750 "$CONFIG_DIR"
@@ -86,7 +139,7 @@ install_base() {
systemctl daemon-reload systemctl daemon-reload
log_info "Template-Unit installiert ✓" log_info "Template-Unit installiert ✓"
chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR" "$LOG_DIR" chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR"
} }
# ============================================================ # ============================================================
@@ -116,6 +169,66 @@ show_existing_instances() {
echo echo
} }
# Liest den Wert eines Keys (erste Zuweisung am Zeilenanfang) aus einer Config
config_value() {
local file="$1" key="$2"
sed -n "s|^${key}[[:space:]]*=[[:space:]]*\"\(.*\)\"[[:space:]]*$|\1|p" "$file" | head -n1
}
# Maskiert Sonderzeichen, damit ein Pfad gefahrlos in eine sed-Ersetzung darf
# (Trennzeichen '|', Rueckverweis '&', Backslash).
sed_escape_repl() {
printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
}
# Prueft jeden Tesseract-Sprachcode gegen die installierten Sprachdateien und
# bietet fehlende Pakete zur Installation an.
# Rueckgabe: 0 = alle Sprachen verfuegbar (oder Pruefung nicht moeglich),
# 1 = mindestens eine Sprache fehlt weiterhin.
ensure_tesseract_langs() {
local langs="$1"
local raw installed code pkg answer rc=0
local -a codes
if ! command -v tesseract >/dev/null 2>&1; then
log_warn "tesseract ist nicht aufrufbar — Sprachpruefung wird uebersprungen."
log_warn "Eingabe '$langs' wird unveraendert uebernommen."
return 0
fi
if ! raw="$(tesseract --list-langs 2>/dev/null)"; then
log_warn "'tesseract --list-langs' schlug fehl — Sprachpruefung wird uebersprungen."
log_warn "Eingabe '$langs' wird unveraendert uebernommen."
return 0
fi
installed="$(printf '%s\n' "$raw" | grep -vi '^List of available' || true)"
IFS='+' read -r -a codes <<< "$langs"
for code in "${codes[@]}"; do
[ -n "$code" ] || continue
if printf '%s\n' "$installed" | grep -qxF "$code"; then
log_info "Sprache '$code' ist installiert ✓"
continue
fi
pkg="tesseract-ocr-${code//_/-}"
log_warn "Sprache '$code' ist nicht installiert (Paket: $pkg)."
read -r -p "Paket '$pkg' jetzt installieren? [J/n]: " answer
answer="${answer:-J}"
if [[ "$answer" =~ ^[JjYy]$ ]]; then
if ! apt-get install -y --no-install-recommends "$pkg"; then
log_error "Paket '$pkg' liess sich nicht installieren."
elif tesseract --list-langs 2>/dev/null | grep -qxF "$code"; then
log_info "Paket '$pkg' installiert ✓"
continue
else
log_error "Paket '$pkg' ist da, aber tesseract kennt '$code' weiterhin nicht."
fi
fi
log_warn "Ohne die Sprachdatei '$code' scheitert das OCR bei JEDER Datei."
rc=1
done
return $rc
}
create_instance() { create_instance() {
echo echo
read -r -p "Instanz-Name (nur a-z, 0-9, -): " INST read -r -p "Instanz-Name (nur a-z, 0-9, -): " INST
@@ -153,20 +266,111 @@ create_instance() {
fi fi
fi fi
# --- OCR-Sprachen ---
echo
log_info "Tesseract-Sprachen — gelten NUR fuer diese Instanz '$INST'."
log_info "Jede zusaetzliche Sprache kostet Laufzeit und verschlechtert zugleich"
log_info "die Erkennung — also so eng wie moeglich waehlen (z.B. nur 'deu')."
local LANGS
while true; do
read -r -p "Tesseract-Sprachen [deu+eng]: " LANGS
LANGS="${LANGS:-deu+eng}"
if [[ ! "$LANGS" =~ ^[a-z]{3}(_[A-Za-z]+)?(\+[a-z]{3}(_[A-Za-z]+)?)*$ ]]; then
log_error "Ungueltiges Format. Erwartet: Sprachcodes mit '+' verbunden,"
log_error "z.B. 'deu', 'deu+eng' oder 'chi_sim+eng'."
continue
fi
if ensure_tesseract_langs "$LANGS"; then
break
fi
log_warn "Bitte Sprachen erneut angeben (fehlende Sprache einfach weglassen)."
echo
done
# --- Original archivieren? ---
echo
local ORIG_MODE="delete"
local ARCHIVE_DIR=""
local ARCHIVE_ANS
read -r -p "Original nach erfolgreichem OCR archivieren? [j/N]: " ARCHIVE_ANS
ARCHIVE_ANS="${ARCHIVE_ANS:-N}"
if [[ "$ARCHIVE_ANS" =~ ^[JjYy]$ ]]; then
ORIG_MODE="archive"
local default_archive="$BASE/archive"
while true; do
read -r -p "Archiv-Verzeichnis [$default_archive]: " ARCHIVE_DIR
ARCHIVE_DIR="${ARCHIVE_DIR:-$default_archive}"
if [[ "$ARCHIVE_DIR" != /* ]]; then
log_error "Bitte einen absoluten Pfad angeben (beginnt mit '/')."
continue
fi
# Das Archiv darf keines der Arbeitsverzeichnisse sein: im Eingang
# wuerde das Original endlos neu aufgegriffen, in den uebrigen
# kollidiert es mit der Verarbeitung.
case "${ARCHIVE_DIR%/}" in
"$BASE/incoming"|"$BASE/outgoing"|"$BASE/working"|"$BASE/error")
log_error "Das Archiv darf nicht incoming/outgoing/working/error sein."
continue
;;
esac
break
done
else
log_info "Original wird nach erfolgreichem OCR geloescht (original_on_success = \"delete\")."
fi
log_info "Lege Datenverzeichnisse unter $BASE an..." log_info "Lege Datenverzeichnisse unter $BASE an..."
mkdir -p "$BASE"/{incoming,outgoing,working,error} mkdir -p "$BASE"/{incoming,outgoing,working,error}
if [ -n "$ARCHIVE_DIR" ]; then
mkdir -p "$ARCHIVE_DIR"
fi
chown -R "$SVC_USER":"$SVC_GROUP" "$BASE" chown -R "$SVC_USER":"$SVC_GROUP" "$BASE"
# Innerhalb von $BASE erledigt das chown -R oben schon alles; nur ein Archiv
# ausserhalb braucht eigenes mkdir/chown.
if [ -n "$ARCHIVE_DIR" ] && [[ "$ARCHIVE_DIR" != "$BASE"/* ]] && [ "$ARCHIVE_DIR" != "$BASE" ]; then
chown -R "$SVC_USER":"$SVC_GROUP" "$ARCHIVE_DIR"
log_info "Archiv-Verzeichnis $ARCHIVE_DIR angelegt (liegt ausserhalb von $BASE)"
fi
log_info "Erstelle Config $CONFIG_DIR/$INST.toml..." log_info "Erstelle Config $CONFIG_DIR/$INST.toml..."
# Verankerte Ausdruecke (Zeilenanfang + Key + '='), damit die deutschen
# Kommentarzeilen ueber den Keys unangetastet bleiben.
local ESC_BASE ESC_ARCHIVE ESC_LANGS
ESC_BASE="$(sed_escape_repl "$BASE")"
ESC_ARCHIVE="$(sed_escape_repl "$ARCHIVE_DIR")"
ESC_LANGS="$(sed_escape_repl "$LANGS")"
sed \ sed \
-e "s|/var/lib/pdf-ocr-hotfolder/incoming|$BASE/incoming|" \ -e "s|^incoming[[:space:]]*=.*|incoming = \"$ESC_BASE/incoming\"|" \
-e "s|/var/lib/pdf-ocr-hotfolder/outgoing|$BASE/outgoing|" \ -e "s|^outgoing[[:space:]]*=.*|outgoing = \"$ESC_BASE/outgoing\"|" \
-e "s|/var/lib/pdf-ocr-hotfolder/working|$BASE/working|" \ -e "s|^working[[:space:]]*=.*|working = \"$ESC_BASE/working\"|" \
-e "s|/var/lib/pdf-ocr-hotfolder/error|$BASE/error|" \ -e "s|^error[[:space:]]*=.*|error = \"$ESC_BASE/error\"|" \
-e "s|^languages[[:space:]]*=.*|languages = \"$ESC_LANGS\"|" \
-e "s|^original_on_success[[:space:]]*=.*|original_on_success = \"$ORIG_MODE\"|" \
-e "s|^archive_dir[[:space:]]*=.*|archive_dir = \"$ESC_ARCHIVE\"|" \
"$INSTALL_DIR/config.example.toml" > "$CONFIG_DIR/$INST.toml" "$INSTALL_DIR/config.example.toml" > "$CONFIG_DIR/$INST.toml"
chown root:"$SVC_GROUP" "$CONFIG_DIR/$INST.toml" chown root:"$SVC_GROUP" "$CONFIG_DIR/$INST.toml"
chmod 640 "$CONFIG_DIR/$INST.toml" chmod 640 "$CONFIG_DIR/$INST.toml"
# Erzeugte Config gegenpruefen: tragen die drei Keys wirklich die Auswahl?
local CFG_OK=1 got key want
for key in languages original_on_success archive_dir; do
case "$key" in
languages) want="$LANGS" ;;
original_on_success) want="$ORIG_MODE" ;;
archive_dir) want="$ARCHIVE_DIR" ;;
esac
got="$(config_value "$CONFIG_DIR/$INST.toml" "$key")"
if [ "$got" != "$want" ]; then
log_error "Config-Pruefung: $key ist \"$got\", erwartet \"$want\""
CFG_OK=0
fi
done
if [ "$CFG_OK" -eq 1 ]; then
log_info "Config-Pruefung ok ✓ (languages / original_on_success / archive_dir)"
else
log_warn "Bitte $CONFIG_DIR/$INST.toml von Hand nachziehen."
fi
# Drop-in für abweichenden Service-User # Drop-in für abweichenden Service-User
if [ "$SVC_USER" != "$DEFAULT_USER" ]; then if [ "$SVC_USER" != "$DEFAULT_USER" ]; then
local DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@${INST}.service.d" local DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@${INST}.service.d"
@@ -193,6 +397,12 @@ EOF
echo " Eingang: $BASE/incoming" echo " Eingang: $BASE/incoming"
echo " Ausgang: $BASE/outgoing" echo " Ausgang: $BASE/outgoing"
echo " User: $SVC_USER ($SVC_GROUP)" echo " User: $SVC_USER ($SVC_GROUP)"
if [ "$CFG_OK" -eq 1 ]; then
echo " Sprachen: $LANGS"
if [ "$ORIG_MODE" = "archive" ]; then
echo " Archiv: $ARCHIVE_DIR"
fi
fi
echo echo
} }
+1 -1
View File
@@ -1,3 +1,3 @@
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen.""" """PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
__version__ = "0.1.0" __version__ = "0.5.0"
+5 -1
View File
@@ -7,7 +7,7 @@ import sys
from pathlib import Path from pathlib import Path
from . import __version__ from . import __version__
from .config import load_config from .config import ConfigError, load_config
from .service import HotfolderService, PreflightError from .service import HotfolderService, PreflightError
@@ -36,7 +36,11 @@ def main() -> int:
print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr) print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr)
return 2 return 2
try:
cfg = load_config(cfg_path) cfg = load_config(cfg_path)
except ConfigError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 2
_setup_logging(cfg.log_level) _setup_logging(cfg.log_level)
service = HotfolderService(cfg) service = HotfolderService(cfg)
+52 -7
View File
@@ -7,6 +7,10 @@ from pathlib import Path
from typing import Any from typing import Any
class ConfigError(RuntimeError):
"""Konfigurationsdatei ist unvollständig oder fehlerhaft."""
@dataclass @dataclass
class Paths: class Paths:
incoming: Path incoming: Path
@@ -21,11 +25,26 @@ class OcrConfig:
jobs: int = 4 jobs: int = 4
skip_text: bool = True skip_text: bool = True
oversample: int = 300 oversample: int = 300
pdfa_level: str = "2" # Default bewusst leer: pdfa_level + skip_text zerschießt OCR mit
# Ghostscript 10.0.0-10.02.0 (Debian-12-Default), siehe Issue #3
pdfa_level: str = ""
deskew: bool = True deskew: bool = True
clean: bool = False clean: bool = False
max_workers: int = 2 max_workers: int = 2
timeout: int = 1800 # Max. Sekunden, die Tesseract pro Seite laufen darf (0 = kein eigenes Limit)
timeout: int = 300
@dataclass
class OutputConfig:
# "prefix" | "suffix" | "none"
name_mode: str = "prefix"
# Tag-String, verbatim eingefügt (Leerstring = kein Tag)
name_tag: str = "OCR_"
# "delete" | "archive"
original_on_success: str = "delete"
# Absoluter Pfad; Pflicht wenn original_on_success == "archive"
archive_dir: str = ""
@dataclass @dataclass
@@ -79,6 +98,7 @@ class EmailNotify:
class Config: class Config:
paths: Paths paths: Paths
ocr: OcrConfig ocr: OcrConfig
output: OutputConfig
verapdf: VeraPdfConfig verapdf: VeraPdfConfig
folder: FolderUpload folder: FolderUpload
nextcloud: NextcloudUpload nextcloud: NextcloudUpload
@@ -94,21 +114,46 @@ def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
return cur if isinstance(cur, dict) else {} return cur if isinstance(cur, dict) else {}
def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
"""Holt einen Pflicht-Pfad aus der [paths]-Sektion.
Wirft ConfigError mit klarer Meldung statt eines nackten KeyError.
"""
value = p.get(key)
if value is None or (isinstance(value, str) and not value.strip()):
raise ConfigError(
f"{cfg_path}: In der Sektion [paths] fehlt der Eintrag '{key}' "
f"(oder er ist leer). Bitte ergänzen, z.B. "
f'{key} = "/var/lib/pdf-ocr-hotfolder/{key}" '
f"— siehe config.example.toml."
)
return Path(str(value))
def load_config(path: str | Path) -> Config: def load_config(path: str | Path) -> Config:
path = Path(path) path = Path(path)
with path.open("rb") as f: with path.open("rb") as f:
data = tomllib.load(f) data = tomllib.load(f)
if not isinstance(data.get("paths"), dict):
raise ConfigError(
f"{path}: Die Sektion [paths] fehlt (oder ist keine Tabelle). "
"Sie muss die Einträge incoming, outgoing, working und error "
"enthalten — siehe config.example.toml."
)
p = _section(data, "paths") p = _section(data, "paths")
paths = Paths( paths = Paths(
incoming=Path(p["incoming"]), incoming=_require_path(p, "incoming", path),
outgoing=Path(p["outgoing"]), outgoing=_require_path(p, "outgoing", path),
working=Path(p["working"]), working=_require_path(p, "working", path),
error=Path(p["error"]), error=_require_path(p, "error", path),
) )
ocr = OcrConfig(**{k: v for k, v in _section(data, "ocr").items() ocr = OcrConfig(**{k: v for k, v in _section(data, "ocr").items()
if k in OcrConfig.__annotations__}) if k in OcrConfig.__annotations__})
output = OutputConfig(**{k: v for k, v in _section(data, "output").items()
if k in OutputConfig.__annotations__})
verapdf = VeraPdfConfig(**{k: v for k, v in _section(data, "verapdf").items() verapdf = VeraPdfConfig(**{k: v for k, v in _section(data, "verapdf").items()
if k in VeraPdfConfig.__annotations__}) if k in VeraPdfConfig.__annotations__})
folder = FolderUpload(**{k: v for k, v in _section(data, "upload", "folder").items() folder = FolderUpload(**{k: v for k, v in _section(data, "upload", "folder").items()
@@ -123,7 +168,7 @@ def load_config(path: str | Path) -> Config:
log_level = _section(data, "logging").get("level", "INFO") log_level = _section(data, "logging").get("level", "INFO")
return Config( return Config(
paths=paths, ocr=ocr, verapdf=verapdf, paths=paths, ocr=ocr, output=output, verapdf=verapdf,
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email, folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
log_level=log_level, log_level=log_level,
) )
+86 -5
View File
@@ -7,10 +7,39 @@ import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .config import OcrConfig, VeraPdfConfig from .config import OcrConfig, OutputConfig, VeraPdfConfig
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft
VALID_NAME_MODES = ("prefix", "suffix", "none")
def build_output_name(src_name: str, mode: str, tag: str) -> str:
"""Erzeugt den Ziel-Dateinamen für ein OCR-PDF.
Args:
src_name: Original-Dateiname (z.B. "scan.pdf")
mode: "prefix" | "suffix" | "none"
tag: Einzufügender String (verbatim, leer = kein Tag)
Beispiele:
prefix "OCR_": "scan.pdf" -> "OCR_scan.pdf"
suffix "_OCR": "scan.pdf" -> "scan_OCR.pdf"
suffix "_OCR": "scan.tar.gz.pdf" -> "scan.tar.gz_OCR.pdf"
none: "scan.pdf" -> "scan.pdf"
"""
if mode == "none" or not tag:
return src_name
if mode == "prefix":
return f"{tag}{src_name}"
if mode == "suffix":
# Nur die letzte Extension abspalten, sonst "foo.bar.pdf" kaputt gemacht
p = Path(src_name)
stem, ext = p.stem, p.suffix
return f"{stem}{tag}{ext}"
raise ValueError(f"Unbekannter name_mode: {mode!r}")
@dataclass @dataclass
class ProcessResult: class ProcessResult:
@@ -39,6 +68,15 @@ def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
else: else:
kwargs["output_type"] = "pdf" kwargs["output_type"] = "pdf"
# [ocr].timeout = max. Sekunden, die Tesseract pro Seite laufen darf.
# ocrmypdf kennt kein Gesamt-Timeout für ein Dokument, nur `tesseract_timeout`
# (pro Seite). ACHTUNG: ocrmypdf interpretiert tesseract_timeout=0 als
# "OCR komplett überspringen" — deshalb wird 0 bei uns als "kein eigenes
# Limit" behandelt und gar nicht erst durchgereicht (dann gilt der
# ocrmypdf-Default).
if cfg.timeout and cfg.timeout > 0:
kwargs["tesseract_timeout"] = float(cfg.timeout)
log.info("OCR start: %s", src.name) log.info("OCR start: %s", src.name)
ocrmypdf.ocr(str(src), str(dst), **kwargs) ocrmypdf.ocr(str(src), str(dst), **kwargs)
log.info("OCR done: %s", dst.name) log.info("OCR done: %s", dst.name)
@@ -71,11 +109,13 @@ def process_pdf(
error_dir: Path, error_dir: Path,
ocr_cfg: OcrConfig, ocr_cfg: OcrConfig,
vera_cfg: VeraPdfConfig, vera_cfg: VeraPdfConfig,
output_cfg: OutputConfig,
) -> ProcessResult: ) -> ProcessResult:
"""Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error.""" """Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag)
work_src = working_dir / src.name work_src = working_dir / src.name
work_out = working_dir / f"OCR_{src.name}" work_out = working_dir / f"__ocr_{out_name}" # Temp-Name, damit er != src.name ist
final_out = outgoing_dir / f"OCR_{src.name}" final_out = outgoing_dir / out_name
try: try:
shutil.move(str(src), str(work_src)) shutil.move(str(src), str(work_src))
@@ -93,17 +133,58 @@ def process_pdf(
if vera_cfg.enabled: if vera_cfg.enabled:
vera_ok = run_verapdf(work_out, vera_cfg) vera_ok = run_verapdf(work_out, vera_cfg)
if not vera_ok: if not vera_ok:
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
# [output].original_on_success-Regel wie im Erfolgsfall, sonst
# verliert man es ausgerechnet im Fehlerfall (archive!).
_move_to_error(work_out, error_dir) _move_to_error(work_out, error_dir)
work_src.unlink(missing_ok=True) _dispose_original(work_src, src.name, output_cfg)
log.error(
"veraPDF FAIL: %s — OCR-Ergebnis nach %s verschoben, Original %s",
src.name, error_dir,
"archiviert" if output_cfg.original_on_success == "archive" else "gelöscht",
)
return ProcessResult(src, final_out, False, return ProcessResult(src, final_out, False,
"verapdf validation failed", verapdf_passed=False) "verapdf validation failed", verapdf_passed=False)
outgoing_dir.mkdir(parents=True, exist_ok=True) outgoing_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(work_out), str(final_out)) shutil.move(str(work_out), str(final_out))
work_src.unlink(missing_ok=True) _dispose_original(work_src, src.name, output_cfg)
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok) return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
Validierung ablehnt: auch dann soll `archive` das Original erhalten.
"""
if not work_src.exists():
return
mode = cfg.original_on_success
if mode == "delete":
work_src.unlink(missing_ok=True)
return
if mode == "archive":
if not cfg.archive_dir:
log.error("original_on_success=archive aber archive_dir ist leer — lösche stattdessen")
work_src.unlink(missing_ok=True)
return
archive = Path(cfg.archive_dir)
archive.mkdir(parents=True, exist_ok=True)
dest = archive / original_name
# Bei Namens-Kollision mit Timestamp umbenennen
if dest.exists():
from datetime import datetime
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dest = archive / f"{dest.stem}_{ts}{dest.suffix}"
shutil.move(str(work_src), str(dest))
log.info("Original archiviert: %s", dest)
return
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
work_src.unlink(missing_ok=True)
def _move_to_error(p: Path, error_dir: Path) -> None: def _move_to_error(p: Path, error_dir: Path) -> None:
error_dir.mkdir(parents=True, exist_ok=True) error_dir.mkdir(parents=True, exist_ok=True)
try: try:
+190 -20
View File
@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import shutil import shutil
import signal import signal
import subprocess
import threading import threading
import time import time
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
@@ -13,7 +15,7 @@ from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer from watchdog.observers import Observer
from .config import Config from .config import Config
from .processor import ProcessResult, process_pdf from .processor import VALID_NAME_MODES, ProcessResult, _move_to_error, process_pdf
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -26,11 +28,80 @@ 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):
# 10.0.0 .. 10.02.0 (inklusive). Ab 10.02.1 wieder nutzbar.
_GS_BROKEN_MIN = (10, 0, 0)
_GS_BROKEN_MAX = (10, 2, 0)
def check_preflight() -> None:
"""Prüft, ob alle externen Abhängigkeiten (Tesseract, Ghostscript) installiert sind.
Wirft PreflightError mit Liste der fehlenden Binaries. def _parse_version(text: str) -> tuple[int, ...] | None:
"""Extrahiert die erste X.Y[.Z] Version aus einem String."""
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text)
if not m:
return None
return tuple(int(x) if x is not None else 0 for x in m.groups())
def is_ghostscript_broken(version: str | None) -> bool:
"""Prüft, ob eine Ghostscript-Version vom PDF/A+skip_text Bug betroffen ist.
Betrifft 10.0.0 bis einschließlich 10.02.0. Ab 10.02.1 wieder sicher.
"""
if not version:
return False
parsed = _parse_version(version)
if parsed is None:
return False
# Auf 3-Tupel normalisieren
while len(parsed) < 3:
parsed = parsed + (0,)
parsed = parsed[:3]
return _GS_BROKEN_MIN <= parsed <= _GS_BROKEN_MAX
def detect_ghostscript_version() -> str | None:
"""Ruft `gs --version` auf und gibt den Versionsstring zurück (oder None)."""
gs = shutil.which("gs")
if gs is None:
return None
try:
result = subprocess.run([gs, "--version"], capture_output=True,
text=True, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return None
return result.stdout.strip() or None
def check_output_config(mode: str, archive_dir: str,
name_mode: str = "prefix") -> None:
"""Validiert die [output]-Section. Wirft PreflightError bei Problemen."""
valid_modes = {"delete", "archive"}
if mode not in valid_modes:
raise PreflightError(
f"[output].original_on_success={mode!r} ungültig. "
f"Erlaubt: {sorted(valid_modes)}"
)
if mode == "archive" and not archive_dir:
raise PreflightError(
"[output].original_on_success='archive' erfordert [output].archive_dir"
)
# Früh prüfen: sonst schlägt ein Tippfehler erst pro Datei zu — und zwar
# NACH dem Move nach working/, wo die Datei dann liegen bleibt.
if name_mode not in VALID_NAME_MODES:
raise PreflightError(
f"[output].name_mode={name_mode!r} ungültig. "
f"Erlaubt: {sorted(VALID_NAME_MODES)}"
)
def check_preflight(pdfa_level: str = "") -> None:
"""Prüft externe Abhängigkeiten.
- Tesseract und Ghostscript müssen im PATH sein
- Bei gesetztem pdfa_level wird die Ghostscript-Version gegen den
bekannten 10.0.0–10.02.0 Bug geprüft
Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript.
""" """
missing = [b for b in _REQUIRED_BINARIES if shutil.which(b) is None] missing = [b for b in _REQUIRED_BINARIES if shutil.which(b) is None]
if missing: if missing:
@@ -39,6 +110,16 @@ def check_preflight() -> None:
+ ". Bitte installieren: sudo apt install tesseract-ocr ghostscript" + ". Bitte installieren: sudo apt install tesseract-ocr ghostscript"
) )
if pdfa_level:
gs_version = detect_ghostscript_version()
if is_ghostscript_broken(gs_version):
raise PreflightError(
f"Ghostscript {gs_version} ist mit pdfa_level='{pdfa_level}' nicht "
"kompatibel (bekannter Bug in 10.0.0–10.02.0). "
"Entweder ghostscript auf >=10.02.1 upgraden (z.B. via bookworm-backports) "
"oder in der Config [ocr].pdfa_level = \"\" setzen."
)
def _is_pdf(path: Path) -> bool: def _is_pdf(path: Path) -> bool:
return path.suffix.lower() == ".pdf" and path.is_file() return path.suffix.lower() == ".pdf" and path.is_file()
@@ -113,7 +194,10 @@ class HotfolderService:
# ---- Lifecycle ---- # ---- Lifecycle ----
def run(self) -> None: def run(self) -> None:
check_preflight() check_preflight(self.cfg.ocr.pdfa_level)
check_output_config(self.cfg.output.original_on_success,
self.cfg.output.archive_dir,
self.cfg.output.name_mode)
self.ensure_dirs() self.ensure_dirs()
self._scan_existing() self._scan_existing()
@@ -137,7 +221,10 @@ class HotfolderService:
Returns: Returns:
Anzahl fehlgeschlagener PDFs (0 = alles ok). Anzahl fehlgeschlagener PDFs (0 = alles ok).
""" """
check_preflight() check_preflight(self.cfg.ocr.pdfa_level)
check_output_config(self.cfg.output.original_on_success,
self.cfg.output.archive_dir,
self.cfg.output.name_mode)
self.ensure_dirs() self.ensure_dirs()
self._scan_existing() self._scan_existing()
self._executor.shutdown(wait=True) self._executor.shutdown(wait=True)
@@ -181,13 +268,33 @@ class HotfolderService:
# ---- Processing ---- # ---- Processing ----
def _count_success(self) -> None:
with self._lock:
self._success_count += 1
def _count_error(self) -> None:
with self._lock:
self._error_count += 1
def _process(self, path: Path) -> None: def _process(self, path: Path) -> None:
if not _wait_until_stable(path): if not _wait_until_stable(path):
log.warning("Datei nicht stabilisiert, überspringe: %s", path) if not path.exists():
# Datei wurde währenddessen entfernt — kein Fehlerfall
log.info("Datei vor der Verarbeitung verschwunden: %s", path)
return
# Bewusst als Fehler zählen: sonst liefert --once trotz liegen
# gebliebener Datei Exit 0.
log.error(
"Datei hat sich nicht stabilisiert (Timeout): %s — bleibt in %s "
"liegen und wird beim nächsten Lauf erneut versucht",
path, self.cfg.paths.incoming,
)
self._count_error()
return return
if not path.exists(): if not path.exists():
return return
try:
result: ProcessResult = process_pdf( result: ProcessResult = process_pdf(
src=path, src=path,
working_dir=self.cfg.paths.working, working_dir=self.cfg.paths.working,
@@ -195,24 +302,87 @@ class HotfolderService:
error_dir=self.cfg.paths.error, error_dir=self.cfg.paths.error,
ocr_cfg=self.cfg.ocr, ocr_cfg=self.cfg.ocr,
vera_cfg=self.cfg.verapdf, vera_cfg=self.cfg.verapdf,
output_cfg=self.cfg.output,
) )
except Exception as e: # noqa: BLE001 - kein Fehler darf die Zählung umgehen
log.exception("Unerwarteter Fehler bei der Verarbeitung von %s", path.name)
self._count_error()
self._rescue_to_error(path)
self._notify(ProcessResult(
path, self.cfg.paths.outgoing / path.name, False,
f"unerwarteter Fehler: {e}",
))
return
with self._lock: if not result.success:
if result.success: self._count_error()
self._success_count += 1 self._notify(result)
else: return
self._error_count += 1
if result.success: failed = self._dispatch_uploads(result.output)
self._dispatch_uploads(result.output) if failed:
log.error(
"Upload fehlgeschlagen (%s) für %s — das OCR selbst war "
"erfolgreich, die Datei bleibt daher in %s liegen und wird "
"NICHT nach error/ verschoben",
", ".join(failed), result.output.name, result.output.parent,
)
self._count_error()
self._notify_upload_failure(result, failed)
return
self._count_success()
self._notify(result) self._notify(result)
def _dispatch_uploads(self, pdf: Path) -> None: def _rescue_to_error(self, src: Path) -> None:
upload_folder(pdf, self.cfg.folder, self.cfg.paths.outgoing) """Bringt eine Datei nach einer unerwarteten Exception ins error-Verzeichnis.
if self.cfg.nextcloud.enabled:
upload_nextcloud(pdf, self.cfg.nextcloud) Die Datei kann je nach Abbruchzeitpunkt noch in incoming/ oder schon in
if self.cfg.sftp.enabled: working/ liegen. Der erste Treffer wird verschoben (keine Doppel-Moves),
upload_sftp(pdf, self.cfg.sftp) Fehler beim Verschieben werden nur geloggt.
"""
error_dir = self.cfg.paths.error
for candidate in (src, self.cfg.paths.working / src.name):
try:
if not candidate.is_file():
continue
if candidate.parent.resolve() == error_dir.resolve():
return # liegt bereits im error-Verzeichnis
except OSError:
continue
_move_to_error(candidate, error_dir)
return
log.warning("Datei %s nach Fehler nicht mehr auffindbar — "
"kein Verschieben nach error/ möglich", src.name)
def _dispatch_uploads(self, pdf: Path) -> list[str]:
"""Schiebt das fertige PDF an alle Upload-Ziele.
Die uploader prüfen `cfg.enabled` jeweils selbst und liefern für
deaktivierte Ziele True.
Returns:
Namen der fehlgeschlagenen Ziele — leere Liste = alle erfolgreich.
"""
failed: list[str] = []
if not upload_folder(pdf, self.cfg.folder, self.cfg.paths.outgoing):
failed.append("folder")
if not upload_nextcloud(pdf, self.cfg.nextcloud):
failed.append("nextcloud")
if not upload_sftp(pdf, self.cfg.sftp):
failed.append("sftp")
return failed
def _notify_upload_failure(self, result: ProcessResult, failed: list[str]) -> None:
"""Fehler-Mail, wenn das OCR lief, aber mindestens ein Upload scheiterte."""
subject = f"[pdf-ocr] FEHLER Upload: {result.source.name}"
body = (
f"OCR erfolgreich: {result.output}\n\n"
f"Fehlgeschlagene Upload-Ziele: {', '.join(failed)}\n\n"
f"Das OCR-PDF bleibt in {result.output.parent} liegen und wurde "
"NICHT nach error/ verschoben. Details siehe Log.\n"
)
notify_email(self.cfg.email, subject, body, False)
def _notify(self, result: ProcessResult) -> None: def _notify(self, result: ProcessResult) -> None:
if result.success: if result.success:
+4 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import shutil
import smtplib import smtplib
import ssl import ssl
from email.message import EmailMessage from email.message import EmailMessage
@@ -25,7 +26,9 @@ def upload_folder(pdf: Path, cfg: FolderUpload, default_target: Path) -> bool:
try: try:
if pdf.resolve() == dest.resolve(): if pdf.resolve() == dest.resolve():
return True return True
dest.write_bytes(pdf.read_bytes()) # copyfile statt read_bytes/write_bytes: große PDFs nicht komplett
# in den Speicher laden
shutil.copyfile(pdf, dest)
log.info("Folder upload OK: %s", dest) log.info("Folder upload OK: %s", dest)
return True return True
except OSError as e: except OSError as e:
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+10
View File
@@ -0,0 +1,10 @@
# Drop-in für LXC/Container-Betrieb
# Kopieren nach: /etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf
# Danach: systemctl daemon-reload && systemctl restart 'pdf-ocr-hotfolder@*'
[Service]
PrivateTmp=false
ProtectSystem=false
ProtectKernelTunables=false
ProtectKernelModules=false
ProtectControlGroups=false
+1
View File
@@ -7,6 +7,7 @@ Wants=network-online.target
Type=simple Type=simple
User=pdfocr User=pdfocr
Group=pdfocr Group=pdfocr
WorkingDirectory=/opt/pdf-ocr-hotfolder
ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/%i.toml ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/%i.toml
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
+2
View File
@@ -11,6 +11,7 @@ from pdf_ocr_hotfolder.config import (
FolderUpload, FolderUpload,
NextcloudUpload, NextcloudUpload,
OcrConfig, OcrConfig,
OutputConfig,
Paths, Paths,
SftpUpload, SftpUpload,
VeraPdfConfig, VeraPdfConfig,
@@ -32,6 +33,7 @@ def tmp_config(tmp_path: Path) -> Config:
return Config( return Config(
paths=paths, paths=paths,
ocr=OcrConfig(max_workers=1), ocr=OcrConfig(max_workers=1),
output=OutputConfig(),
verapdf=VeraPdfConfig(enabled=False), verapdf=VeraPdfConfig(enabled=False),
folder=FolderUpload(enabled=False), folder=FolderUpload(enabled=False),
nextcloud=NextcloudUpload(enabled=False), nextcloud=NextcloudUpload(enabled=False),
+79
View File
@@ -0,0 +1,79 @@
"""Tests für verständliche Fehlermeldungen beim Laden der Config."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from pdf_ocr_hotfolder.config import ConfigError, load_config
_FULL_PATHS = """
[paths]
incoming = "/tmp/in"
outgoing = "/tmp/out"
working = "/tmp/work"
error = "/tmp/err"
"""
def _write(tmp_path: Path, content: str) -> Path:
cfg = tmp_path / "config.toml"
cfg.write_text(content)
return cfg
def test_missing_paths_section(tmp_path: Path) -> None:
"""Fehlt [paths] komplett → ConfigError statt KeyError."""
cfg = _write(tmp_path, '[ocr]\nlanguages = "deu"\n')
with pytest.raises(ConfigError) as exc:
load_config(cfg)
msg = str(exc.value)
assert "[paths]" in msg
assert str(cfg) in msg
def test_empty_config_file(tmp_path: Path) -> None:
cfg = _write(tmp_path, "")
with pytest.raises(ConfigError, match=r"\[paths\]"):
load_config(cfg)
@pytest.mark.parametrize("missing", ["incoming", "outgoing", "working", "error"])
def test_missing_single_path_key(tmp_path: Path, missing: str) -> None:
"""Fehlt ein einzelner Key, wird genau dieser genannt."""
lines = [line for line in _FULL_PATHS.strip().splitlines()
if not line.startswith(missing)]
cfg = _write(tmp_path, "\n".join(lines) + "\n")
with pytest.raises(ConfigError) as exc:
load_config(cfg)
msg = str(exc.value)
assert missing in msg
assert str(cfg) in msg
def test_empty_path_value_is_rejected(tmp_path: Path) -> None:
"""Ein leerer Pfad ist genauso falsch wie ein fehlender."""
cfg = _write(tmp_path, _FULL_PATHS.replace('working = "/tmp/work"',
'working = ""'))
with pytest.raises(ConfigError, match="working"):
load_config(cfg)
def test_complete_paths_section_loads(tmp_path: Path) -> None:
cfg = _write(tmp_path, _FULL_PATHS)
loaded = load_config(cfg)
assert loaded.paths.incoming == Path("/tmp/in")
assert loaded.paths.error == Path("/tmp/err")
def test_main_returns_2_on_broken_config(tmp_path: Path, monkeypatch, capsys) -> None:
"""CLI bricht sauber mit Exit-Code 2 ab — ohne Traceback."""
cfg = _write(tmp_path, '[ocr]\nlanguages = "deu"\n')
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg), "--once"])
from pdf_ocr_hotfolder.__main__ import main
assert main() == 2
err = capsys.readouterr().err
assert "FEHLER" in err
assert "[paths]" in err
+257
View File
@@ -0,0 +1,257 @@
"""Tests für die Fehlerzählung im Service.
Deckt drei bisher stumme Fehlerpfade ab:
- Exception aus `process_pdf()` (z.B. fehlgeschlagener Move nach outgoing/)
- fehlgeschlagene Uploads
- Timeout im Stabilitäts-Check
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.processor import ProcessResult
from pdf_ocr_hotfolder.service import HotfolderService
def _run_once(tmp_config, **patches):
"""Führt run_once() mit gemocktem Preflight aus und gibt den Service zurück."""
stack = [
patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None),
patch("pdf_ocr_hotfolder.service._wait_until_stable",
return_value=patches.pop("stable", True)),
]
for target, kwargs in patches.items():
stack.append(patch(f"pdf_ocr_hotfolder.service.{target}", **kwargs))
service = HotfolderService(tmp_config)
try:
for p in stack:
p.start()
service.run_once()
finally:
for p in reversed(stack):
p.stop()
service._executor.shutdown(wait=False)
return service
# ---------------- Exception aus process_pdf ----------------
def test_exception_from_process_pdf_counts_as_error(tmp_config) -> None:
"""Eine Exception aus process_pdf() darf die Zählung nicht umgehen."""
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
def explode(src, **kwargs):
raise OSError("move to outgoing failed")
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
assert service.error_count == 1
assert service.success_count == 0
def test_exception_moves_file_to_error_dir(tmp_config) -> None:
"""Die Datei landet nach einer Exception im error-Verzeichnis."""
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
def explode(src, **kwargs):
raise RuntimeError("kaputt")
_run_once(tmp_config, process_pdf={"side_effect": explode})
assert (tmp_config.paths.error / "boom.pdf").exists()
assert not (tmp_config.paths.incoming / "boom.pdf").exists()
def test_exception_after_move_to_working_rescues_from_working(tmp_config) -> None:
"""Realistischer Fall: process_pdf hat schon nach working/ verschoben."""
src = tmp_config.paths.incoming / "boom.pdf"
src.write_bytes(b"%PDF-1.4\n")
def explode(src: Path, working_dir: Path, **kwargs):
# process_pdf verschiebt zuerst nach working/, dann knallt der Move
# nach outgoing/
src.rename(working_dir / src.name)
raise OSError("move to outgoing failed")
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
assert service.error_count == 1
assert (tmp_config.paths.error / "boom.pdf").exists()
assert not (tmp_config.paths.working / "boom.pdf").exists()
def test_exception_with_vanished_file_does_not_raise(tmp_config) -> None:
"""Ist die Datei nicht mehr auffindbar, wird nur geloggt — kein Crash."""
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
def explode(src: Path, **kwargs):
src.unlink(missing_ok=True)
raise RuntimeError("kaputt")
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
assert service.error_count == 1
assert not (tmp_config.paths.error / "boom.pdf").exists()
def test_exception_triggers_error_notification(tmp_config) -> None:
"""Auch bei einer Exception geht eine Fehler-Mail raus (success=False)."""
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
def explode(src, **kwargs):
raise RuntimeError("kaputt")
with patch("pdf_ocr_hotfolder.service.notify_email") as mail:
_run_once(tmp_config, process_pdf={"side_effect": explode})
assert mail.call_count == 1
args = mail.call_args[0]
assert "FEHLER" in args[1]
assert args[3] is False # success-Flag
# ---------------- Upload-Fehler ----------------
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
out = outgoing_dir / f"OCR_{src.name}"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(b"%PDF-1.4 ocr\n")
src.unlink(missing_ok=True)
return ProcessResult(src, out, True)
def test_failed_upload_counts_as_error(tmp_config) -> None:
"""Ein fehlgeschlagener Upload zählt als Fehler, nicht als Erfolg."""
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
service = _run_once(
tmp_config,
process_pdf={"side_effect": _fake_success},
upload_nextcloud={"return_value": False},
)
assert service.error_count == 1
assert service.success_count == 0
def test_failed_upload_sends_error_mail_naming_targets(tmp_config) -> None:
"""Die Fehler-Mail nennt die fehlgeschlagenen Ziele."""
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
with patch("pdf_ocr_hotfolder.service.notify_email") as mail:
_run_once(
tmp_config,
process_pdf={"side_effect": _fake_success},
upload_nextcloud={"return_value": False},
upload_sftp={"return_value": False},
)
assert mail.call_count == 1
_cfg, subject, body, success = mail.call_args[0]
assert "FEHLER" in subject
assert success is False
assert "nextcloud" in body
assert "sftp" in body
def test_failed_upload_keeps_pdf_in_outgoing(tmp_config) -> None:
"""Das OCR war erfolgreich — die Datei bleibt in outgoing/, nicht error/."""
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
_run_once(
tmp_config,
process_pdf={"side_effect": _fake_success},
upload_folder={"return_value": False},
)
assert (tmp_config.paths.outgoing / "OCR_a.pdf").exists()
assert not (tmp_config.paths.error / "OCR_a.pdf").exists()
def test_successful_uploads_count_as_success(tmp_config) -> None:
"""Gegenprobe: wenn alle Uploads durchgehen, zählt es als Erfolg."""
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
service = _run_once(tmp_config, process_pdf={"side_effect": _fake_success})
assert service.success_count == 1
assert service.error_count == 0
def test_dispatch_uploads_reports_failed_targets(tmp_config) -> None:
"""_dispatch_uploads() liefert die Namen der fehlgeschlagenen Ziele."""
service = HotfolderService(tmp_config)
try:
pdf = tmp_config.paths.outgoing / "x.pdf"
pdf.write_bytes(b"%PDF-1.4\n")
with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \
patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=False), \
patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True):
assert service._dispatch_uploads(pdf) == ["nextcloud"]
with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \
patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=True), \
patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True):
assert service._dispatch_uploads(pdf) == []
finally:
service._executor.shutdown(wait=False)
# ---------------- Stabilitäts-Timeout ----------------
def test_unstable_file_counts_as_error(tmp_config) -> None:
"""Stabilisiert sich eine Datei nicht, ist das ein Fehler (Exit 1)."""
(tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n")
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False), \
patch("pdf_ocr_hotfolder.service.process_pdf") as proc:
service = HotfolderService(tmp_config)
try:
errors = service.run_once()
finally:
service._executor.shutdown(wait=False)
assert errors == 1
assert service.error_count == 1
proc.assert_not_called()
def test_unstable_file_stays_in_incoming(tmp_config) -> None:
"""Die instabile Datei bleibt bewusst in incoming/ liegen."""
(tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n")
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False):
service = HotfolderService(tmp_config)
try:
service.run_once()
finally:
service._executor.shutdown(wait=False)
assert (tmp_config.paths.incoming / "slow.pdf").exists()
assert not (tmp_config.paths.error / "slow.pdf").exists()
def test_vanished_file_is_not_an_error(tmp_config) -> None:
"""Verschwundene Datei ist kein Fehler — _wait_until_stable liefert dafür
ebenfalls False."""
pdf = tmp_config.paths.incoming / "weg.pdf"
pdf.write_bytes(b"%PDF-1.4\n")
def vanish(path: Path, **kwargs) -> bool:
path.unlink(missing_ok=True)
return False
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
patch("pdf_ocr_hotfolder.service._wait_until_stable", side_effect=vanish):
service = HotfolderService(tmp_config)
try:
errors = service.run_once()
finally:
service._executor.shutdown(wait=False)
assert errors == 0
assert service.error_count == 0
+72
View File
@@ -0,0 +1,72 @@
"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 PDF/A-Bug-Erkennung."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.service import (
PreflightError,
check_preflight,
is_ghostscript_broken,
)
@pytest.mark.parametrize("version,expected", [
# Betroffene Versionen
("10.0.0", True),
("10.00.0", True),
("10.01.0", True),
("10.01.1", True),
("10.01.2", True),
("10.02.0", True),
# Sichere Versionen
("10.02.1", False),
("10.03.0", False),
("10.04.0", False),
("11.0.0", False),
("9.56.1", False), # Debian 11 / Ubuntu 22.04
("9.55.0", False),
# Edge cases
("", False),
(None, False),
("garbage", False),
])
def test_is_ghostscript_broken(version, expected) -> None:
assert is_ghostscript_broken(version) is expected
def test_check_preflight_without_pdfa_passes_with_broken_gs() -> None:
"""Ohne pdfa_level darf der betroffene GS verwendet werden."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.0.0"):
check_preflight(pdfa_level="") # darf nicht werfen
def test_check_preflight_with_pdfa_fails_on_broken_gs() -> None:
"""Mit pdfa_level + kaputtem GS → PreflightError mit hilfreicher Meldung."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.0.0"):
with pytest.raises(PreflightError, match="Ghostscript 10.0.0"):
check_preflight(pdfa_level="2")
def test_check_preflight_with_pdfa_passes_on_fixed_gs() -> None:
"""Mit pdfa_level + gefixtem GS → ok."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.02.1"):
check_preflight(pdfa_level="2") # darf nicht werfen
def test_default_config_pdfa_level_is_empty() -> None:
"""Default-Config der Beispiel-Datei soll pdfa_level='' enthalten (Issue #3)."""
from pathlib import Path
import tomllib
cfg_path = Path(__file__).parent.parent / "config.example.toml"
with cfg_path.open("rb") as f:
data = tomllib.load(f)
assert data["ocr"]["pdfa_level"] == "", \
"config.example.toml muss pdfa_level='' als sicheren Default haben"
+81
View File
@@ -0,0 +1,81 @@
"""Tests für [ocr].timeout → ocrmypdf `tesseract_timeout`.
ocrmypdf wird hier komplett gemockt (per sys.modules), es läuft also nie
wirklich — die Tests laufen auch ohne installiertes ocrmypdf.
"""
from __future__ import annotations
import sys
import tomllib
from pathlib import Path
from types import ModuleType
import pytest
from pdf_ocr_hotfolder.config import OcrConfig
from pdf_ocr_hotfolder.processor import run_ocr
@pytest.fixture
def fake_ocrmypdf(monkeypatch) -> ModuleType:
"""Schiebt ein Dummy-ocrmypdf in sys.modules und merkt sich die kwargs."""
mod = ModuleType("ocrmypdf")
mod.calls = [] # type: ignore[attr-defined]
def ocr(src, dst, **kwargs):
mod.calls.append({"src": src, "dst": dst, "kwargs": kwargs}) # type: ignore[attr-defined]
Path(dst).write_bytes(b"%PDF-1.4 ocr\n")
mod.ocr = ocr # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "ocrmypdf", mod)
return mod
def _run(fake, tmp_path: Path, cfg: OcrConfig) -> dict:
src = tmp_path / "in.pdf"
src.write_bytes(b"%PDF-1.4\n")
run_ocr(src, tmp_path / "out.pdf", cfg)
assert len(fake.calls) == 1
return fake.calls[0]["kwargs"]
def test_timeout_is_passed_as_tesseract_timeout(fake_ocrmypdf, tmp_path: Path) -> None:
kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=120))
assert kwargs["tesseract_timeout"] == 120.0
def test_timeout_zero_means_no_limit(fake_ocrmypdf, tmp_path: Path) -> None:
"""0 = kein Limit → der Key darf NICHT durchgereicht werden.
ocrmypdf würde tesseract_timeout=0 als 'OCR überspringen' auslegen.
"""
kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=0))
assert "tesseract_timeout" not in kwargs
def test_negative_timeout_is_ignored(fake_ocrmypdf, tmp_path: Path) -> None:
kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=-5))
assert "tesseract_timeout" not in kwargs
def test_default_timeout_is_passed(fake_ocrmypdf, tmp_path: Path) -> None:
kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig())
assert kwargs["tesseract_timeout"] == 300.0
def test_other_kwargs_still_present(fake_ocrmypdf, tmp_path: Path) -> None:
"""Der neue Key ersetzt nichts Bestehendes."""
kwargs = _run(fake_ocrmypdf, tmp_path,
OcrConfig(languages="deu", jobs=2, pdfa_level=""))
assert kwargs["language"] == "deu"
assert kwargs["jobs"] == 2
assert kwargs["output_type"] == "pdf"
assert kwargs["skip_text"] is True
def test_config_default_matches_example(tmp_path: Path) -> None:
"""Dataclass-Default und config.example.toml dürfen nicht auseinanderlaufen."""
cfg_path = Path(__file__).parent.parent / "config.example.toml"
with cfg_path.open("rb") as f:
data = tomllib.load(f)
assert data["ocr"]["timeout"] == OcrConfig().timeout == 300
+2 -2
View File
@@ -8,7 +8,7 @@ from pdf_ocr_hotfolder.processor import ProcessResult
from pdf_ocr_hotfolder.service import HotfolderService from pdf_ocr_hotfolder.service import HotfolderService
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera_cfg): def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
out = outgoing_dir / f"OCR_{src.name}" out = outgoing_dir / f"OCR_{src.name}"
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(b"%PDF-1.4 ocr\n") out.write_bytes(b"%PDF-1.4 ocr\n")
@@ -16,7 +16,7 @@ def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera
return ProcessResult(src, out, True) return ProcessResult(src, out, True)
def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera_cfg): def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
error_dir.mkdir(parents=True, exist_ok=True) error_dir.mkdir(parents=True, exist_ok=True)
dest = error_dir / src.name dest = error_dir / src.name
src.rename(dest) src.rename(dest)
+315
View File
@@ -0,0 +1,315 @@
"""Tests für Feature: konfigurierbare Dateinamen und Original-Behandlung."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import build_output_name, process_pdf
from pdf_ocr_hotfolder.service import PreflightError, check_output_config
# ---------------- build_output_name ----------------
@pytest.mark.parametrize("src,mode,tag,expected", [
# prefix
("scan.pdf", "prefix", "OCR_", "OCR_scan.pdf"),
("scan.pdf", "prefix", "[OCR] ", "[OCR] scan.pdf"),
# suffix (Tag vor Extension)
("scan.pdf", "suffix", "_OCR", "scan_OCR.pdf"),
("scan.pdf", "suffix", "-ocr", "scan-ocr.pdf"),
# none
("scan.pdf", "none", "OCR_", "scan.pdf"),
# leerer Tag = none
("scan.pdf", "prefix", "", "scan.pdf"),
("scan.pdf", "suffix", "", "scan.pdf"),
# Mehrfach-Punkte im Namen: nur letzte Extension zählt
("rechnung.2026.pdf", "suffix", "_OCR", "rechnung.2026_OCR.pdf"),
("rechnung.2026.pdf", "prefix", "OCR_", "OCR_rechnung.2026.pdf"),
# Name ohne Extension
("NO_EXT", "suffix", "_OCR", "NO_EXT_OCR"),
])
def test_build_output_name(src, mode, tag, expected) -> None:
assert build_output_name(src, mode, tag) == expected
def test_build_output_name_invalid_mode() -> None:
with pytest.raises(ValueError, match="name_mode"):
build_output_name("x.pdf", "bogus", "OCR_")
# ---------------- check_output_config ----------------
def test_check_output_config_delete_ok() -> None:
check_output_config("delete", "") # ok
def test_check_output_config_archive_requires_dir() -> None:
with pytest.raises(PreflightError, match="archive_dir"):
check_output_config("archive", "")
def test_check_output_config_archive_with_dir_ok() -> None:
check_output_config("archive", "/var/archive") # ok
def test_check_output_config_invalid_mode() -> None:
with pytest.raises(PreflightError, match="ungültig"):
check_output_config("trash", "")
@pytest.mark.parametrize("name_mode", ["prefix", "suffix", "none"])
def test_check_output_config_accepts_valid_name_modes(name_mode) -> None:
check_output_config("delete", "", name_mode) # ok
@pytest.mark.parametrize("name_mode", ["prefixx", "Prefix", "", "postfix"])
def test_check_output_config_invalid_name_mode(name_mode) -> None:
"""Tippfehler in name_mode muss schon im Preflight auffallen."""
with pytest.raises(PreflightError, match="name_mode"):
check_output_config("delete", "", name_mode)
def test_run_once_aborts_on_invalid_name_mode(tmp_config) -> None:
"""Der Dienst bricht beim Start ab, bevor eine Datei angefasst wird."""
from unittest.mock import patch
from pdf_ocr_hotfolder.service import HotfolderService
tmp_config.output.name_mode = "bogus"
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
service = HotfolderService(tmp_config)
try:
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None):
with pytest.raises(PreflightError, match="name_mode"):
service.run_once()
finally:
service._executor.shutdown(wait=False)
# Datei wurde nicht angefasst
assert (tmp_config.paths.incoming / "a.pdf").exists()
def test_main_returns_2_on_invalid_name_mode(tmp_path: Path, monkeypatch) -> None:
"""CLI liefert Exit-Code 2 — gleicher Mechanismus wie die übrigen Preflights."""
import sys
from unittest.mock import patch as _patch
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_path / 'in'}"
outgoing = "{tmp_path / 'out'}"
working = "{tmp_path / 'work'}"
error = "{tmp_path / 'err'}"
[output]
name_mode = "bogus"
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file), "--once"])
with _patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None):
from pdf_ocr_hotfolder.__main__ import main
assert main() == 2
# ---------------- process_pdf mit Original-Behandlung ----------------
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
"""Simuliert ocrmypdf: kopiert Inhalt, erzeugt Zieldatei."""
dst.write_bytes(b"%PDF-1.4 OCRed\n" + src.read_bytes())
def _prepare(tmp_path: Path) -> dict:
dirs = {
"working": tmp_path / "working",
"outgoing": tmp_path / "outgoing",
"error": tmp_path / "error",
"archive": tmp_path / "archive",
"incoming": tmp_path / "incoming",
}
for d in dirs.values():
d.mkdir(parents=True, exist_ok=True)
src = dirs["incoming"] / "scan.pdf"
src.write_bytes(b"%PDF-1.4 original\n")
return {"src": src, **dirs}
def test_process_pdf_prefix_delete(tmp_path: Path) -> None:
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete")
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
result = process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
assert result.success
assert (env["outgoing"] / "OCR_scan.pdf").exists()
# Original ist weg, weder in incoming noch in working
assert not env["src"].exists()
assert not (env["working"] / "scan.pdf").exists()
def test_process_pdf_suffix_delete(tmp_path: Path) -> None:
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="suffix", name_tag="_OCR",
original_on_success="delete")
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
result = process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
assert result.success
assert (env["outgoing"] / "scan_OCR.pdf").exists()
def test_process_pdf_none_mode(tmp_path: Path) -> None:
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="none", name_tag="OCR_",
original_on_success="delete")
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
result = process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
assert result.success
# Ausgang hat GLEICHEN Namen wie Original
assert (env["outgoing"] / "scan.pdf").exists()
def test_process_pdf_archive_original(tmp_path: Path) -> None:
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
result = process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
assert result.success
assert (env["outgoing"] / "OCR_scan.pdf").exists()
# Original liegt jetzt im Archiv
archived = env["archive"] / "scan.pdf"
assert archived.exists()
assert archived.read_bytes() == b"%PDF-1.4 original\n"
def test_process_pdf_archive_name_collision(tmp_path: Path) -> None:
"""Bei Namens-Kollision im Archiv wird Timestamp angehängt."""
env = _prepare(tmp_path)
# Vorhandene Kollisions-Datei
(env["archive"] / "scan.pdf").write_bytes(b"old")
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=out_cfg,
)
# Alte Datei unverändert
assert (env["archive"] / "scan.pdf").read_bytes() == b"old"
# Neue Datei mit Timestamp-Suffix
archived = list(env["archive"].glob("scan_*.pdf"))
assert len(archived) == 1
assert archived[0].read_bytes() == b"%PDF-1.4 original\n"
# ---------------- veraPDF FAIL: Original folgt original_on_success ----------------
def _run_with_vera_fail(env: dict, out_cfg: OutputConfig):
"""process_pdf mit gemocktem OCR und einem veraPDF, das FAIL meldet."""
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), \
patch("pdf_ocr_hotfolder.processor.run_verapdf", return_value=False):
return process_pdf(
src=env["src"],
working_dir=env["working"],
outgoing_dir=env["outgoing"],
error_dir=env["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=True),
output_cfg=out_cfg,
)
def test_process_pdf_verapdf_fail_delete_removes_original(tmp_path: Path) -> None:
"""delete: Verhalten wie bisher — OCR-Ergebnis nach error/, Original weg."""
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete")
result = _run_with_vera_fail(env, out_cfg)
assert not result.success
assert result.verapdf_passed is False
# OCR-Ergebnis liegt in error/
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
# Original ist weg
assert not env["src"].exists()
assert not (env["working"] / "scan.pdf").exists()
assert not (env["outgoing"] / "OCR_scan.pdf").exists()
def test_process_pdf_verapdf_fail_archive_keeps_original(tmp_path: Path) -> None:
"""archive: das Original darf im Fehlerfall NICHT verloren gehen."""
env = _prepare(tmp_path)
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
result = _run_with_vera_fail(env, out_cfg)
assert not result.success
assert result.verapdf_passed is False
# OCR-Ergebnis liegt in error/
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
# Original liegt unversehrt im Archiv
archived = env["archive"] / "scan.pdf"
assert archived.exists()
assert archived.read_bytes() == b"%PDF-1.4 original\n"
assert not (env["working"] / "scan.pdf").exists()
assert not (env["outgoing"] / "OCR_scan.pdf").exists()
def test_process_pdf_verapdf_fail_archive_name_collision(tmp_path: Path) -> None:
"""Auch im veraPDF-FAIL-Pfad greift der Timestamp-Kollisionsschutz."""
env = _prepare(tmp_path)
(env["archive"] / "scan.pdf").write_bytes(b"old")
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"]))
_run_with_vera_fail(env, out_cfg)
assert (env["archive"] / "scan.pdf").read_bytes() == b"old"
archived = list(env["archive"].glob("scan_*.pdf"))
assert len(archived) == 1
assert archived[0].read_bytes() == b"%PDF-1.4 original\n"
+66
View File
@@ -0,0 +1,66 @@
"""Tests für upload_folder() — Kopie per shutil.copyfile statt read_bytes()."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.config import FolderUpload
from pdf_ocr_hotfolder.uploaders import upload_folder
def test_upload_folder_copies_file(tmp_path: Path) -> None:
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4 inhalt\n")
target = tmp_path / "ziel"
assert upload_folder(src, FolderUpload(enabled=True, target=str(target)),
tmp_path / "out") is True
assert (target / "OCR_scan.pdf").read_bytes() == b"%PDF-1.4 inhalt\n"
# Quelle bleibt liegen (Kopie, kein Move)
assert src.exists()
def test_upload_folder_uses_copyfile_not_read_bytes(tmp_path: Path) -> None:
"""Große PDFs dürfen nicht komplett in den Speicher gelesen werden."""
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4\n")
target = tmp_path / "ziel"
with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile") as copyfile:
upload_folder(src, FolderUpload(enabled=True, target=str(target)),
tmp_path / "out")
copyfile.assert_called_once()
def test_upload_folder_skips_self_target(tmp_path: Path) -> None:
"""Ist das Ziel = outgoing, wird nicht auf sich selbst kopiert."""
out = tmp_path / "out"
out.mkdir()
src = out / "OCR_scan.pdf"
src.write_bytes(b"%PDF-1.4\n")
with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile") as copyfile:
assert upload_folder(src, FolderUpload(enabled=True, target=""), out) is True
copyfile.assert_not_called()
assert src.read_bytes() == b"%PDF-1.4\n"
def test_upload_folder_disabled_returns_true(tmp_path: Path) -> None:
src = tmp_path / "OCR_scan.pdf"
src.write_bytes(b"%PDF-1.4\n")
assert upload_folder(src, FolderUpload(enabled=False), tmp_path) is True
def test_upload_folder_reports_failure(tmp_path: Path) -> None:
"""OSError beim Kopieren → False (wird vom Service als Fehler gezählt)."""
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4\n")
with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile",
side_effect=OSError("disk full")):
assert upload_folder(src, FolderUpload(enabled=True,
target=str(tmp_path / "ziel")),
tmp_path / "out") is False