Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).

## [Unreleased]

### OCR completeness and safe output publication (2026-10-02)
- Reject missing rasterized pages, empty per-page OCR results and OCR results with more than one page per source image; preserve previous output on failure.
- Write OCR PDFs, merged PDFs and JSON manifests to private same-directory staging files before replacing their targets. Keep lazy PDF sources open through staging, then close them before publication. Clean failed disk fallbacks without masking the primary error.
- Prevent a merged output from replacing an input or its filesystem alias. Archive individual results by staging a complete copy before removing the original. Report archiving failures separately from an already saved collective PDF, including localized GUI warnings.
- Reopen written PDF stages and verify their page count before publication. Catch manifest-export failures in the GUI and retain the previous manifest. Add 19 behavior regressions; see `SAVE_SAFETY.md` for evidence and limits. Version 1.1.4 remains frozen.
### Bug-Sweep BS-11: EXIF-Metadaten-Erhalt, Merge-Härtung, Worker-Abbruchsicherheit & Pfad-Resilienz (2026-10-03)
- **EXIF-Metadaten & Bild-Transposition (`_load_source_images`)**:
- `_load_source_images` überträgt `info` und `_exif` (aus `im.getexif()`) nun explizit auf die zurückgegebenen Bildframes, da `Image.copy()` diese standardmäßig verwirft. Dadurch funktioniert `ImageOps.exif_transpose` in `normalize_image_for_ocr` nun auch verlässlich für smartphone- und scan-basierte Bilddateien (Orientierungs-Tags 3, 6, 8).
Expand Down
237 changes: 145 additions & 92 deletions PDFtoPDFocr_2.py

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions SAVE_SAFETY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Vollständige Seiten und sicheres Speichern

Stand: 2026-10-02. Version 1.1.4 bleibt unverändert.

PDFtoPDFocr veröffentlicht eine OCR-Ausgabe erst, wenn alle gerenderten
Quellseiten beziehungsweise Bildframes jeweils genau eine gültige OCR-PDF-Seite
ergeben haben. Bei PDFs muss die Anzahl gerenderter Seiten zur Quell-PDF passen.
Das fertig geschriebene OCR-/Merge-PDF wird vor der Veröffentlichung erneut
geöffnet und seine Seitenzahl geprüft.
Eine leere OCR-Antwort wird als Fehler behandelt; sie darf keine unvollständige
Ausgabe mit Erfolgsstatus erzeugen. OCR-Erkennungsgenauigkeit ist davon getrennt.

OCR-Ausgaben, Sammel-PDFs und Job-Manifeste werden vollständig in einem privaten
temporären Verzeichnis neben der Ausgabe vorbereitet. Die bisherigen Ausgabebytes
bleiben bei Schreib- oder Ersetzungsfehlern erhalten. Die temporären pikepdf-Quellen
und BytesIO-Puffer bleiben während des Speicherns offen und werden vor dem
Ersetzen geschlossen. Auch ein beschädigter OCR-Disk-Fallback bleibt innerhalb
des privaten Verzeichnisses und wird bereinigt.

Ein Bereinigungsfehler wird gesondert protokolliert. Er verdeckt keinen
Verarbeitungsfehler und macht einen bereits gespeicherten Export nicht rückgängig.
Ein Absturz oder Stromausfall garantiert keine Bereinigung. Es wird keine
Transaktion über die gesamte Batch-Verarbeitung behauptet.

## Sammel-PDF und anschließende Archivierung

Eine Sammel-PDF darf keine ihrer Eingaben ersetzen, auch nicht über einen
aufgelösten Pfad oder Hardlink. Nach erfolgreicher Veröffentlichung werden
Einzeldateien jeweils vollständig vorbereitet, ins Archiv übernommen und erst
dann am alten Ort entfernt. Bei einem Kopierfehler bleibt das Original erhalten;
bei einem Löschfehler bleiben Original und Archivkopie erhalten.

Die Archivierung mehrerer Einzeldateien ist keine gemeinsame Transaktion.
Bei einem späteren Archivierungsfehler kann die Sammel-PDF bereits gültig
gespeichert sein und ein Teil der Einzeldateien bereits im Archiv liegen. Die GUI
zeigt dies als Warnung zusätzlich zur gespeicherten Sammel-PDF an. API-Aufrufer
können `archive_warnings` übergeben; ohne diese Liste signalisiert
`MergeArchiveError.merged_path` das bereits veröffentlichte Ergebnis.

Manifestfehler werden im GUI-Slot abgefangen. Er zeigt einen Fehler und meldet
keinen Speicherefolg; das vorherige Manifest bleibt unverändert.

## Prüfung und Grenzen

`tests/test_save_safety.py` enthält 19 Verhaltenstests mit synthetischen PDFs,
kontrollierten OCR-Antworten sowie Schreib-, Veröffentlichungs-, Archivierungs-
und Bereinigungsfehlern. Die ursprünglichen sieben Gegenproben scheiterten am
unveränderten GitHub-Stand `a825ee1`. Die korrigierte Quellcode-Suite erreicht
163 bestandene Tests und eine dokumentierte Überspringung: Der lokale
Aufgabenabgleich benötigt die bewusst nicht versionierte `AUFGABEN.txt`.

Diese Tests belegen den geprüften Seiten-/Speichervertrag, keine allgemeine
OCR-Erkennungsqualität. Externe Pfadrennen, umfassender Stage-Austauschschutz,
globale Mehrdatei-Transaktionen, echte Geräte, Store-Zertifizierung und neue
EXE-Pakete sind nicht abgenommen. Benutzerdateien und persönliche Konfigurationen
wurden nicht als Testdaten verwendet.
4 changes: 2 additions & 2 deletions tests/test_bug_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def test_bs1_pikepdf_sources_collected():

