From dfdc9abe131469da8504cabf000a59bab78a38f1 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Fri, 2 Oct 2026 02:09:56 +0200 Subject: [PATCH 1/3] Preserve complete OCR results and publish output files safely Signed-off-by: Lukas Geiger --- CHANGELOG.md | 6 + PDFtoPDFocr_2.py | 213 ++++++++++++++++++++------------ SAVE_SAFETY.md | 54 ++++++++ tests/test_bug_regressions.py | 4 +- tests/test_export_format.py | 73 ++++++----- tests/test_save_safety.py | 225 ++++++++++++++++++++++++++++++++++ translations.json | 18 ++- 7 files changed, 473 insertions(+), 120 deletions(-) create mode 100644 SAVE_SAFETY.md create mode 100644 tests/test_save_safety.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a03a7..baef538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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. +- Catch manifest-export failures in the GUI and retain the previous manifest. Add 17 behavior regressions; see `SAVE_SAFETY.md` for evidence and limits. Version 1.1.4 remains frozen. + ### Pfad B: Discoverability, Visuelle Vier-Sichten-Architektur, Level 1 SBOM Re-Audit & Vertragstests (2026-10-01) - **Visuelle Vier-Sichten-Architektur (ASCII Four-View Architectural Topology)**: - Vollständige zweisprachige ASCII Four-View Architectural Topology in `README.md` und `README_de.md` in Section 2 direkt unterhalb des Mermaid-Diagramms implementiert (`[VIEW 1: INGESTION, DRAG-AND-DROP QUEUE & ACCESSIBILITY]` bis `[VIEW 4: AIR-GAP DEFENSE PERIMETER, ZERO-EGRESS & GOVERNANCE BOUNDARY]` sowie `[SICHT 1]` bis `[SICHT 4]`). diff --git a/PDFtoPDFocr_2.py b/PDFtoPDFocr_2.py index daf50b4..3d6e085 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 @@ -521,14 +522,37 @@ 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 + + # ===== Merge/Stapeln (Welle-1 U2/U3/U4/U5) ===== def resolve_export_folder( @@ -556,6 +580,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). @@ -576,28 +601,35 @@ def merge_ocr_outputs( """ if len(output_paths) < 2: raise ValueError("merge_ocr_outputs benötigt mindestens 2 Dateien") + if not merged_name or merged_name in (".", "..") or "/" in merged_name or "\\" in merged_name: + raise ValueError("Merge-Ausgabe benötigt einen einfachen Dateinamen") 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) - merged = pikepdf.Pdf.new() - opened_sources: list[pikepdf.Pdf] = [] - try: - for p in output_paths: - src_pdf = pikepdf.Pdf.open(p) - opened_sources.append(src_pdf) - merged.pages.extend(src_pdf.pages) - merged_path = export_folder / 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 output_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) + 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. @@ -619,7 +651,15 @@ def merge_ocr_outputs( except OSError: pass dest = subfolder / f"{src_path.stem}_{uuid.uuid4().hex[:8]}{src_path.suffix}" - shutil.move(str(src_path), str(dest)) + try: + 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 @@ -755,7 +795,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: @@ -766,59 +811,49 @@ 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") - tmp.write(pdf_bytes) - tmp.flush() - tmp.close() - src_pdf = pikepdf.Pdf.open(tmp.name) + 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: + 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) - page_sources.append((src_pdf, tmp.name)) - - 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: + 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) + 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 @@ -1347,8 +1382,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: @@ -1361,8 +1397,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 @@ -1409,8 +1453,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) @@ -1418,6 +1463,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}") @@ -1538,12 +1585,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..5607657 --- /dev/null +++ b/SAVE_SAFETY.md @@ -0,0 +1,54 @@ +# 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. +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 17 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 +161 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_export_format.py b/tests/test_export_format.py index e068261..e6ef998 100644 --- a/tests/test_export_format.py +++ b/tests/test_export_format.py @@ -189,52 +189,49 @@ 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 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) + 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..415a900 --- /dev/null +++ b/tests/test_save_safety.py @@ -0,0 +1,225 @@ +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() 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 +} From 74bde996fc25b4c53b9742b8a30b4401a8316b04 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Fri, 2 Oct 2026 02:14:42 +0200 Subject: [PATCH 2/3] Validate written PDF stages before publication Signed-off-by: Lukas Geiger --- CHANGELOG.md | 2 +- PDFtoPDFocr_2.py | 9 +++++++++ SAVE_SAFETY.md | 6 ++++-- tests/test_export_format.py | 4 +++- tests/test_save_safety.py | 21 +++++++++++++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baef538..4c81cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - 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. -- Catch manifest-export failures in the GUI and retain the previous manifest. Add 17 behavior regressions; see `SAVE_SAFETY.md` for evidence and limits. Version 1.1.4 remains frozen. +- 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. ### Pfad B: Discoverability, Visuelle Vier-Sichten-Architektur, Level 1 SBOM Re-Audit & Vertragstests (2026-10-01) - **Visuelle Vier-Sichten-Architektur (ASCII Four-View Architectural Topology)**: diff --git a/PDFtoPDFocr_2.py b/PDFtoPDFocr_2.py index 3d6e085..2863ede 100644 --- a/PDFtoPDFocr_2.py +++ b/PDFtoPDFocr_2.py @@ -553,6 +553,13 @@ def __init__(self, merged_path: Path, cause: OSError): 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( @@ -626,6 +633,7 @@ def merge_ocr_outputs( 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() @@ -845,6 +853,7 @@ def _ocr_pdf(self, src_path: str, lang: str) -> bool: 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() diff --git a/SAVE_SAFETY.md b/SAVE_SAFETY.md index 5607657..9ae1a10 100644 --- a/SAVE_SAFETY.md +++ b/SAVE_SAFETY.md @@ -5,6 +5,8 @@ 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. @@ -40,11 +42,11 @@ keinen Speicherefolg; das vorherige Manifest bleibt unverändert. ## Prüfung und Grenzen -`tests/test_save_safety.py` enthält 17 Verhaltenstests mit synthetischen PDFs, +`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 -161 bestandene Tests und eine dokumentierte Überspringung: Der lokale +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 diff --git a/tests/test_export_format.py b/tests/test_export_format.py index e6ef998..eb7950d 100644 --- a/tests/test_export_format.py +++ b/tests/test_export_format.py @@ -191,6 +191,7 @@ def test_ocr_worker_progress_uses_tr_for_localization(): def test_ocr_pdf_fallback_closes_src_pdf_before_unlink(tmp_path, monkeypatch): """Fallback handles must close before private PDF files are removed.""" import io + from pathlib import Path from PIL import Image as PILImage import pikepdf @@ -209,7 +210,8 @@ def patched_open(source, *args, **kwargs): if isinstance(source, io.BytesIO): raise pikepdf.PdfError("synthetic BytesIO failure") pdf = original_open(source, *args, **kwargs) - fallback_ids.append(id(pdf)) + if Path(source).name.startswith("page-"): + fallback_ids.append(id(pdf)) return pdf def patched_close(pdf): diff --git a/tests/test_save_safety.py b/tests/test_save_safety.py index 415a900..cfb389f 100644 --- a/tests/test_save_safety.py +++ b/tests/test_save_safety.py @@ -223,3 +223,24 @@ def denied(*args): 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() From 7d214274689506da099b9c9f9f16ce41b1a0b45a Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Fri, 2 Oct 2026 02:28:21 +0200 Subject: [PATCH 3/3] Fix first-interaction action input names Signed-off-by: Lukas Geiger --- .github/workflows/welcome.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml index c092e51..cb61eea 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome.yml @@ -21,6 +21,6 @@ jobs: steps: - uses: actions/first-interaction@v3 with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - issue-message: 'Thank you for opening an issue on PDFtoPDFocr! We appreciate your feedback and will review it shortly.' - pr-message: 'Thank you for your pull request! The team will inspect and review your contribution.' + repo_token: ${{ secrets.GITHUB_TOKEN }} + issue_message: 'Thank you for opening an issue on PDFtoPDFocr! We appreciate your feedback and will review it shortly.' + pr_message: 'Thank you for your pull request! The team will inspect and review your contribution.'