diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e3e04..019d45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/PDFtoPDFocr_2.py b/PDFtoPDFocr_2.py index 89cdee1..ecc1db2 100644 --- a/PDFtoPDFocr_2.py +++ b/PDFtoPDFocr_2.py @@ -10,6 +10,7 @@ """ import glob +from contextlib import contextmanager import io import json import logging @@ -556,14 +557,44 @@ def build_job_export_payload( def write_job_export(target_path: str | Path, payload: dict) -> Path: """Writes the OCR job manifest as UTF-8 JSON without BOM.""" export_path = Path(target_path) - export_path.parent.mkdir(parents=True, exist_ok=True) - export_path.write_text( - json.dumps(payload, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) + serialized = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + with staged_output(export_path) as staged: + staged.write_text(serialized, encoding="utf-8") return export_path +@contextmanager +def staged_output(target: str | Path): + """Replace the destination only after its writer and file handles finish.""" + target = Path(target) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = tempfile.TemporaryDirectory(prefix="pdftopdfocr-output-", dir=target.parent) + try: + staged = Path(temporary.name) / ("output" + target.suffix) + yield staged + os.replace(staged, target) + finally: + try: + temporary.cleanup() + except OSError as error: + logging.warning("Temporary output cleanup failed: %s", error) + + +class MergeArchiveError(OSError): + """The collective PDF was saved, but subsequent archiving failed.""" + + def __init__(self, merged_path: Path, cause: OSError): + super().__init__(str(cause)) + self.merged_path = merged_path + + +def validate_pdf_output(path: Path, expected_pages: int) -> None: + """Read the written stage before publishing it.""" + with pikepdf.Pdf.open(path) as written: + if not expected_pages or len(written.pages) != expected_pages: + raise ValueError("Written PDF does not contain the expected pages") + + # ===== Merge/Stapeln (Welle-1 U2/U3/U4/U5) ===== def resolve_export_folder( @@ -591,6 +622,7 @@ def merge_ocr_outputs( merged_name: str, export_folder: str | Path, subfolder_name: str = MERGE_SUBFOLDER_NAME, + archive_warnings: list[str] | None = None, ) -> Path: """Merges already-OCRed single-file result PDFs into one collective PDF (U2). @@ -611,36 +643,43 @@ def merge_ocr_outputs( """ if len(output_paths) < 2: raise ValueError("merge_ocr_outputs benötigt mindestens 2 Dateien") + merged_name = Path(str(merged_name or "").strip().replace("\\", "/")).name + if merged_name in ("", ".", ".."): + merged_name = "merged.pdf" + if not merged_name.lower().endswith(".pdf"): + merged_name = f"{merged_name}.pdf" valid_paths = [str(p) for p in output_paths if p and Path(p).is_file()] if len(valid_paths) < 2: raise ValueError("merge_ocr_outputs benötigt mindestens 2 existierende PDF-Dateien") export_folder = Path(export_folder) + merged_path = export_folder / merged_name + for source in output_paths: + if merged_path.resolve() == Path(source).resolve() or ( + merged_path.exists() and os.path.samefile(merged_path, source) + ): + raise ValueError("Merge-Ausgabe darf keine Quelldatei ersetzen") export_folder.mkdir(parents=True, exist_ok=True) subfolder = export_folder / subfolder_name subfolder.mkdir(parents=True, exist_ok=True) - clean_merged_name = Path(str(merged_name or "").strip()).name or "merged.pdf" - if not clean_merged_name.lower().endswith(".pdf"): - clean_merged_name = f"{clean_merged_name}.pdf" - - merged = pikepdf.Pdf.new() - opened_sources: list[pikepdf.Pdf] = [] - try: - for p in valid_paths: - src_pdf = pikepdf.Pdf.open(p) - opened_sources.append(src_pdf) - merged.pages.extend(src_pdf.pages) - merged_path = export_folder / clean_merged_name - merged.save(merged_path) - finally: - for src_pdf in opened_sources: - try: + with staged_output(merged_path) as staged: + merged = pikepdf.Pdf.new() + opened_sources: list[pikepdf.Pdf] = [] + try: + for p in valid_paths: + src_pdf = pikepdf.Pdf.open(p) + opened_sources.append(src_pdf) + if not src_pdf.pages: + raise ValueError("Merge-Quelle enthält keine Seiten") + merged.pages.extend(src_pdf.pages) + merged.save(staged) + validate_pdf_output(staged, len(merged.pages)) + finally: + for src_pdf in opened_sources: src_pdf.close() - except Exception: - pass - merged.close() + merged.close() # Einzelseiten erst NACH dem Speichern der Sammel-PDF verschieben, damit ein # Fehlschlag beim Merge keine Dateien verwaist zurücklässt. @@ -663,9 +702,14 @@ def merge_ocr_outputs( pass dest = subfolder / f"{src_path.stem}_{uuid.uuid4().hex[:8]}{src_path.suffix}" try: - shutil.move(str(src_path), str(dest)) - except OSError as e: - logging.warning("Quelldatei konnte nicht nach %s verschoben werden: %s", dest, e) + with staged_output(dest) as staged: + shutil.copyfile(src_path, staged) + src_path.unlink() + except OSError as error: + logging.warning("Merged PDF saved, but archiving failed for %s: %s", src_path, error) + if archive_warnings is None: + raise MergeArchiveError(merged_path, error) from error + archive_warnings.append(f"{src_path.name}: {error}") return merged_path @@ -834,7 +878,12 @@ def _load_source_images(self, src_path: str) -> List[Image.Image]: ext = os.path.splitext(src_path)[1].lower() if ext not in IMAGE_EXTS: poppler_path = self.poppler_path or None - return convert_from_path(src_path, dpi=300, poppler_path=poppler_path) + with pikepdf.Pdf.open(src_path) as source: + expected_pages = len(source.pages) + images = convert_from_path(src_path, dpi=300, poppler_path=poppler_path) + if not expected_pages or len(images) != expected_pages: + raise ValueError("PDF rasterization did not produce all source pages") + return images images: List[Image.Image] = [] with Image.open(src_path) as im: @@ -860,66 +909,50 @@ def _load_source_images(self, src_path: str) -> List[Image.Image]: return images def _ocr_pdf(self, src_path: str, lang: str) -> bool: - """Führt OCR auf einer PDF- oder Bilddatei aus (läuft im Worker-Thread).""" + """Publish an OCR PDF only when every source page produced one PDF page.""" try: - images: List[Image.Image] = self._load_source_images(src_path) - - out_pdf = pikepdf.Pdf.new() - # FIX: pikepdf kopiert Seiten LAZY -> die Quell-PDFs (und temp-Dateien) - # muessen bis NACH out_pdf.save() geoeffnet bleiben. Vorher wurde src_pdf - # im Loop VOR dem Speichern geschlossen (und tmp geloescht) -> korrupte/ - # fehlende OCR-Seiten moeglich. Daher sammeln, erst im finally schliessen. - page_sources = [] # (pikepdf.Pdf, tmp_path_or_None) - try: - for img in images: - img = normalize_image_for_ocr(img) - pdf_bytes = pytesseract.image_to_pdf_or_hocr(img, lang=lang, extension='pdf') - if not pdf_bytes: - continue - try: - src_pdf = pikepdf.Pdf.open(io.BytesIO(pdf_bytes)) - out_pdf.pages.extend(src_pdf.pages) - page_sources.append((src_pdf, None)) - except Exception as e: - logging.warning(f"PDF operation failed: {e}") - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") + images = self._load_source_images(src_path) + if not images: + raise ValueError("OCR produced no source images") + dst_path = os.path.splitext(src_path)[0] + "_ocred.pdf" + with staged_output(dst_path) as staged_path: + out_pdf = pikepdf.Pdf.new() + page_sources = [] + page_buffers = [] + try: + for img in images: + img = normalize_image_for_ocr(img) + pdf_bytes = pytesseract.image_to_pdf_or_hocr(img, lang=lang, extension='pdf') + if not pdf_bytes: + raise ValueError("OCR returned an empty page") + buffer = io.BytesIO(pdf_bytes) + page_buffers.append(buffer) try: - tmp.write(pdf_bytes) - tmp.flush() - tmp.close() - src_pdf = pikepdf.Pdf.open(tmp.name) - out_pdf.pages.extend(src_pdf.pages) - page_sources.append((src_pdf, tmp.name)) - except Exception: - try: - os.unlink(tmp.name) - except OSError: - pass - raise - - if len(out_pdf.pages) == 0: - raise ValueError("OCR produced no pages — all pages yielded empty PDF bytes") - dst_path = os.path.splitext(src_path)[0] + "_ocred.pdf" - out_pdf.save(dst_path) - finally: - # Quell-PDFs + temp-Dateien erst NACH save() schliessen/aufraeumen. - for _src_pdf, _tmp in page_sources: - try: + src_pdf = pikepdf.Pdf.open(buffer) + temporary_path = None + except pikepdf.PdfError: + # Keep the fallback inside the private output directory. + temporary_path = staged_path.parent / f"page-{len(page_sources)}.pdf" + temporary_path.write_bytes(pdf_bytes) + src_pdf = pikepdf.Pdf.open(temporary_path) + page_sources.append((src_pdf, temporary_path)) + if len(src_pdf.pages) != 1: + raise ValueError("OCR must produce exactly one PDF page per source image") + out_pdf.pages.extend(src_pdf.pages) + if len(out_pdf.pages) != len(images): + raise ValueError("OCR output page count differs from the source") + # Lazy page sources and buffers remain open through the save. + out_pdf.save(staged_path) + validate_pdf_output(staged_path, len(images)) + finally: + for _src_pdf, _tmp in page_sources: _src_pdf.close() - except Exception: - pass - if _tmp: - try: - os.unlink(_tmp) - except OSError: - pass - out_pdf.close() + for buffer in page_buffers: + buffer.close() + out_pdf.close() return True - except Exception as e: - # logging statt print: im windowed-PyInstaller ist sys.stdout None -> - # print() wuerde den Worker-Thread crashen (finished_all nie emittiert, - # GUI haengt mit dauerhaft deaktiviertem Start-Button). - logging.error("OCR-Fehler bei %s: %s", src_path, e) + except Exception as error: + logging.error("OCR-Fehler bei %s: %s", src_path, error) return False @@ -1454,8 +1487,9 @@ def merge_selected( target_path = chosen_path target = Path(target_path) + archive_warnings = [] try: - merged_path = merge_ocr_outputs(output_paths, target.name, target.parent) + merged_path = merge_ocr_outputs(output_paths, target.name, target.parent, archive_warnings=archive_warnings) for it in done_items: resolved = resolve_ocr_output_path(it.data(Qt.UserRole), target.parent) if not resolved and self.export_folder: @@ -1468,8 +1502,16 @@ def merge_selected( self.status_label.setText(tr("status_merge_saved", filename=merged_path.name)) self.status_label.setStyleSheet("color: #0b6e4f; font-weight: bold;") + if archive_warnings: + self._show_merge_archive_warning(archive_warnings) return merged_path + def _show_merge_archive_warning(self, warnings): + message = tr("warning_merge_archive", error="\n".join(warnings)) + self.status_label.setText(self.status_label.text() + " " + message) + self.status_label.setStyleSheet("color: #8a4b00; font-weight: bold;") + QMessageBox.warning(self, tr("error_title"), message) + def _register_batch_folder(self, batch_id: str, folder_path: str): """Remembers which folder a folder-drop batch originated from (U5).""" self._batch_folders[batch_id] = folder_path @@ -1518,8 +1560,9 @@ def _auto_merge_completed_batches(self): export_folder = configured if configured and configured.is_dir() else Path(folder) base_folder_name = os.path.basename(os.path.normpath(folder)) or "batch" merged_name = f"{base_folder_name}_merged.pdf" + archive_warnings = [] try: - merge_ocr_outputs(outputs, merged_name, export_folder) + merge_ocr_outputs(outputs, merged_name, export_folder, archive_warnings=archive_warnings) self._merged_batches.add(batch_id) for it in done_items: resolved = resolve_ocr_output_path(it.data(Qt.UserRole), export_folder) @@ -1527,6 +1570,8 @@ def _auto_merge_completed_batches(self): it.setData(Qt.UserRole + 4, str(resolved)) self.status_label.setText(tr("status_merge_saved", filename=merged_name)) self.status_label.setStyleSheet("color: #0b6e4f; font-weight: bold;") + if archive_warnings: + self._show_merge_archive_warning(archive_warnings) except Exception as e: logging.warning(f"Auto-Merge fuer Batch {batch_id} fehlgeschlagen: {e}") @@ -1652,12 +1697,20 @@ def export_job_manifest( return None target_path = chosen_path - payload = build_job_export_payload( - entries, - self.lang_combo.currentText(), - export_folder=self.export_folder, - ) - written_path = write_job_export(target_path, payload) + try: + payload = build_job_export_payload( + entries, + self.lang_combo.currentText(), + export_folder=self.export_folder, + ) + written_path = write_job_export(target_path, payload) + except Exception as error: + message = tr("error_export_failed", error=error) + self.status_label.setText(message) + self.status_label.setStyleSheet("color: #b00020; font-weight: bold;") + if show_feedback: + QMessageBox.critical(self, tr("error_title"), message) + return None self.status_label.setText(tr("status_export_saved", filename=written_path.name)) self.status_label.setStyleSheet("color: #0b6e4f; font-weight: bold;") if show_feedback: diff --git a/SAVE_SAFETY.md b/SAVE_SAFETY.md new file mode 100644 index 0000000..9ae1a10 --- /dev/null +++ b/SAVE_SAFETY.md @@ -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. diff --git a/tests/test_bug_regressions.py b/tests/test_bug_regressions.py index 57e4810..f61efcb 100644 --- a/tests/test_bug_regressions.py +++ b/tests/test_bug_regressions.py @@ -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" @@ -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" diff --git a/tests/test_bugsweep_exif_merge_worker_resilience_20261003.py b/tests/test_bugsweep_exif_merge_worker_resilience_20261003.py index d3c5d42..ad81c1c 100644 --- a/tests/test_bugsweep_exif_merge_worker_resilience_20261003.py +++ b/tests/test_bugsweep_exif_merge_worker_resilience_20261003.py @@ -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): diff --git a/tests/test_export_format.py b/tests/test_export_format.py index e068261..eb7950d 100644 --- a/tests/test_export_format.py +++ b/tests/test_export_format.py @@ -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): diff --git a/tests/test_save_safety.py b/tests/test_save_safety.py new file mode 100644 index 0000000..cfb389f --- /dev/null +++ b/tests/test_save_safety.py @@ -0,0 +1,246 @@ +import io +from pathlib import Path + +import pikepdf +from PIL import Image +import pytest +import PDFtoPDFocr_2 as app + + +def pdf_bytes(pages=1): + pdf = pikepdf.Pdf.new() + for _ in range(pages): + pdf.add_blank_page(page_size=(100,100)) + buffer = io.BytesIO() + pdf.save(buffer) + pdf.close() + return buffer.getvalue() + + +def synthetic_pdf(path, pages=1): + path.write_bytes(pdf_bytes(pages)) + return path + + +def synthetic_worker(monkeypatch, images=2): + worker = app.OCRWorker([], 'eng') + monkeypatch.setattr(worker, '_load_source_images', lambda _: [Image.new('RGB',(10,10),'white') for _ in range(images)]) + return worker + + +def test_empty_ocr_page_does_not_publish_partial_success(tmp_path, monkeypatch): + source = synthetic_pdf(tmp_path/'scan.pdf',2) + output = tmp_path/'scan_ocred.pdf' + output.write_bytes(b'previous') + worker = synthetic_worker(monkeypatch) + results = iter([pdf_bytes(),b'']) + monkeypatch.setattr(app.pytesseract,'image_to_pdf_or_hocr',lambda *a,**kw:next(results)) + assert worker._ocr_pdf(str(source),'eng') is False + assert output.read_bytes() == b'previous' + + +def test_incomplete_rasterization_preserves_output(tmp_path, monkeypatch): + source = synthetic_pdf(tmp_path/'scan.pdf',2) + output = tmp_path/'scan_ocred.pdf' + output.write_bytes(b'previous') + monkeypatch.setattr(app,'convert_from_path',lambda *a,**kw:[Image.new('RGB',(10,10),'white')]) + monkeypatch.setattr(app.pytesseract,'image_to_pdf_or_hocr',lambda *a,**kw:pdf_bytes()) + assert app.OCRWorker([], 'eng')._ocr_pdf(str(source),'eng') is False + assert output.read_bytes() == b'previous' + + +@pytest.mark.parametrize('operation',['ocr','merge']) +def test_partial_pdf_write_preserves_previous_output(tmp_path, monkeypatch, operation): + source = synthetic_pdf(tmp_path/'scan.pdf') + second = synthetic_pdf(tmp_path/'second.pdf') + target = tmp_path/('scan_ocred.pdf' if operation=='ocr' else 'merged.pdf') + target.write_bytes(b'previous') + original = {p:p.read_bytes() for p in (source,second)} + worker = synthetic_worker(monkeypatch,1) + page = pdf_bytes() + monkeypatch.setattr(app.pytesseract,'image_to_pdf_or_hocr',lambda *a,**kw:page) + def partial_save(self,destination,*a,**kw): + Path(destination).write_bytes(b'partial') + raise OSError('synthetic partial save') + monkeypatch.setattr(app.pikepdf.Pdf,'save',partial_save) + if operation=='ocr': + assert worker._ocr_pdf(str(source),'eng') is False + else: + with pytest.raises(OSError,match='partial save'): + app.merge_ocr_outputs([str(source),str(second)],target.name,tmp_path) + assert target.read_bytes() == b'previous' + assert all(p.read_bytes()==old for p,old in original.items()) + + +def test_partial_manifest_write_preserves_previous_output(tmp_path,monkeypatch): + target = tmp_path/'job.json' + target.write_bytes(b'previous') + def partial_write(self,*a,**kw): + self.write_bytes(b'partial') + raise OSError('synthetic manifest error') + monkeypatch.setattr(Path,'write_text',partial_write) + with pytest.raises(OSError): + app.write_job_export(target,{'synthetic':'äöü'}) + assert target.read_bytes() == b'previous' + + +def test_invalid_ocr_pdf_cleans_fallback_temporary_file(tmp_path,monkeypatch): + source = synthetic_pdf(tmp_path/'source.pdf') + before = set(tmp_path.iterdir()) + worker = synthetic_worker(monkeypatch,1) + monkeypatch.setattr(app.pytesseract,'image_to_pdf_or_hocr',lambda *a,**kw:b'invalid PDF bytes') + monkeypatch.setattr(app.tempfile,'gettempdir',lambda:str(tmp_path)) + assert worker._ocr_pdf(str(source),'eng') is False + assert set(tmp_path.iterdir()) == before + + +def test_one_ocr_frame_cannot_add_multiple_pages(tmp_path,monkeypatch): + source = synthetic_pdf(tmp_path/'scan.pdf') + target = tmp_path/'scan_ocred.pdf' + target.write_bytes(b'previous') + worker = synthetic_worker(monkeypatch,1) + monkeypatch.setattr(app.pytesseract,'image_to_pdf_or_hocr',lambda *a,**kw:pdf_bytes(2)) + assert worker._ocr_pdf(str(source),'eng') is False + assert target.read_bytes() == b'previous' + + +@pytest.mark.parametrize('operation', ['ocr','manifest','merge']) +def test_final_replace_failure_preserves_outputs(tmp_path,monkeypatch,operation): + source = synthetic_pdf(tmp_path/'scan.pdf') + second = synthetic_pdf(tmp_path/'second.pdf') + name = {'ocr':'scan_ocred.pdf','manifest':'job.json','merge':'merged.pdf'}[operation] + target = tmp_path/name + target.write_bytes(b'previous') + before = {p:p.read_bytes() for p in tmp_path.iterdir()} + worker = synthetic_worker(monkeypatch,1) + page = pdf_bytes() + monkeypatch.setattr(app.pytesseract,'image_to_pdf_or_hocr',lambda *a,**kw:page) + def denied(*args): + raise PermissionError('synthetic sharing violation') + monkeypatch.setattr(app.os,'replace',denied) + if operation == 'ocr': + assert worker._ocr_pdf(str(source),'eng') is False + else: + with pytest.raises(PermissionError): + if operation == 'manifest': + app.write_job_export(target,{'synthetic':True}) + else: + app.merge_ocr_outputs([str(source),str(second)],target.name,tmp_path) + assert {p for p in tmp_path.iterdir() if p.is_file()} == set(before) + assert not list(tmp_path.glob('pdftopdfocr-output-*')) + assert all(p.read_bytes() == old for p,old in before.items()) + + +def test_archive_partial_copy_preserves_individual_pdf(tmp_path,monkeypatch): + source = synthetic_pdf(tmp_path/'a.pdf') + second = synthetic_pdf(tmp_path/'b.pdf') + before = source.read_bytes() + def partial_copy(source,destination): + Path(destination).write_bytes(b'partial') + raise OSError('synthetic archive copy failure') + monkeypatch.setattr(app.shutil,'copyfile',partial_copy) + with pytest.raises(app.MergeArchiveError) as error: + app.merge_ocr_outputs([str(source),str(second)],'merged.pdf',tmp_path) + assert error.value.merged_path == tmp_path/'merged.pdf' + with pikepdf.open(error.value.merged_path) as pdf: + assert len(pdf.pages) == 2 + assert source.read_bytes() == before + assert second.exists() + assert not list((tmp_path/app.MERGE_SUBFOLDER_NAME).iterdir()) + + +def test_archive_unlink_failure_reports_saved_pdf_and_keeps_source(tmp_path,monkeypatch): + source = synthetic_pdf(tmp_path/'a.pdf') + second = synthetic_pdf(tmp_path/'b.pdf') + original_unlink = Path.unlink + def denied(path,*args,**kwargs): + if path == source: + raise PermissionError('source held open') + return original_unlink(path,*args,**kwargs) + monkeypatch.setattr(Path,'unlink',denied) + warnings = [] + merged = app.merge_ocr_outputs([str(source),str(second)],'merged.pdf',tmp_path,archive_warnings=warnings) + assert merged.exists() and source.exists() + assert warnings and 'source held open' in warnings[0] + assert (tmp_path/app.MERGE_SUBFOLDER_NAME/source.name).read_bytes() == source.read_bytes() + + +@pytest.mark.parametrize('alias',['direct','hardlink']) +def test_merge_rejects_input_alias_before_changes(tmp_path,monkeypatch,alias): + source = synthetic_pdf(tmp_path/'a.pdf') + second = synthetic_pdf(tmp_path/'b.pdf') + target = source if alias == 'direct' else tmp_path/'alias.pdf' + if alias == 'hardlink': + try: + target.hardlink_to(source) + except OSError as error: + pytest.skip(f'Hardlinks unavailable: {error}') + before = {p:p.read_bytes() for p in tmp_path.iterdir()} + with pytest.raises(ValueError,match='Quelldatei'): + app.merge_ocr_outputs([str(source),str(second)],target.name,tmp_path) + assert set(tmp_path.iterdir()) == set(before) + assert all(p.read_bytes() == old for p,old in before.items()) + + +@pytest.mark.parametrize('processing_fails',[False,True]) +def test_cleanup_does_not_change_commit_status(tmp_path,monkeypatch,processing_fails): + target = tmp_path/'job.json' + target.write_bytes(b'previous') + original_cleanup = app.tempfile.TemporaryDirectory.cleanup + def cleanup(directory): + original_cleanup(directory) + raise PermissionError('synthetic cleanup denied') + monkeypatch.setattr(app.tempfile.TemporaryDirectory,'cleanup',cleanup) + if processing_fails: + def denied(*args): + raise PermissionError('primary publish failure') + monkeypatch.setattr(app.os,'replace',denied) + with pytest.raises(PermissionError,match='primary publish failure'): + app.write_job_export(target,{'synthetic':True}) + assert target.read_bytes() == b'previous' + else: + assert app.write_job_export(target,{'synthetic':'äöü'}) == target + assert 'äöü' in target.read_text(encoding='utf-8') + + +def test_gui_manifest_failure_has_no_success_feedback(tmp_path,monkeypatch): + from PySide6.QtWidgets import QApplication + qt = QApplication.instance() or QApplication([]) + gui = app.OCRConverterGUI() + target = tmp_path/'job.json' + target.write_bytes(b'previous') + def denied(*args): + raise PermissionError('synthetic destination locked') + feedback = [] + monkeypatch.setattr(app.os,'replace',denied) + monkeypatch.setattr(app.QMessageBox,'information',lambda *args:feedback.append('success')) + monkeypatch.setattr(app.QMessageBox,'critical',lambda *args:feedback.append('error')) + try: + assert gui.export_job_manifest(target_path=target) is None + assert feedback == ['error'] + assert 'synthetic destination locked' in gui.status_label.text() + assert target.read_bytes() == b'previous' + finally: + gui.close() + qt.processEvents() + + +@pytest.mark.parametrize('operation',['ocr','merge']) +def test_silently_corrupted_written_pdf_is_not_published(tmp_path,monkeypatch,operation): + source = synthetic_pdf(tmp_path/'scan.pdf') + second = synthetic_pdf(tmp_path/'second.pdf') + target = tmp_path/('scan_ocred.pdf' if operation == 'ocr' else 'merged.pdf') + target.write_bytes(b'previous') + worker = synthetic_worker(monkeypatch,1) + page = pdf_bytes() + monkeypatch.setattr(app.pytesseract,'image_to_pdf_or_hocr',lambda *a,**kw:page) + def invalid_save(pdf,path,*args,**kwargs): + Path(path).write_bytes(b'not a valid PDF') + monkeypatch.setattr(app.pikepdf.Pdf,'save',invalid_save) + if operation == 'ocr': + assert worker._ocr_pdf(str(source),'eng') is False + else: + with pytest.raises(pikepdf.PdfError): + app.merge_ocr_outputs([str(source),str(second)],'merged.pdf',tmp_path) + assert target.read_bytes() == b'previous' + assert source.exists() and second.exists() diff --git a/translations.json b/translations.json index ac721eb..2c0407a 100644 --- a/translations.json +++ b/translations.json @@ -1,4 +1,20 @@ { + "error_export_failed": { + "de": "Job-Manifest konnte nicht gespeichert werden: {error}", + "en": "Could not save the job manifest: {error}", + "es": "No se pudo guardar el manifiesto del trabajo: {error}", + "zh": "无法保存任务清单:{error}", + "ja": "ジョブマニフェストを保存できませんでした:{error}", + "ru": "Не удалось сохранить манифест задания: {error}" + }, + "warning_merge_archive": { + "de": "Sammel-PDF gespeichert. Einige Einzeldateien konnten nicht archiviert werden und bleiben an ihrem bisherigen Ort: {error}", + "en": "Merged PDF saved. Some individual files could not be archived and remain in their previous location: {error}", + "es": "PDF combinado guardado. Algunos archivos individuales no se pudieron archivar y permanecen en su ubicación anterior: {error}", + "zh": "合并的 PDF 已保存。部分单独文件无法归档,仍保留在原来的位置:{error}", + "ja": "結合したPDFを保存しました。一部の個別ファイルはアーカイブできず、元の場所に残っています:{error}", + "ru": "Объединённый PDF сохранён. Некоторые отдельные файлы не удалось архивировать; они остаются на прежнем месте: {error}" + }, "window_title": { "de": "PDF OCR Werkzeug", "en": "PDF OCR Tool", @@ -511,4 +527,4 @@ "ja": "エクスポートフォルダー表示", "ru": "Отображение папки экспорта" } -} \ No newline at end of file +}