def test_bs1_sources_closed_after_save():
"""Quell-PDFs duerfen erst NACH out_pdf.save() geschlossen werden."""
i_save = _SRC.find("out_pdf.save(dst_path)")
i_save = _SRC.find("out_pdf.save(staged_path)")
i_close_loop = _SRC.find("for _src_pdf, _tmp in page_sources")
assert 0 <= i_save < i_close_loop, (
"Quell-PDFs werden vor out_pdf.save() geschlossen -> Lazy-Copy-Korruption"
Expand All @@ -45,7 +45,7 @@ def test_bs2_ocr_error_uses_logging_not_print():

def test_bs3_merge_ocr_outputs_sources_closed_after_save():
"""Beim Mergen duerfen Quell-PDFs erst NACH merged.save() geschlossen werden."""
i_save = _SRC.find("merged.save(merged_path)")
i_save = _SRC.find("merged.save(staged)")
i_close_loop = _SRC.find("for src_pdf in opened_sources:")
assert 0 <= i_save < i_close_loop, (
"Quell-PDFs in merge_ocr_outputs werden vor merged.save() geschlossen"
Expand Down
7 changes: 4 additions & 3 deletions tests/test_bugsweep_exif_merge_worker_resilience_20261003.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,10 @@ def failing_open(src):

success = worker._ocr_pdf(str(dummy_pdf), "deu")
assert success is False
assert len(created_temp_files) == 1
# Ensure temporary file was cleanly unlinked and not leaked
assert not Path(created_temp_files[0]).exists()
# Fallback pages are staged inside the private output directory (see SAVE_SAFETY.md),
# so no system temp file may be created and nothing may be left beside the source.
assert all(not Path(name).exists() for name in created_temp_files)
assert sorted(item.name for item in tmp_path.iterdir()) == ["test.pdf"]


def test_bs11_open_file_path_and_folder_rejects_none_empty_and_dirs(tmp_path):
Expand Down
75 changes: 37 additions & 38 deletions tests/test_export_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,52 +189,51 @@ def test_ocr_worker_progress_uses_tr_for_localization():


def test_ocr_pdf_fallback_closes_src_pdf_before_unlink(tmp_path, monkeypatch):
"""Bug #3: src_pdf.close() muss vor os.unlink() im Fallback-Temp-Pfad kommen."""
"""Fallback handles must close before private PDF files are removed."""
import io
from pathlib import Path
from PIL import Image as PILImage
import pikepdf

_qapp()
worker = app.OCRWorker(pending_paths=[], lang="eng")

fake_image = PILImage.new("RGB", (10, 10))
monkeypatch.setattr("PDFtoPDFocr_2.convert_from_path", lambda *a, **kw: [fake_image])
monkeypatch.setattr(
"PDFtoPDFocr_2.pytesseract.image_to_pdf_or_hocr",
lambda *a, **kw: b"%PDF-fake",
)

events = []
mock_src_pdf = MagicMock()
mock_src_pdf.pages = []
mock_src_pdf.close = lambda: events.append("close")

open_calls = [0]

def patched_open(source, *a, **kw):
open_calls[0] += 1
if open_calls[0] == 1:
raise Exception("forced BytesIO failure")
return mock_src_pdf
image = PILImage.new("RGB", (10, 10), "white")
monkeypatch.setattr(worker, "_load_source_images", lambda _: [image])
buffer = io.BytesIO()
image.save(buffer, "PDF")
monkeypatch.setattr(app.pytesseract, "image_to_pdf_or_hocr", lambda *a, **kw: buffer.getvalue())
events, fallback_ids = [], []
original_open = pikepdf.Pdf.open
original_close = pikepdf.Pdf.close

def patched_open(source, *args, **kwargs):
if isinstance(source, io.BytesIO):
raise pikepdf.PdfError("synthetic BytesIO failure")
pdf = original_open(source, *args, **kwargs)
if Path(source).name.startswith("page-"):
fallback_ids.append(id(pdf))
return pdf

def patched_close(pdf):
events.append(("close", id(pdf)))
original_close(pdf)

original_unlink = app.os.unlink
def patched_unlink(path, *args, **kwargs):
events.append(("unlink", str(path)))
original_unlink(path, *args, **kwargs)

monkeypatch.setattr(pikepdf.Pdf, "open", patched_open)

_original_unlink = app.os.unlink

def patched_unlink(p):
events.append("unlink")
_original_unlink(p)

monkeypatch.setattr(pikepdf.Pdf, "close", patched_close)
monkeypatch.setattr(app.os, "unlink", patched_unlink)

src = tmp_path / "test.pdf"
src.write_bytes(b"%PDF-1.4\n")
worker._ocr_pdf(str(src), "eng")

assert "close" in events, "src_pdf.close() wurde im Fallback-Pfad nie aufgerufen"
assert "unlink" in events, "os.unlink() wurde nie aufgerufen — Temp-Datei wurde nicht gelöscht"
assert events.index("close") < events.index("unlink"), (
f"src_pdf.close() muss vor os.unlink() kommen; Reihenfolge war: {events}"
)
source = tmp_path / "source.pdf"
source.write_bytes(buffer.getvalue())
assert worker._ocr_pdf(str(source), "eng") is True
assert len(fallback_ids) == 1
close_index = events.index(("close", fallback_ids[0]))
unlink_index = next(i for i, event in enumerate(events) if event[0] == "unlink" and "page-0" in event[1])
assert close_index < unlink_index
assert not list(tmp_path.glob("pdftopdfocr-output-*"))


def test_ocr_pdf_returns_false_when_all_pages_yield_empty_bytes(tmp_path, monkeypatch):
Expand Down
Loading
Loading