From 11697faed099a241621bca5172cbbaede19bb5d7 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 14:57:45 +0100 Subject: [PATCH 01/14] fix(installer): capture exc in lambda closure to prevent NameError (CRIT) When install_app raised, the except block scheduled lambdas via self.after() that referenced `exc` after the worker thread frame had unwound, so the after-callback hit NameError instead of showing the friendly error dialog. Capture str(exc) up front and bind it via a lambda default argument so the closure is independent of the enclosing frame's local-variable lifetime. Co-Authored-By: Claude Opus 4.7 --- installer.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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"))) From 865e7bef1d7abfa2c8a5eaeb8d3ba703cde377ed Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 14:59:41 +0100 Subject: [PATCH 02/14] fix(editor): cap image dimensions in signature/image pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user (or a malicious actor) picking a gigapixel TIFF (50000x50000) crashed the editor process when QPixmap allocated multi-GB to hold the raw decoded buffer. Add a shared utils.check_image_size helper using PIL.Image (already a dependency) to short-circuit before allocation, returning width/height for a localised warning. Apply at the two entry points the audit flagged: _SignatureDialog import tab in app/editor/dialogs.py and EditorTab._pick_image in app/editor/tab.py. Cap is 100 megapixels — generous enough for any phone-camera / scanner output, tight enough to refuse gigapixel attacks. Add editor.image_too_large key across all 8 supported languages. Co-Authored-By: Claude Opus 4.7 --- app/editor/dialogs.py | 11 +++++++++++ app/editor/tab.py | 11 +++++++++++ app/translations.json | 8 ++++++++ app/utils.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/app/editor/dialogs.py b/app/editor/dialogs.py index debce97..f016743 100644 --- a/app/editor/dialogs.py +++ b/app/editor/dialogs.py @@ -420,6 +420,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..b8d6a54 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -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) 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/utils.py b/app/utils.py index ecabfdf..e5c58ec 100644 --- a/app/utils.py +++ b/app/utils.py @@ -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")) From a0c1ce0d058d76ac8b20079f9f8044f29a37d819 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:00:02 +0100 Subject: [PATCH 03/14] fix(import_pdf): cap image dimensions before fitz.open The same gigapixel-image attack surface the editor pickers hardened exists in the Import-PDF tool when the user multi-selects images: a single 50000x50000 TIFF in the source list crashed the worker thread (and on frozen PyInstaller builds takes the whole app). Use the shared utils.check_image_size guard, increment the existing `skipped` counter on rejection so the user gets the standard "skipped N images" status rather than a silent failure. Co-Authored-By: Claude Opus 4.7 --- app/tools/import_pdf.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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: From 4b52c4ef905c85ab952abf9ce5832d9984fb1cfa Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:00:22 +0100 Subject: [PATCH 04/14] fix(dialogs): scale password icon by devicePixelRatio The password dialog's lock icon was rendered at 72x72 with a hardcoded DPR of 2.0, which assumes every monitor is Retina/HiDPI. On a 1.0 DPR (regular 1080p) monitor the icon was over-sized then downscaled (blur); on a mixed-DPI multi-monitor setup it drifted by a few pixels when the dialog moved between screens. Sample devicePixelRatioF() on the dialog itself and size the pixmap to `logical_size * dpr` while keeping the QLabel fixed at 40x40 logical pixels. Defaults to 1.0 if the API is missing or returns a non-positive value (headless/mocked widgets). Co-Authored-By: Claude Opus 4.7 --- app/editor/dialogs.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/editor/dialogs.py b/app/editor/dialogs.py index f016743..96ea704 100644 --- a/app/editor/dialogs.py +++ b/app/editor/dialogs.py @@ -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) From 0b55e028ddbaef1584ad7c673819ab706bf5fca3 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:00:39 +0100 Subject: [PATCH 05/14] fix(editor): include text_edit type in non-Latin font detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-Latin warning loop (R11-L4) covered the 'text' and 'note' edit types but missed 'text_edit' — yet text_edit also writes via the built-in helv font, so users editing existing spans containing non-Latin characters got silent tofu output without any warning. Co-Authored-By: Claude Opus 4.7 --- app/editor/tab.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/editor/tab.py b/app/editor/tab.py index b8d6a54..6ecd311 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -1227,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 ) From 03daec3fa13ac51147107053bd694a941d225431 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:02:05 +0100 Subject: [PATCH 06/14] fix(convert): atomic write for non-PDF outputs (6 sites) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every non-image converter (DOCX/TXT/PPTX/XLSX/HTML/EPUB) was writing to the user-supplied out_path directly, so a crash or cancel mid-save truncated whatever was previously at that path (the docx_doc.save, prs.save, wb.save, epub.write_epub, raw open(... 'w') calls all truncate-on-open). PDF outputs already went through BasePage._atomic_pdf_write; non-PDF outputs were the gap. Add a private _atomic_save helper that wraps tempfile.mkstemp + os.replace (same-directory tempfile so the rename stays a single FS op). For TXT, refactor the streaming-write loop to raise CancelledError if the worker cancels mid-page — _atomic_save's BaseException handler discards the half-written tmp, leaving the previous output untouched. Co-Authored-By: Claude Opus 4.7 --- app/tools/convert.py | 80 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 16 deletions(-) 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 From f31474cbd15a23a1523fe3f343fc5857fe8ac131 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:02:43 +0100 Subject: [PATCH 07/14] fix(utils): fall back to short-name path for non-ASCII gs subprocess on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the input PDF or the tempfile output sits under a user profile with non-ASCII characters (e.g. C:\Users\José\report.pdf), Ghostscript on Windows mis-parses the command line because it still reads argv through the legacy ANSI code page — the resulting error is the unhelpful 'could not open output file' message. Add _win_short_path helper that calls GetShortPathNameW to map the path to its 8.3 alias (ASCII on NTFS with short-names enabled, which is the default). No-op on POSIX or when the path does not yet exist. Apply at the gs subprocess call site for both the input PDF and the tempfile output. Co-Authored-By: Claude Opus 4.7 --- app/utils.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/app/utils.py b/app/utils.py index e5c58ec..85859f1 100644 --- a/app/utils.py +++ b/app/utils.py @@ -410,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: """ @@ -490,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 From e4c51385bec7142d7c32ef8ebc7e7236a17a8ab2 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:03:32 +0100 Subject: [PATCH 08/14] chore(i18n): make _MAX_RECENT configurable via max_recent_files (default 10) Replace the hardcoded _MAX_RECENT=5 with a _get_max_recent() helper that reads max_recent_files from the on-disk config, falls back to the new default of 10 (matches Office/Chrome/Acrobat conventions; 5 was leftover from the 1.0 prototype), and clamps to [1, 50] so a hostile / corrupt config cannot make the recents menu freeze the UI for seconds while re-rendering thousands of entries. Keep the _MAX_RECENT module-level alias bound to the default so any external import of the legacy constant keeps working. Live config override is only honoured via the new helper. Co-Authored-By: Claude Opus 4.7 --- app/i18n.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) 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) From 3311861a9a1fb15dabba037c61bb3481eff11294 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:08:51 +0100 Subject: [PATCH 09/14] chore: ruff lint cleanup (F401, F541, E741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep the four ruff codes the audit flagged across app/, pdfapps.py and installer.py. 41 violations → 0: - 14 F401: drop dead PdfReader/Qt/QPixmap/QPainter/QColor/Signal/ Thread/glob/ACCENT_H/BORDER/TEXT_SEC/QApplication/QPoint/ QGridLayout/QLayout/QComboBox/_TextDialog/_TextEditDialog/is_dark imports. The defensive pypdf import in pdfapps.py and the pytesseract probe in tools/ocr.py keep their imports but get a noqa:F401 with an inline explanation. - 11 F541: strip the spurious f-prefix on Qt stylesheet fragments that contain no placeholders (toast in base.py, mode-button inactive variants in editor/tab.py). - 1 E741: rename ambiguous `l` to `lbl` in worker.py drain loop. Co-Authored-By: Claude Opus 4.7 --- app/base.py | 14 +++++++------- app/editor/canvas.py | 2 +- app/editor/dialogs.py | 2 +- app/editor/tab.py | 14 +++++++------- app/tools/compress.py | 4 +--- app/tools/encrypt.py | 2 +- app/tools/extract.py | 2 +- app/tools/info.py | 1 - app/tools/ocr.py | 2 +- app/tools/reorder.py | 2 +- app/tools/rotate.py | 2 +- app/tools/split.py | 3 +-- app/updater.py | 3 +-- app/utils.py | 4 ++-- app/viewer/canvas.py | 2 +- app/widgets.py | 2 +- app/window.py | 3 +-- app/worker.py | 6 +++--- pdfapps.py | 5 ++++- 19 files changed, 36 insertions(+), 39 deletions(-) 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 96ea704..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, diff --git a/app/editor/tab.py b/app/editor/tab.py index 6ecd311..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( 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/encrypt.py b/app/tools/encrypt.py index 1fdf83e..0381b60 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 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/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/updater.py b/app/updater.py index b36af92..acd7ddd 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 diff --git a/app/utils.py b/app/utils.py index 85859f1..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, ) 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/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", From 4fea5dad18378b68024e2cd79bea898156ab00c9 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:09:11 +0100 Subject: [PATCH 10/14] fix(encrypt): correct type annotation for n_pages The fallback path reassigned an int-typed variable to '?' on read failure, which mypy flagged as incompatible. Add the int|str union annotation up front so the fallback is type-clean; the renderer (t('edit.status.pages', n=...)) treats both transparently because str.format handles either. Co-Authored-By: Claude Opus 4.7 --- app/tools/encrypt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/tools/encrypt.py b/app/tools/encrypt.py index 0381b60..f666f47 100644 --- a/app/tools/encrypt.py +++ b/app/tools/encrypt.py @@ -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: From 39d6290945c1698862e3f98ee5dc005c1ea863a5 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:10:01 +0100 Subject: [PATCH 11/14] test: fix two stale test stubs in test_pdfapps.py 1. test_encrypted_pdf_helpers_unlock_with_stored_password: PR-H added a staticmethod _nfc on BasePage that _open_reader and _open_fitz call via self._nfc(...). The standalone test _Stub never grew the attribute, so the test failed with AttributeError before exercising the actual decrypt path. Bind staticmethod(BasePage._nfc). 2. test_editor_handles_encrypted_pdfs: the test sliced the source of _load_pdf with a fixed 1500-char window, which PR-H/PR-I outgrew when they added the password prompt + NFC normalisation lines. The 'password=self._pdf_password' assertion fell off the slice. Use the next `def ` boundary as the slice end so the assertion stays robust against further additions inside the same function. Both tests now pass. Co-Authored-By: Claude Opus 4.7 --- tests/test_pdfapps.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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, \ From 9cfe83c43584e42c0d084d836491b0d4e4e54bc8 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:10:36 +0100 Subject: [PATCH 12/14] fix(updater): place backup file in temp dir to avoid OneDrive sync noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old binary was renamed to '.bak' alongside the install location. On OneDrive-installed copies (Windows Store + manual installs of self-extracting setup into a synced folder) the .bak file became a visible sync entry, prompting OneDrive to upload the prior binary on every update — adds a couple of hundred MB of churn per release for users with the app under their OneDrive root. Move the backup to tempfile.gettempdir() via mkstemp, keeping the original basename + .pdfapps-backup suffix for forensics. Use shutil.move (already imported) instead of os.rename for the cross-volume case — temp dir is often on a different filesystem from /usr/bin on Linux installs. Co-Authored-By: Claude Opus 4.7 --- app/updater.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/updater.py b/app/updater.py index acd7ddd..7fb59b9 100644 --- a/app/updater.py +++ b/app/updater.py @@ -267,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) From c58f89106f0e58e71318c53c393528a44244a675 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:11:15 +0100 Subject: [PATCH 13/14] ci: drop raw PDFApps.exe from artifact upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows artifact bundle uploaded both the NSIS installer (PDFAppsSetup.exe) AND the raw PyInstaller binary (PDFApps.exe), then attached both to the public release with checksums. The raw exe embeds the runner's internal build paths in its PyInstaller debug strings — not a credential leak but free reconnaissance for anyone scraping the .exe for build-machine signatures. Users install via PDFAppsSetup.exe or the .msix; the in-app auto-updater (`_find_asset` in app/updater.py) already targets PDFAppsSetup.exe on Windows, so this is a no-op for the update flow. Drop PDFApps.exe from the three places it appeared in build.yml: matrix upload, sha256 checksum loop, and release files list. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 From 4c890fe3e1cbdc057f7b7d166b1a04a0be37323a Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 21 Jun 2026 15:13:14 +0100 Subject: [PATCH 14/14] test: add regression suite for PR-J audit cleanup (20 tests) One test per fix in the PR plus the i18n parity check. Where the fix has an observable runtime effect (image-cap guard, atomic save, config-driven max-recent, short-path helper) the test calls the helper directly with stubs/monkeypatch; where the effect is in CI / installer / GUI code, the test grep-asserts on the source file so future regressions are caught at lint time. Notably: - check_image_size: normal-image accept, gigapixel reject (PIL stub to avoid OOM), corrupt-file fallback - _atomic_save: success path replaces, failure leaves prior content untouched and cleans up the tempfile - _get_max_recent: default + config override + clamp tests - ruff F401/F541/E741 subprocess check guards future regressions Co-Authored-By: Claude Opus 4.7 --- tests/test_audit_pr_j.py | 313 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 tests/test_audit_pr_j.py 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"