diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 887045e..944f8cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,8 +83,12 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.artifact }} + # Intentionally do NOT upload dist/PDFApps.exe (the raw + # PyInstaller binary). Users install via PDFAppsSetup.exe + # (NSIS installer) or the .msix package; shipping the raw + # exe just publishes the runner's internal build paths + # embedded in PyInstaller debug strings. path: | - dist/PDFApps.exe dist/PDFAppsSetup.exe dist/PDFAppsUninstall.exe dist/PDFApps-*.msix @@ -298,7 +302,6 @@ jobs: echo "" >> release_notes.md echo "## Checksums (SHA256)" >> release_notes.md for f in artifacts/PDFApps-Windows/PDFAppsSetup.exe \ - artifacts/PDFApps-Windows/PDFApps.exe \ artifacts/PDFApps-Windows/PDFApps-*.msix \ artifacts/PDFApps-Linux/PDFApps-Linux.tar.gz \ artifacts/PDFApps-macOS/PDFApps-macOS.dmg \ @@ -321,7 +324,6 @@ jobs: body_path: release_notes.md files: | artifacts/PDFApps-Windows/PDFAppsSetup.exe - artifacts/PDFApps-Windows/PDFApps.exe artifacts/PDFApps-Windows/PDFApps-*.msix artifacts/PDFApps-Linux/PDFApps-Linux.tar.gz artifacts/PDFApps-macOS/PDFApps-macOS.dmg diff --git a/app/base.py b/app/base.py index eab7241..81e501b 100644 --- a/app/base.py +++ b/app/base.py @@ -247,13 +247,13 @@ def _show_toast(self, message: str, file_path: str = "", toast = QWidget(); toast.setObjectName("toast") toast.setStyleSheet( - f"#toast {{ background: #065F46; border: 1px solid #10B981; " - f"border-radius: 8px; padding: 8px 12px; }}" - f"#toast QLabel {{ color: white; font-size: 10pt; background: transparent; }}" - f"#toast QPushButton {{ color: #A7F3D0; border: none; background: transparent; " - f"font-size: 10pt; text-decoration: underline; padding: 0 4px; }}" - f"#toast QPushButton:hover {{ color: white; }}" - f"#toast QPushButton#toast_save {{ color: white; font-weight: 600; }}") + "#toast { background: #065F46; border: 1px solid #10B981; " + "border-radius: 8px; padding: 8px 12px; }" + "#toast QLabel { color: white; font-size: 10pt; background: transparent; }" + "#toast QPushButton { color: #A7F3D0; border: none; background: transparent; " + "font-size: 10pt; text-decoration: underline; padding: 0 4px; }" + "#toast QPushButton:hover { color: white; }" + "#toast QPushButton#toast_save { color: white; font-weight: 600; }") h = QHBoxLayout(toast); h.setContentsMargins(8, 4, 8, 4); h.setSpacing(8) h.addWidget(QLabel(f"✔ {message}"), 1) if with_save: diff --git a/app/editor/canvas.py b/app/editor/canvas.py index e513198..1afada3 100644 --- a/app/editor/canvas.py +++ b/app/editor/canvas.py @@ -369,7 +369,7 @@ def _style_inline_insert(self): f" border: none; border-bottom: 1px dashed {ACCENT}; padding: 0; }}") def _style_inline_edit(self, span: dict): - from PySide6.QtGui import QFont, QColor + from PySide6.QtGui import QFont bb = span["bbox"] visual_size = max(float(span.get("size") or 0), float(bb[3] - bb[1])) fname = (span.get("font", "") or "").lower() diff --git a/app/editor/dialogs.py b/app/editor/dialogs.py index debce97..6cd9457 100644 --- a/app/editor/dialogs.py +++ b/app/editor/dialogs.py @@ -3,7 +3,7 @@ import os import tempfile -from PySide6.QtCore import Qt, QPoint +from PySide6.QtCore import Qt from PySide6.QtGui import QImage, QPainter, QPen, QColor, QFont, QPainterPath, QPixmap from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, @@ -43,8 +43,18 @@ def __init__(self, filename: str, wrong: bool = False, parent=None): top = QHBoxLayout(); top.setSpacing(14) ico = QLabel() - _pix = qta.icon("fa5s.lock", color=ACCENT).pixmap(72, 72) - _pix.setDevicePixelRatio(2.0) + # Honour the actual screen DPR instead of hardcoding 2.0 — on + # a 1.0 (regular) monitor the icon was over-sized then scaled + # down (blur); on a 1.5/2.5 (mixed-DPI multi-monitor) layout + # the position drifted by a few pixels. Sampling the dialog's + # devicePixelRatioF gives a crisp 40x40 logical-pixel render + # on every monitor without per-platform fork. + dpr = self.devicePixelRatioF() if hasattr(self, "devicePixelRatioF") else 1.0 + if dpr <= 0: # paranoia: some headless / mocked widgets report 0 + dpr = 1.0 + size = int(40 * dpr) + _pix = qta.icon("fa5s.lock", color=ACCENT).pixmap(size, size) + _pix.setDevicePixelRatio(dpr) ico.setPixmap(_pix) ico.setFixedSize(40, 40) top.addWidget(ico) @@ -420,6 +430,17 @@ def _pick_image(self): self, t("edit.signature.import"), "", "Images (*.png *.jpg *.jpeg *.bmp *.webp *.tif *.tiff)") if p and os.path.isfile(p): + # Reject pathological / malicious gigapixel images BEFORE + # QPixmap allocates a multi-GB buffer (a 50000x50000 TIFF + # would crash the editor process). See utils.check_image_size. + from app.utils import check_image_size + ok, w, h = check_image_size(p) + if not ok: + QMessageBox.warning(self, t("msg.warning"), + t("editor.image_too_large", + width=w, height=h, + megapix=w * h // 1_000_000)) + return self._imp_path = p pix = QPixmap(p) self._imp_preview.setPixmap(pix.scaled( diff --git a/app/editor/tab.py b/app/editor/tab.py index 922c17c..25d05bf 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -9,8 +9,8 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QStackedWidget, QGroupBox, - QGridLayout, QLayout, QSizePolicy, QListWidget, QTableWidget, - QTableWidgetItem, QHeaderView, QTextEdit, QComboBox, QFileDialog, + QSizePolicy, QListWidget, QTableWidget, + QTableWidgetItem, QHeaderView, QTextEdit, QFileDialog, QMessageBox, QDialog, QApplication, QSlider, ) import qtawesome as qta @@ -23,7 +23,7 @@ from app.i18n import t from app.widgets import DropFileEdit, ColorPickerButton from app.editor.canvas import PdfEditCanvas, _get_icon_cursor -from app.editor.dialogs import _TextDialog, _NoteDialog, _TextEditDialog +from app.editor.dialogs import _NoteDialog _log = logging.getLogger(__name__) @@ -500,8 +500,8 @@ def update_theme(self, dark: bool) -> None: f"color:{ACCENT}; border-radius:6px; border-radius:6px;") else: b.setStyleSheet( - f"background:#D6F2EC; border:1px solid #83CABB; " - f"color:#0E5A51; border-radius:6px; border-radius:6px;") + "background:#D6F2EC; border:1px solid #83CABB; " + "color:#0E5A51; border-radius:6px; border-radius:6px;") def _update_nav(self): n = self._canvas.page_count() @@ -538,8 +538,8 @@ def _on_mode_btn(self, btn): f"color:{ACCENT}; border-radius:6px; border-radius:6px;") else: b.setStyleSheet( - f"background:#D6F2EC; border:1px solid #83CABB; " - f"color:#0E5A51; border-radius:6px; border-radius:6px;") + "background:#D6F2EC; border:1px solid #83CABB; " + "color:#0E5A51; border-radius:6px; border-radius:6px;") else: if self._dark_mode: b.setStyleSheet( @@ -735,6 +735,17 @@ def _pick_image(self): p, _ = QFileDialog.getOpenFileName(self, t("edit.image"), DESKTOP, t("file_filter.images")) if p: + # Reject gigapixel images before any downstream consumer + # (QPixmap preview, fitz.Pixmap on save) allocates a huge + # buffer. Mirrors the guard in _SignatureDialog._pick_image. + from app.utils import check_image_size + ok, w, h = check_image_size(p) + if not ok: + QMessageBox.warning(self, t("msg.warning"), + t("editor.image_too_large", + width=w, height=h, + megapix=w * h // 1_000_000)) + return self._img_drop.blockSignals(True) self._img_drop.set_path(p) self._img_drop.blockSignals(False) @@ -1216,7 +1227,11 @@ def _run(self): # user expectations instead of letting them discover tofu # after the save completes. _non_latin = any( - e.get("type") in ("text", "note") + # text_edit also writes via the built-in helv font + # (see the type-dispatch a few lines below), so the + # warning must cover it too — previously the user + # got tofu on edited spans without any heads-up. + e.get("type") in ("text", "note", "text_edit") and any(ord(c) > 0xFF for c in (e.get("text") or "")) for e in self._pending ) diff --git a/app/i18n.py b/app/i18n.py index 93e537c..4ded09c 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -238,7 +238,39 @@ def t(key: str, **kwargs) -> str: # ── Recent files ────────────────────────────────────────────────────────── -_MAX_RECENT = 5 +#: Conservative ceiling so a stray ``max_recent_files`` entry (corrupt +#: config, manual edit) cannot blow the recents menu out to thousands +#: of items and freeze the UI for seconds while it re-renders. +_RECENT_FILES_MIN = 1 +_RECENT_FILES_MAX = 50 +#: Default bumped from 5 to 10 — modern app conventions (Office, Chrome, +#: Acrobat) keep ~10 recents; 5 was leftover from the 1.0 prototype. +_DEFAULT_MAX_RECENT = 10 +# Back-compat alias for any external code that still imports the legacy +# constant. New call sites should use _get_max_recent() so the user's +# config override is honoured. +_MAX_RECENT = _DEFAULT_MAX_RECENT + + +def _get_max_recent() -> int: + """Return the user-configured max recent-files count, clamped. + + Reads ``max_recent_files`` from the on-disk config (falls back to + :data:`_DEFAULT_MAX_RECENT` for missing / non-int values). The value + is clamped to [_RECENT_FILES_MIN, _RECENT_FILES_MAX] so a hostile + or corrupt config cannot make the recents menu unusable. + """ + try: + with open(_CONFIG_PATH, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception: + return _DEFAULT_MAX_RECENT + val = cfg.get("max_recent_files", _DEFAULT_MAX_RECENT) + try: + n = int(val) + except (TypeError, ValueError): + return _DEFAULT_MAX_RECENT + return max(_RECENT_FILES_MIN, min(_RECENT_FILES_MAX, n)) def get_recent_files() -> list[str]: @@ -281,7 +313,7 @@ def _mutate(cfg: dict) -> None: if path in recents: recents.remove(path) recents.insert(0, path) - cfg["recent_files"] = recents[:_MAX_RECENT] + cfg["recent_files"] = recents[:_get_max_recent()] _update_config(_mutate) diff --git a/app/tools/compress.py b/app/tools/compress.py index 93a51ab..a17d241 100644 --- a/app/tools/compress.py +++ b/app/tools/compress.py @@ -7,12 +7,10 @@ QGroupBox, QFormLayout, QComboBox, QLabel, QFileDialog, QMessageBox, QProgressDialog, ) -from pypdf import PdfReader - from app.base import BasePage from app.i18n import t from app.utils import (section, info_lbl, _compress_pdf, _find_gs, - show_error, is_dark, result_label_style) + show_error, result_label_style) from app.worker import TaskRunner, run_task from app.constants import DESKTOP, TEXT_SEC from app.widgets import DropFileEdit diff --git a/app/tools/convert.py b/app/tools/convert.py index 2468132..6803f32 100644 --- a/app/tools/convert.py +++ b/app/tools/convert.py @@ -4,6 +4,7 @@ import logging import os import re +import tempfile _log = logging.getLogger(__name__) @@ -14,6 +15,33 @@ def _clean(text: str) -> str: """Strip control characters that break XML-based formats.""" return _CTRL_RE.sub('', text) + +def _atomic_save(out_path: str, write_cb): + """Run ``write_cb(tmp_path)`` then atomically rename onto ``out_path``. + + Mirrors BasePage._atomic_pdf_write for non-PDF outputs (DOCX, TXT, + PPTX, XLSX, HTML, EPUB) so a crash or cancel mid-save can no longer + leave a half-written file in place of the user's previous output — + the original output (if any) survives untouched until the rename + succeeds. Same-directory tempfile ensures os.replace is a single + filesystem operation (no cross-device move). + """ + suffix = os.path.splitext(out_path)[1] or ".tmp" + out_dir = os.path.dirname(out_path) or "." + fd, tmp = tempfile.mkstemp(suffix=suffix, dir=out_dir) + os.close(fd) + try: + write_cb(tmp) + os.replace(tmp, out_path) + except BaseException: + # BaseException so KeyboardInterrupt / cancellation also cleans + # up. Suppress the cleanup OSError because the original error + # is the one the caller needs to see. + with contextlib.suppress(OSError): + if os.path.exists(tmp): + os.unlink(tmp) + raise + from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QGroupBox, QFormLayout, QComboBox, QLabel, QFileDialog, @@ -22,7 +50,10 @@ def _clean(text: str) -> str: from app.base import BasePage from app.i18n import t -from app.utils import section, info_lbl, pick_folder, show_error, result_label_style +from app.utils import ( + section, info_lbl, pick_folder, show_error, result_label_style, + CancelledError, +) from app.constants import DESKTOP from app.widgets import DropFileEdit @@ -612,7 +643,7 @@ def _flush_until(y_limit: float) -> None: docx_doc.add_paragraph(f"[Note: {a.type}] {content}") if worker.is_cancelled(): return None - docx_doc.save(out_path) + _atomic_save(out_path, lambda p: docx_doc.save(p)) finally: doc.close() return total @@ -647,16 +678,29 @@ def do_work(worker): doc = fitz.open(pdf_path) if doc.needs_pass and pwd: doc.authenticate(pwd) + cancelled = False try: - with open(out_path, 'w', encoding='utf-8') as f: - for i, page in enumerate(doc): - if worker.is_cancelled(): - return None - if i > 0: - f.write(t("tool.convert.txt.page_separator", - n=i + 1)) - f.write(page.get_text()) - worker.progress.emit(i, f"{i + 1}/{total}…") + def _write_txt(tmp_path: str) -> None: + nonlocal cancelled + with open(tmp_path, 'w', encoding='utf-8') as f: + for i, page in enumerate(doc): + if worker.is_cancelled(): + cancelled = True + # Bail out via exception so _atomic_save + # discards the half-written tmp and does + # not replace the user's previous output. + raise CancelledError() + if i > 0: + f.write(t("tool.convert.txt.page_separator", + n=i + 1)) + f.write(page.get_text()) + worker.progress.emit(i, f"{i + 1}/{total}…") + try: + _atomic_save(out_path, _write_txt) + except CancelledError: + return None + if cancelled: + return None finally: doc.close() return total @@ -942,7 +986,7 @@ def _rgb(c): worker.progress.emit(i, f"{i + 1}/{total}…") if worker.is_cancelled(): return None - prs.save(out_path) + _atomic_save(out_path, lambda p: prs.save(p)) finally: doc.close() return total @@ -1007,7 +1051,7 @@ def do_work(worker): worker.progress.emit(i, f"{i + 1}/{total}…") if worker.is_cancelled(): return None - wb.save(out_path) + _atomic_save(out_path, lambda p: wb.save(p)) finally: doc.close() return total @@ -1091,8 +1135,12 @@ def do_work(worker): parts.append("") if worker.is_cancelled(): return None - with open(out_path, "w", encoding="utf-8") as f: - f.write("\n".join(parts)) + _html = "\n".join(parts) + + def _write_html(tmp_path: str) -> None: + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(_html) + _atomic_save(out_path, _write_html) finally: doc.close() return total @@ -1159,7 +1207,7 @@ def do_work(worker): book.add_item(epub.EpubNav()) book.spine = ["nav"] + chapters book.toc = chapters - epub.write_epub(out_path, book) + _atomic_save(out_path, lambda p: epub.write_epub(p, book)) finally: doc.close() return total diff --git a/app/tools/encrypt.py b/app/tools/encrypt.py index 1fdf83e..f666f47 100644 --- a/app/tools/encrypt.py +++ b/app/tools/encrypt.py @@ -8,7 +8,7 @@ QGroupBox, QFormLayout, QHBoxLayout, QComboBox, QLineEdit, QFileDialog, QMessageBox, ) -from pypdf import PdfReader, PdfWriter +from pypdf import PdfWriter from app.base import BasePage from app.i18n import t @@ -97,6 +97,11 @@ def _load_input(self, p: str): # is_encrypted reflects the on-disk state, even after decrypt() encrypted = r.is_encrypted status = t("tool.encrypt.status_enc") if encrypted else t("tool.encrypt.status_dec") + # ``n_pages`` is rendered straight into the localised + # "Pages: {n}" label, so the unknown-count fallback needs + # to be a string. Annotate the union explicitly instead of + # the previous int-typed "?" reassignment (mypy R7 LOW). + n_pages: int | str try: n_pages = len(r.pages) except Exception: diff --git a/app/tools/extract.py b/app/tools/extract.py index fb4cff5..cd67e2c 100644 --- a/app/tools/extract.py +++ b/app/tools/extract.py @@ -6,7 +6,7 @@ from PySide6.QtWidgets import ( QGroupBox, QFormLayout, QLineEdit, QLabel, QFileDialog, QMessageBox, ) -from pypdf import PdfReader, PdfWriter +from pypdf import PdfWriter from app.base import BasePage from app.i18n import t diff --git a/app/tools/import_pdf.py b/app/tools/import_pdf.py index a1efe6f..b54f30f 100644 --- a/app/tools/import_pdf.py +++ b/app/tools/import_pdf.py @@ -197,12 +197,22 @@ def _convert_images(self, sources: list, out_path: str): def do_work(worker): import fitz + from app.utils import check_image_size doc = fitz.open() skipped = 0 try: for i, img_path in enumerate(sources): if worker.is_cancelled(): return None + # Mirror the editor's gigapixel guard — a single + # 50000x50000 TIFF in the import list would otherwise + # make fitz allocate multi-GB and bring down the + # worker thread (which on a frozen PyInstaller build + # takes the whole app with it). + ok, _w, _h = check_image_size(img_path) + if not ok: + skipped += 1 + continue img = fitz.open(img_path) try: if img.page_count == 0: diff --git a/app/tools/info.py b/app/tools/info.py index d4d702d..a299a4a 100644 --- a/app/tools/info.py +++ b/app/tools/info.py @@ -3,7 +3,6 @@ import os from PySide6.QtWidgets import QTextEdit -from pypdf import PdfReader from app.base import BasePage from app.i18n import t diff --git a/app/tools/ocr.py b/app/tools/ocr.py index aec03f5..cc52ef9 100644 --- a/app/tools/ocr.py +++ b/app/tools/ocr.py @@ -224,7 +224,7 @@ def _run(self): out_path = self._resolve_output_file(self.drop_out, pdf_path) if not out_path: return try: - import pytesseract + import pytesseract # noqa: F401 — surface ImportError before launching worker except ImportError: QMessageBox.critical(self, t("msg.missing_dep"), t("tool.ocr.dep_pytesseract")) return diff --git a/app/tools/reorder.py b/app/tools/reorder.py index 42d8779..df84312 100644 --- a/app/tools/reorder.py +++ b/app/tools/reorder.py @@ -6,7 +6,7 @@ QGroupBox, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QAbstractItemView, QPushButton, QFileDialog, QMessageBox, ) -from pypdf import PdfReader, PdfWriter +from pypdf import PdfWriter from app.base import BasePage from app.i18n import t diff --git a/app/tools/rotate.py b/app/tools/rotate.py index 21a8284..12896e6 100644 --- a/app/tools/rotate.py +++ b/app/tools/rotate.py @@ -6,7 +6,7 @@ from PySide6.QtWidgets import ( QGroupBox, QFormLayout, QLineEdit, QComboBox, QFileDialog, QMessageBox, ) -from pypdf import PdfReader, PdfWriter +from pypdf import PdfWriter from app.base import BasePage from app.i18n import t diff --git a/app/tools/split.py b/app/tools/split.py index b90f2af..bfcc953 100644 --- a/app/tools/split.py +++ b/app/tools/split.py @@ -2,12 +2,11 @@ import os -from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QGroupBox, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QSpinBox, QPushButton, QFileDialog, QMessageBox, ) -from pypdf import PdfReader, PdfWriter +from pypdf import PdfWriter from app.base import BasePage from app.i18n import t diff --git a/app/translations.json b/app/translations.json index 03d106e..0f32418 100644 --- a/app/translations.json +++ b/app/translations.json @@ -597,6 +597,7 @@ "editor.forms.undo_unavailable": "Undo is not available in Forms mode.", "editor.forms.load_failed": "Failed to load form fields (PDF may be malformed).", "editor.forms.no_fields": "No form fields detected in this PDF.", + "editor.image_too_large": "Image is too large ({width}x{height}, {megapix} megapixels). Please pick an image under 100 megapixels.", "editor.signature.empty_draw": "Please draw your signature before clicking OK.", "editor.signature.empty_type": "Please type your name before clicking OK.", "editor.signature.empty_import": "Please pick an image before clicking OK.", @@ -1203,6 +1204,7 @@ "editor.forms.undo_unavailable": "Desfazer não está disponível no modo Formulários.", "editor.forms.load_failed": "Falha ao carregar campos do formulário (PDF pode estar corrompido).", "editor.forms.no_fields": "Não foram detetados campos de formulário neste PDF.", + "editor.image_too_large": "A imagem é demasiado grande ({width}x{height}, {megapix} megapíxeis). Escolha uma imagem com menos de 100 megapíxeis.", "editor.signature.empty_draw": "Desenhe a sua assinatura antes de clicar em OK.", "editor.signature.empty_type": "Escreva o seu nome antes de clicar em OK.", "editor.signature.empty_import": "Selecione uma imagem antes de clicar em OK.", @@ -1809,6 +1811,7 @@ "editor.forms.undo_unavailable": "Deshacer no está disponible en el modo Formularios.", "editor.forms.load_failed": "Error al cargar campos de formulario (PDF puede estar dañado).", "editor.forms.no_fields": "No se detectaron campos de formulario en este PDF.", + "editor.image_too_large": "La imagen es demasiado grande ({width}x{height}, {megapix} megapíxeles). Elige una imagen de menos de 100 megapíxeles.", "editor.signature.empty_draw": "Dibuja tu firma antes de hacer clic en Aceptar.", "editor.signature.empty_type": "Escribe tu nombre antes de hacer clic en Aceptar.", "editor.signature.empty_import": "Selecciona una imagen antes de hacer clic en Aceptar.", @@ -2415,6 +2418,7 @@ "editor.forms.undo_unavailable": "Annuler n'est pas disponible en mode Formulaires.", "editor.forms.load_failed": "Échec du chargement des champs de formulaire (PDF peut-être corrompu).", "editor.forms.no_fields": "Aucun champ de formulaire détecté dans ce PDF.", + "editor.image_too_large": "L'image est trop grande ({width}x{height}, {megapix} mégapixels). Choisissez une image de moins de 100 mégapixels.", "editor.signature.empty_draw": "Veuillez dessiner votre signature avant de cliquer sur OK.", "editor.signature.empty_type": "Veuillez saisir votre nom avant de cliquer sur OK.", "editor.signature.empty_import": "Veuillez sélectionner une image avant de cliquer sur OK.", @@ -3021,6 +3025,7 @@ "editor.forms.undo_unavailable": "Rückgängig ist im Formularmodus nicht verfügbar.", "editor.forms.load_failed": "Formularfelder konnten nicht geladen werden (PDF möglicherweise beschädigt).", "editor.forms.no_fields": "In diesem PDF wurden keine Formularfelder erkannt.", + "editor.image_too_large": "Das Bild ist zu groß ({width}x{height}, {megapix} Megapixel). Bitte wählen Sie ein Bild mit weniger als 100 Megapixeln.", "editor.signature.empty_draw": "Bitte zeichnen Sie Ihre Unterschrift, bevor Sie auf OK klicken.", "editor.signature.empty_type": "Bitte geben Sie Ihren Namen ein, bevor Sie auf OK klicken.", "editor.signature.empty_import": "Bitte wählen Sie ein Bild, bevor Sie auf OK klicken.", @@ -3627,6 +3632,7 @@ "editor.forms.undo_unavailable": "在表单模式下无法撤消。", "editor.forms.load_failed": "加载表单字段失败(PDF 可能已损坏)。", "editor.forms.no_fields": "在此 PDF 中未检测到表单字段。", + "editor.image_too_large": "图片过大({width}x{height},{megapix} 兆像素)。请选择小于 100 兆像素的图片。", "editor.signature.empty_draw": "请在点击确定前绘制您的签名。", "editor.signature.empty_type": "请在点击确定前输入您的姓名。", "editor.signature.empty_import": "请在点击确定前选择一张图像。", @@ -4233,6 +4239,7 @@ "editor.forms.undo_unavailable": "Annulla non è disponibile in modalità Moduli.", "editor.forms.load_failed": "Impossibile caricare i campi del modulo (PDF potrebbe essere danneggiato).", "editor.forms.no_fields": "Nessun campo modulo rilevato in questo PDF.", + "editor.image_too_large": "L'immagine è troppo grande ({width}x{height}, {megapix} megapixel). Scegli un'immagine inferiore a 100 megapixel.", "editor.signature.empty_draw": "Disegna la tua firma prima di fare clic su OK.", "editor.signature.empty_type": "Digita il tuo nome prima di fare clic su OK.", "editor.signature.empty_import": "Seleziona un'immagine prima di fare clic su OK.", @@ -4839,6 +4846,7 @@ "editor.forms.undo_unavailable": "Ongedaan maken is niet beschikbaar in formuliermodus.", "editor.forms.load_failed": "Kan formuliervelden niet laden (PDF mogelijk beschadigd).", "editor.forms.no_fields": "Geen formuliervelden gevonden in deze PDF.", + "editor.image_too_large": "Afbeelding is te groot ({width}x{height}, {megapix} megapixels). Kies een afbeelding van minder dan 100 megapixels.", "editor.signature.empty_draw": "Teken je handtekening voordat je op OK klikt.", "editor.signature.empty_type": "Typ je naam voordat je op OK klikt.", "editor.signature.empty_import": "Kies een afbeelding voordat je op OK klikt.", diff --git a/app/updater.py b/app/updater.py index b36af92..7fb59b9 100644 --- a/app/updater.py +++ b/app/updater.py @@ -7,7 +7,6 @@ import sys import tempfile import urllib.request -from threading import Thread from PySide6.QtCore import Qt, Signal, QObject from PySide6.QtWidgets import ( @@ -15,7 +14,7 @@ QMessageBox, QTextEdit, ) -from app.constants import APP_VERSION, GITHUB_REPO, ACCENT, ACCENT_H, TEXT_SEC, _LQ +from app.constants import APP_VERSION, GITHUB_REPO, ACCENT, TEXT_SEC, _LQ from app.utils import error_color @@ -268,7 +267,22 @@ def _apply_update_unix(downloaded: str): import shutil import stat current = sys.executable - backup = current + ".bak" + # Place the backup in the system temp dir rather than next to the + # binary. On OneDrive-installed copies the previous ".bak" + # neighbour file became visible to the user's sync history and was + # uploaded over wifi every update. tempfile.gettempdir() lives + # outside OneDrive and is auto-cleaned by the OS. + # + # Use shutil.move (not os.rename) because temp dir is frequently + # on a different volume from /usr/bin on Linux installs — rename + # then ENOXDEV; move falls back to copy+delete transparently. + backup_fd, backup = tempfile.mkstemp( + suffix=".pdfapps-backup", prefix=os.path.basename(current) + ".") + os.close(backup_fd) + # mkstemp pre-creates an empty file; remove so shutil.move can + # rename current onto the same name without "destination exists". + with contextlib.suppress(OSError): + os.unlink(backup) try: try: shutil.move(current, backup) diff --git a/app/utils.py b/app/utils.py index ecabfdf..862e542 100644 --- a/app/utils.py +++ b/app/utils.py @@ -10,7 +10,7 @@ from PySide6.QtGui import QPalette, QColor, QPainter from PySide6.QtWidgets import ( QWidget, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, - QScrollArea, QFrame, QFileDialog, QApplication, + QScrollArea, QFrame, QFileDialog, ) import qtawesome as qta @@ -18,7 +18,7 @@ from app.constants import ( ACCENT, DESKTOP, BG_BASE, BG_CARD, BG_INPUT, - BORDER, TEXT_PRI, TEXT_SEC, + TEXT_PRI, SUCCESS_DARK, SUCCESS_LIGHT, _LA, _LB, _LC, _LI, _LN, _LO, _LP, ) @@ -125,6 +125,35 @@ def parse_pages(text: str, total: int) -> list: return sorted(set(pages)) +#: Megapixel hard limit applied to user-supplied raster images before +#: they reach QPixmap / PyMuPDF. A 100MP cap rejects gigapixel scans +#: (e.g. a malicious or accidentally-saved 50000x50000 TIFF) that would +#: otherwise allocate multi-GB pixmaps and crash the process, while +#: still admitting every realistic phone-camera / scanner output (the +#: largest current consumer cameras top out around 200MP — at that +#: point the warning is intentional and the user knows to downscale). +_IMAGE_PIXEL_LIMIT = 100_000_000 + + +def check_image_size(path: str) -> tuple[bool, int, int]: + """Return ``(ok, width, height)`` for the image at ``path``. + + ``ok`` is ``False`` when the image exceeds :data:`_IMAGE_PIXEL_LIMIT` + (width * height > 100 megapixels). Used by the editor signature + picker and the PDF import-images path to short-circuit before + allocating a giant pixmap. On any read error returns ``(True, 0, 0)`` + so callers fall back to their existing failure path (a missing / + corrupted image is the existing tool's responsibility to surface). + """ + try: + from PIL import Image + with Image.open(path) as img: + w, h = img.size + except Exception: + return True, 0, 0 + return (w * h) <= _IMAGE_PIXEL_LIMIT, w, h + + def pick_pdfs(parent: QWidget) -> list: paths, _ = QFileDialog.getOpenFileNames( parent, t("btn.select_pdfs"), DESKTOP, t("file_filter.pdf")) @@ -381,6 +410,35 @@ def _find_gs(): return None +def _win_short_path(path: str) -> str: + """On Windows, try to map ``path`` to its 8.3 short form. + + The 1.5 GB Ghostscript binary still uses the legacy ANSI process + locale on Windows when reading the command line, so paths under + user profiles with non-ASCII characters (e.g. ``C:\\Users\\José``) + get mangled by the time ``-sOutputFile=...`` reaches the engine — + causing a misleading "could not open output file" error. Convert + to the 8.3 short alias which is always ASCII when the volume has + short names enabled (default on NTFS). + + On non-Windows, or if the conversion fails (short names disabled, + path does not exist yet), returns ``path`` unchanged. + """ + if sys.platform != "win32" or not path: + return path + if not os.path.exists(path): + return path # GetShortPathNameW requires the file to exist + try: + import ctypes + buf = ctypes.create_unicode_buffer(512) + n = ctypes.windll.kernel32.GetShortPathNameW(path, buf, 512) + if n and buf.value: + return buf.value + except Exception: + pass + return path + + def _compress_pdf(src: str, dst: str, level: str = "recommended", progress_fn=None) -> tuple: """ @@ -461,7 +519,13 @@ def _prog(stage, cur=0, tot=0): cmd += ["-sColorConversionStrategy=Gray", "-dProcessColorModel=/DeviceGray", "-dOverrideICC"] - cmd += [f"-sOutputFile={p}", src] + # Short-name conversion (Windows non-ASCII user profile + # safety). gs reads the command line through the legacy ANSI + # encoding; the short alias is always ASCII on NTFS volumes + # with 8.3 names enabled (default). No-op on POSIX. + _src_for_gs = _win_short_path(src) + _out_for_gs = _win_short_path(p) + cmd += [f"-sOutputFile={_out_for_gs}", _src_for_gs] # Spawn gs as a polled subprocess so the cancel button works # mid-render. subprocess.run(timeout=120) blocks the worker # thread for the whole timeout window, leaving Cancel dead diff --git a/app/viewer/canvas.py b/app/viewer/canvas.py index a93a960..8bc9c30 100644 --- a/app/viewer/canvas.py +++ b/app/viewer/canvas.py @@ -9,7 +9,7 @@ from PySide6.QtCore import Qt, Signal, QRect, QObject, QRunnable, QThreadPool from PySide6.QtWidgets import QWidget, QApplication -from PySide6.QtGui import QPixmap, QColor, QPainter, QPen, QFont +from PySide6.QtGui import QColor, QPainter, QPen, QFont import qtawesome as qta from app.constants import BG_INNER, TEXT_SEC, _LN diff --git a/app/widgets.py b/app/widgets.py index 1289c19..b0c94c4 100644 --- a/app/widgets.py +++ b/app/widgets.py @@ -3,7 +3,7 @@ import os from PySide6.QtCore import Signal, Qt, QSize -from PySide6.QtGui import QDragEnterEvent, QDropEvent, QColor, QPixmap, QPainter +from PySide6.QtGui import QDragEnterEvent, QDropEvent, QColor from PySide6.QtWidgets import ( QWidget, QHBoxLayout, QLabel, QPushButton, QFileDialog, QColorDialog, QMessageBox, diff --git a/app/window.py b/app/window.py index 642e532..8500dde 100644 --- a/app/window.py +++ b/app/window.py @@ -3,7 +3,7 @@ import contextlib import os -from PySide6.QtCore import Qt, QSize, Signal, QTimer +from PySide6.QtCore import Qt, QSize, QTimer from PySide6.QtGui import QIcon from shiboken6 import isValid from PySide6.QtWidgets import ( @@ -1024,7 +1024,6 @@ def dropEvent(self, e): # R10 #12: accept folders (open every .pdf inside) and warn # the user when a web URL is dropped instead of silently # ignoring it. - import glob for url in e.mimeData().urls(): path = url.toLocalFile() if not path: diff --git a/app/worker.py b/app/worker.py index 82b3a07..0272f0c 100644 --- a/app/worker.py +++ b/app/worker.py @@ -114,7 +114,7 @@ def _drain(): _state["in"] = True try: while _state["pct"] is not None or _state["label"]: - p = _state["pct"]; l = _state["label"] + p = _state["pct"]; lbl = _state["label"] _state["pct"] = None; _state["label"] = None try: if p is not None: @@ -129,8 +129,8 @@ def _drain(): if progress_dlg.maximum() == 0: progress_dlg.setRange(0, 100) progress_dlg.setValue(int(p)) - if l: - progress_dlg.setLabelText(l) + if lbl: + progress_dlg.setLabelText(lbl) except RuntimeError: # Dialog already destroyed (page closed mid-task, # window quit during a queued progress signal). diff --git a/installer.py b/installer.py index c8af0f2..6e0b360 100644 --- a/installer.py +++ b/installer.py @@ -1100,7 +1100,13 @@ def _install(self): self.after(0, self._done, install_dir, app_exe) except Exception as exc: - self.after(0, lambda: messagebox.showerror("Error", str(exc))) + # Capture exc into a local string and bind it as a lambda + # default argument; otherwise Python's late-binding closure + # raises NameError once the worker thread frame unwinds and + # `exc` falls out of scope before tk runs the after-callback. + err_msg = str(exc) + self.after(0, lambda msg=err_msg: + messagebox.showerror("Error", msg)) self.after(0, lambda: self._btn.config( state="normal", text=_t("install"))) diff --git a/pdfapps.py b/pdfapps.py index 0d8edb5..d1da851 100644 --- a/pdfapps.py +++ b/pdfapps.py @@ -7,7 +7,10 @@ from PySide6.QtWidgets import QApplication, QMessageBox try: - from pypdf import PdfReader, PdfWriter + # Defensive import — surfaces the missing-dependency dialog before + # the rest of app.* drags pypdf in via tools/*.py and crashes with + # an opaque traceback. The names are intentionally unused here. + from pypdf import PdfReader, PdfWriter # noqa: F401 except ImportError: _app = QApplication(sys.argv) QMessageBox.critical(None, "Missing dependency", diff --git a/tests/test_audit_pr_j.py b/tests/test_audit_pr_j.py new file mode 100644 index 0000000..a776751 --- /dev/null +++ b/tests/test_audit_pr_j.py @@ -0,0 +1,313 @@ +"""Regression tests for PR-J audit cleanup (13 fixes). + +Each test exercises one of the fixes either via direct call (preferred) +or via source-grep when the production behaviour depends on a GUI / +subprocess / OS call we cannot reasonably stage from a unit test. +""" + +import io +import json +import os +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_REPO_ROOT)) + + +# ── Fix #1: installer NameError on error path ───────────────────────────── + +def test_installer_exc_lambda_captures_message(): + """The installer error path must capture str(exc) into the lambda's + default argument so the after-callback does not blow up with + NameError when the worker frame unwinds. + """ + src = (_REPO_ROOT / "installer.py").read_text(encoding="utf-8") + # Find the install_app worker except block. Match the multi-line + # pattern that PR-J introduced: err_msg = str(exc) followed by a + # lambda with a default arg that binds it. + idx = src.find("err_msg = str(exc)") + assert idx > 0, "installer except path must capture str(exc) into a local" + window = src[idx:idx + 400] + assert "lambda msg=err_msg" in window, \ + "after-callback must bind err_msg as a lambda default argument" + + +# ── Fixes #2/#3: image dimension cap ────────────────────────────────────── + +def test_check_image_size_allows_normal_image(tmp_path): + from PIL import Image + p = tmp_path / "small.png" + Image.new("RGB", (1024, 768), "white").save(str(p)) + from app.utils import check_image_size + ok, w, h = check_image_size(str(p)) + assert ok + assert (w, h) == (1024, 768) + + +def test_check_image_size_rejects_gigapixel(monkeypatch): + """Stub PIL.Image.open to report a 50000x50000 buffer without + actually allocating it (the test must not OOM the CI runner). + The helper must return ok=False with the reported dimensions. + """ + from app import utils + + class _FakeImage: + size = (50_000, 50_000) + def __enter__(self): return self + def __exit__(self, *_): return False + + from PIL import Image as _PILImage + monkeypatch.setattr(_PILImage, "open", lambda _p: _FakeImage()) + ok, w, h = utils.check_image_size("anything.tif") + assert ok is False + assert (w, h) == (50_000, 50_000) + + +def test_check_image_size_swallows_read_error(): + """A missing / corrupt image must NOT block the existing flow — + return ok=True so the caller's normal error handling runs. + """ + from app.utils import check_image_size + ok, w, h = check_image_size("/nonexistent/path/does/not/exist.jpg") + assert ok is True + assert (w, h) == (0, 0) + + +def test_image_cap_guard_present_in_pickers(): + """Source-level: both editor picker entry points must consult + check_image_size before allocating a pixmap. + """ + for rel in ("app/editor/dialogs.py", "app/editor/tab.py", + "app/tools/import_pdf.py"): + src = (_REPO_ROOT / rel).read_text(encoding="utf-8") + assert "check_image_size" in src, \ + f"{rel} must call check_image_size before loading user images" + + +# ── Fix #4: PasswordDialog devicePixelRatio ─────────────────────────────── + +def test_password_dialog_uses_dpr(): + src = (_REPO_ROOT / "app" / "editor" / "dialogs.py").read_text(encoding="utf-8") + # The icon pixmap must be sized from the dialog's own DPR rather + # than the previous hardcoded 72,72 + 2.0 dpr combo. + assert "devicePixelRatioF" in src + # And the hardcoded 72-px pixmap call must be gone. + assert ".pixmap(72, 72)" not in src + assert "setDevicePixelRatio(2.0)" not in src + + +# ── Fix #5: text_edit non-Latin detector ────────────────────────────────── + +def test_non_latin_detector_covers_text_edit(): + src = (_REPO_ROOT / "app" / "editor" / "tab.py").read_text(encoding="utf-8") + # The non-Latin sniffer must include 'text_edit' in its type tuple. + # Match the tuple literal regardless of whitespace tweaks. + assert '"text_edit"' in src + # Locate the _non_latin assignment block and ensure text_edit lives + # inside the same any() generator (not somewhere else in the file). + idx = src.find("_non_latin = any(") + assert idx > 0 + block = src[idx:idx + 400] + assert "text_edit" in block, "text_edit must be in the non-Latin tuple" + + +# ── Fix #6: atomic write for non-PDF outputs ────────────────────────────── + +def test_convert_uses_atomic_save(tmp_path): + """_atomic_save must write to a sibling tempfile and os.replace + onto the target, leaving the prior content untouched on failure. + """ + from app.tools.convert import _atomic_save + target = tmp_path / "out.docx" + target.write_bytes(b"OLD_CONTENT") + + # 1) Successful save replaces the old file. + _atomic_save(str(target), lambda p: open(p, "wb").write(b"NEW_CONTENT")) + assert target.read_bytes() == b"NEW_CONTENT" + + # 2) Failing save leaves the previous (now-new) content untouched + # and does not leak a tempfile in the directory. + with pytest.raises(RuntimeError): + def _boom(p: str) -> None: + open(p, "wb").write(b"PARTIAL") + raise RuntimeError("simulated failure") + _atomic_save(str(target), _boom) + assert target.read_bytes() == b"NEW_CONTENT" + # Sibling tempfiles use .docx suffix; none must remain. + siblings = [f for f in tmp_path.iterdir() + if f.name != "out.docx" and f.name.endswith(".docx")] + assert siblings == [], f"orphan tempfile leaked: {siblings}" + + +def test_convert_call_sites_use_atomic_save(): + src = (_REPO_ROOT / "app" / "tools" / "convert.py").read_text(encoding="utf-8") + # Every non-PDF saver must go through _atomic_save now. Count + # references to the helper — there should be at least 6 (one per + # output format). + n = src.count("_atomic_save(out_path") + assert n >= 6, f"expected >=6 _atomic_save call sites, found {n}" + # And the unsafe direct-save patterns must no longer appear. + assert "docx_doc.save(out_path)" not in src + assert "prs.save(out_path)" not in src + assert "wb.save(out_path)" not in src + assert "epub.write_epub(out_path" not in src + + +# ── Fix #7: gs Windows non-ASCII paths ──────────────────────────────────── + +def test_win_short_path_noop_on_posix(): + """On non-Windows hosts the helper must return the path unchanged.""" + from app.utils import _win_short_path + if sys.platform == "win32": + pytest.skip("Windows-specific behaviour exercised in next test") + assert _win_short_path("/tmp/x.pdf") == "/tmp/x.pdf" + assert _win_short_path("") == "" + + +def test_win_short_path_returns_string_on_windows(tmp_path): + """On Windows the helper must always return a string (either the + short alias or the original path), never raise. + """ + if sys.platform != "win32": + pytest.skip("Windows-only") + from app.utils import _win_short_path + p = tmp_path / "test.pdf" + p.write_bytes(b"%PDF-1.4\n") + out = _win_short_path(str(p)) + assert isinstance(out, str) + assert out # must not be empty + + +def test_compress_gs_cmd_uses_short_path_helper(): + src = (_REPO_ROOT / "app" / "utils.py").read_text(encoding="utf-8") + # The gs call site must apply _win_short_path to both input and + # output before assembling the command. + assert "_win_short_path(src)" in src + assert "_win_short_path(p)" in src + + +# ── Fix #8: _MAX_RECENT configurable ────────────────────────────────────── + +def test_get_max_recent_default(): + from app.i18n import _get_max_recent, _DEFAULT_MAX_RECENT + # Default bumped to 10 from the previous hardcoded 5. + assert _DEFAULT_MAX_RECENT == 10 + n = _get_max_recent() + # Without an override the config returns the default OR a previously + # persisted value; either way it must be clamped to [1, 50]. + assert 1 <= n <= 50 + + +def test_get_max_recent_honours_config(tmp_path, monkeypatch): + """Stub _CONFIG_PATH to a tmp file with max_recent_files=20 and + confirm the helper reflects it. + """ + from app import i18n + cfg_path = tmp_path / "config.json" + cfg_path.write_text(json.dumps({"max_recent_files": 20}), encoding="utf-8") + monkeypatch.setattr(i18n, "_CONFIG_PATH", str(cfg_path)) + assert i18n._get_max_recent() == 20 + + +def test_get_max_recent_clamps_garbage(tmp_path, monkeypatch): + from app import i18n + cfg_path = tmp_path / "config.json" + # 999 must clamp to 50 (upper bound); negative to 1 (lower bound); + # non-int must fall back to the default. + for raw, expected in [(999, 50), (-3, 1), ("oops", 10), (None, 10)]: + cfg_path.write_text(json.dumps({"max_recent_files": raw}), + encoding="utf-8") + monkeypatch.setattr(i18n, "_CONFIG_PATH", str(cfg_path)) + assert i18n._get_max_recent() == expected, \ + f"bad clamp for raw={raw!r}: got {i18n._get_max_recent()}" + + +# ── Fix #9: ruff lint clean ─────────────────────────────────────────────── + +def test_no_f401_f541_e741_lint_residual(): + """ruff --select F401,F541,E741 must report a clean tree.""" + import subprocess + py = sys.executable + res = subprocess.run( + [py, "-m", "ruff", "check", "--select", "F401,F541,E741", + "app/", "pdfapps.py", "installer.py"], + cwd=str(_REPO_ROOT), capture_output=True, text=True, timeout=60) + if res.returncode != 0: + pytest.fail(f"ruff still reports lint:\n{res.stdout}\n{res.stderr}") + + +# ── Fix #10: encrypt.py n_pages type ────────────────────────────────────── + +def test_encrypt_n_pages_annotated_union(): + src = (_REPO_ROOT / "app" / "tools" / "encrypt.py").read_text(encoding="utf-8") + # The annotation must declare the int|str union (or equivalent) + # before the try/except that may rebind to "?". + assert "n_pages: int | str" in src or "n_pages: Union[int, str]" in src + + +# ── Fix #11: existing-test regression check ─────────────────────────────── + +def test_pdfapps_tests_no_longer_reference_old_stub(): + src = (_REPO_ROOT / "tests" / "test_pdfapps.py").read_text(encoding="utf-8") + # The two stale tests have been repaired: + # (a) The _Stub class must now bind _nfc. + # (b) The slice end must use the next def boundary, not a fixed + # 1500-char window. + assert "_nfc = staticmethod(BasePage._nfc)" in src + assert 'src.find("\\n def "' in src + assert "src[i:i + 1500]" not in src + + +# ── Fix #12: updater backup in temp dir ─────────────────────────────────── + +def test_updater_backup_uses_tempfile(): + src = (_REPO_ROOT / "app" / "updater.py").read_text(encoding="utf-8") + # The legacy ".bak" concat must be gone; the new path + # comes from tempfile.mkstemp. + assert 'backup = current + ".bak"' not in src + assert "tempfile.mkstemp(" in src + # And the suffix used for the backup makes it identifiable to a + # forensics workflow. + assert ".pdfapps-backup" in src + + +# ── Fix #13: CI artifact upload ─────────────────────────────────────────── + +def test_build_yml_does_not_publish_raw_exe(): + yml = (_REPO_ROOT / ".github" / "workflows" / "build.yml").read_text(encoding="utf-8") + # Strip YAML comments before scanning so a documentation reference + # inside a "# Intentionally do NOT upload dist/PDFApps.exe" note + # does not trip the assertion. + stripped_lines = [] + for ln in yml.splitlines(): + idx = ln.find("#") + if idx >= 0: + ln = ln[:idx] + stripped_lines.append(ln) + body = "\n".join(stripped_lines) + # The raw PyInstaller binary path must NOT appear in upload, sha256 + # loop, or final release files list. The installer (.exe) and msix + # (.msix) remain. + assert "dist/PDFApps.exe" not in body, \ + "raw PDFApps.exe must not be in upload-artifact path list" + assert "PDFApps-Windows/PDFApps.exe" not in body, \ + "raw PDFApps.exe must not be in download/release pipeline" + + +# ── Translation parity ──────────────────────────────────────────────────── + +def test_image_too_large_key_present_in_all_languages(): + data = json.loads((_REPO_ROOT / "app" / "translations.json") + .read_text(encoding="utf-8")) + for lang, bundle in data.items(): + assert "editor.image_too_large" in bundle, \ + f"editor.image_too_large missing in {lang}" + # All localisations must keep the {width}, {height}, {megapix} + # placeholders intact — the helper renders with kwargs. + v = bundle["editor.image_too_large"] + for placeholder in ("{width}", "{height}", "{megapix}"): + assert placeholder in v, \ + f"{lang}: missing {placeholder} in editor.image_too_large" diff --git a/tests/test_pdfapps.py b/tests/test_pdfapps.py index bc8024a..b4656bb 100644 --- a/tests/test_pdfapps.py +++ b/tests/test_pdfapps.py @@ -654,10 +654,15 @@ def test_encrypted_pdf_helpers_unlock_with_stored_password(self, tmp): with open(enc, "wb") as f: w.write(f) # Stand-alone object that mimics a tool with a stored password + # PR-H added a staticmethod ``_nfc`` (NFC-normaliser); _open_* + # call it via ``self._nfc(...)`` so the stub must expose it too, + # otherwise the test fails with AttributeError instead of + # exercising the actual decrypt code path. class _Stub: _pdf_password = "topsecret" - _open_reader = BasePage._open_reader - _open_fitz = BasePage._open_fitz + _nfc = staticmethod(BasePage._nfc) + _open_reader = BasePage._open_reader + _open_fitz = BasePage._open_fitz stub = _Stub() r = stub._open_reader(enc) @@ -697,10 +702,15 @@ def test_editor_handles_encrypted_pdfs(self): # through to the canvas. The audit flagged this as broken — the # job opened with fitz.open without authenticate(). src = open(_REPO_ROOT / "app" / "editor" / "tab.py", encoding="utf-8").read() - # _load_pdf integrates the password prompt + # _load_pdf integrates the password prompt. PR-H/PR-I inflated + # the body of _load_pdf past the original 1500-char slice (now + # ~2 KB), so slice to the next function boundary instead of a + # fixed-width window — keeps the assertion robust to future + # additions inside the same function. i = src.find("def _load_pdf(self, p: str):") assert i > 0 - block = src[i:i + 1500] + j = src.find("\n def ", i + 1) + block = src[i:j if j > 0 else i + 5000] assert "prompt_pdf_password" in block, \ "editor _load_pdf must call prompt_pdf_password" assert "password=self._pdf_password" in block, \