diff --git a/app/base.py b/app/base.py index f6b509c..eab7241 100644 --- a/app/base.py +++ b/app/base.py @@ -364,7 +364,11 @@ def _open_reader(self, path: str): from pypdf import PdfReader r = PdfReader(path) if r.is_encrypted and self._pdf_password: - r.decrypt(self._nfc(self._pdf_password)) + # R11-M4: pypdf returns 0 on a wrong password and silently + # exposes a reader with zero accessible pages — every + # downstream tool then writes an empty PDF. Raise instead. + if r.decrypt(self._nfc(self._pdf_password)) == 0: + raise ValueError(t("tool.err.wrong_password")) return r def _open_fitz(self, path: str): diff --git a/app/editor/dialogs.py b/app/editor/dialogs.py index d5bec23..debce97 100644 --- a/app/editor/dialogs.py +++ b/app/editor/dialogs.py @@ -386,6 +386,15 @@ def __init__(self, parent=None): btns.addWidget(ca); btns.addWidget(ok) v.addLayout(btns) + # R11-M12: explicit tab order — without this, focus jumps + # erratically between tabs/buttons depending on widget add order. + # Path: tabs -> type input -> font combo -> save cb -> cancel -> ok. + self.setTabOrder(tabs, self._type_input) + self.setTabOrder(self._type_input, self._font_combo) + self.setTabOrder(self._font_combo, self._save_cb) + self.setTabOrder(self._save_cb, ca) + self.setTabOrder(ca, ok) + def _update_type_preview(self): text = self._type_input.text().strip() if not text: diff --git a/app/editor/tab.py b/app/editor/tab.py index 9a9f741..922c17c 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -652,8 +652,15 @@ def _load_pdf(self, p: str): self._update_nav() # Defer annotation/form loading so the UI stays responsive from PySide6.QtCore import QTimer + from shiboken6 import isValid QTimer.singleShot(100, self._load_existing_annotations) - QTimer.singleShot(200, lambda: self._load_form_fields(p)) + # R11-L3: guard the lambda closure — if the tab is closed during + # the 200 ms wait, the underlying QWidget may be deleted and + # calling self._load_form_fields raises RuntimeError. + QTimer.singleShot( + 200, + lambda: self._load_form_fields(p) if isValid(self) else None, + ) def _load_existing_annotations(self): """Load existing text annotations from the PDF as note overlays.""" @@ -779,7 +786,10 @@ def _load_form_fields(self, path): self._form_table.setUpdatesEnabled(False) _r = PdfReader(path) if _r.is_encrypted and self._pdf_password: - _r.decrypt(self._pdf_password) + # R11-M4: 0 == wrong password — surface to the user + # instead of silently parsing an empty PDF. + if _r.decrypt(self._pdf_password) == 0: + raise ValueError(t("tool.err.wrong_password")) fields = _r.get_fields() or {} for name, field in fields.items(): r = self._form_table.rowCount(); self._form_table.insertRow(r) @@ -1200,6 +1210,18 @@ def _run(self): doc = fitz.open(self._doc_path) if doc.needs_pass and self._pdf_password: doc.authenticate(self._pdf_password) + # R11-L4: warn once if any text/note edit uses chars that the + # PyMuPDF built-in Latin-1 fonts can't render. We still write + # the edit (PyMuPDF substitutes ?) — the warning just sets + # user expectations instead of letting them discover tofu + # after the save completes. + _non_latin = any( + e.get("type") in ("text", "note") + and any(ord(c) > 0xFF for c in (e.get("text") or "")) + for e in self._pending + ) + if _non_latin: + self._status(t("tool.warn.font_latin_only")) for e in self._pending: if e.get("_existing") and e.get("type") != "delete_annot": continue # already saved in the PDF @@ -1336,7 +1358,10 @@ def _apply_forms(self, out): _r = PdfReader(_src) was_encrypted = bool(_r.is_encrypted) if was_encrypted and self._pdf_password: - _r.decrypt(self._pdf_password) + # R11-M4: catch the wrong-password silent-fail path + # so we never write an empty PDF over the user's file. + if _r.decrypt(self._pdf_password) == 0: + raise ValueError(t("tool.err.wrong_password")) # If input was encrypted, ask the user how to save. encrypt_choice = "plaintext" if was_encrypted and self._pdf_password: diff --git a/app/styles.py b/app/styles.py index 942d76d..1e0ded2 100644 --- a/app/styles.py +++ b/app/styles.py @@ -16,6 +16,10 @@ QMenu {{ background: {BG_CARD}; color: {TEXT_PRI}; border: 1px solid {BORDER}; }} QMenu::item:selected {{ background: #22303A; }} QMessageBox {{ background: {BG_CARD}; color: {TEXT_PRI}; }} +/* R11-M10: themed tooltip — default Qt tooltips inherit OS chrome that + blends poorly with the dark palette (white-on-light-yellow). */ +QToolTip {{ background: {BG_CARD}; color: {TEXT_PRI}; + border: 1px solid {BORDER}; padding: 4px 6px; }} QScrollArea {{ background: transparent; border: none; }} QScrollArea > QWidget > QWidget {{ background: transparent; }} @@ -272,6 +276,9 @@ QMenu {{ background: {_LC}; color: {_LP}; border: 1px solid {_LO}; }} QMenu::item:selected {{ background: #E7F0ED; }} QMessageBox {{ background: {_LC}; color: {_LP}; }} +/* R11-M10: themed tooltip in light mode too. */ +QToolTip {{ background: {_LC}; color: {_LP}; + border: 1px solid {_LO}; padding: 4px 6px; }} QScrollArea {{ background: transparent; border: none; }} QScrollArea > QWidget > QWidget {{ background: transparent; }} diff --git a/app/tools/encrypt.py b/app/tools/encrypt.py index 9a8ffb1..1fdf83e 100644 --- a/app/tools/encrypt.py +++ b/app/tools/encrypt.py @@ -130,6 +130,10 @@ def _run(self): QMessageBox.warning(self, t("msg.warning"), t("msg.select_valid_pdf")); return out_path = self._resolve_output_file(self.drop_out, pdf_path) if not out_path: return + # R11-M2: only wipe password fields on a fully successful run. + # Previously the finally-clause cleared fields on every exit, + # forcing users to retype on wrong-password / mismatch errors. + success = False try: reader = self._open_reader(pdf_path) if self.cmb_mode.currentIndex() == 0: @@ -151,6 +155,7 @@ def _run(self): self._pipeline_success(msg, out_path) else: QMessageBox.information(self, t("msg.done"), msg) + success = True else: # _open_reader already decrypted with self._pdf_password (if any). # The edit_pwd field acts as a manual override — if non-empty, @@ -170,10 +175,11 @@ def _run(self): self._pipeline_success(msg, out_path) else: QMessageBox.information(self, t("msg.done"), msg) + success = True except Exception as e: show_error(self, e) finally: - # R8-H1: wipe password fields whether _run() succeeded or - # raised. Keeping the user's password in the QLineEdit text - # buffer for the whole session was a needless heap leak. - self._clear_password_fields() + # R11-M2: wipe only on success. Wrong-pwd / mismatch keep + # user input so they can correct and retry. + if success: + self._clear_password_fields() diff --git a/app/tools/import_pdf.py b/app/tools/import_pdf.py index 6ebd099..a1efe6f 100644 --- a/app/tools/import_pdf.py +++ b/app/tools/import_pdf.py @@ -174,7 +174,9 @@ def do_work(worker): est_lines = max(1, len(line) * fontsize * 0.5 / max_width + 1) y += line_height * est_lines try: - doc.save(out_path) + # R11-M8: atomic write — avoids truncating a pre-existing + # output file if the save crashes or the process is killed. + BasePage._atomic_pdf_write(doc, out_path) finally: doc.close() return out_path @@ -184,6 +186,13 @@ def do_work(worker): on_done=lambda _r: self._done(out_path)) def _convert_images(self, sources: list, out_path: str): + # R11-L1: filter to recognised image extensions up front. The + # _IMG_EXTS tuple was previously declared but unused (dead code); + # using it here catches obvious mistakes (user picked a .pdf or + # .txt in the multi-select dialog) before fitz.open raises an + # unhelpful error. + sources = [p for p in sources + if os.path.splitext(p)[1].lower() in _IMG_EXTS] n = len(sources) def do_work(worker): @@ -205,7 +214,9 @@ def do_work(worker): finally: img.close() worker.progress.emit(i + 1, f"{i + 1}/{n}…") - doc.save(out_path) + # R11-M8: atomic write — avoids truncating a pre-existing + # output file if the save crashes or the process is killed. + BasePage._atomic_pdf_write(doc, out_path) finally: doc.close() return skipped @@ -252,7 +263,9 @@ def do_work(worker): page.insert_text(fitz.Point(50, y), text, fontsize=size, fontname="helv") y += size * 1.5 - doc.save(out_path) + # R11-M8: atomic write — avoids truncating a pre-existing + # output file if the save crashes or the process is killed. + BasePage._atomic_pdf_write(doc, out_path) finally: doc.close() return out_path @@ -336,7 +349,9 @@ def do_work(worker): worker.progress.emit(i + 1, f"{i + 1}/{n}…") if worker.is_cancelled(): return None - doc.save(out_path) + # R11-M8: atomic write — avoids truncating a pre-existing + # output file if the save crashes or the process is killed. + BasePage._atomic_pdf_write(doc, out_path) finally: doc.close() return out_path @@ -403,7 +418,9 @@ def do_work(worker): f"{fi + 1}/{n}: {i + 1}/{total_slides}…") if worker.is_cancelled(): return None - doc.save(out_path) + # R11-M8: atomic write — avoids truncating a pre-existing + # output file if the save crashes or the process is killed. + BasePage._atomic_pdf_write(doc, out_path) finally: doc.close() return out_path @@ -445,7 +462,9 @@ def do_work(worker): worker.progress.emit(i + 1, f"{i + 1}/{n}…") if worker.is_cancelled(): return None - doc.save(out_path) + # R11-M8: atomic write — avoids truncating a pre-existing + # output file if the save crashes or the process is killed. + BasePage._atomic_pdf_write(doc, out_path) finally: doc.close() return out_path @@ -479,7 +498,9 @@ def do_work(worker): worker.progress.emit(i + 1, f"{i + 1}/{n}…") if worker.is_cancelled(): return None - doc.save(out_path) + # R11-M8: atomic write — avoids truncating a pre-existing + # output file if the save crashes or the process is killed. + BasePage._atomic_pdf_write(doc, out_path) finally: doc.close() return out_path @@ -553,7 +574,9 @@ def do_work(worker): worker.progress.emit(i + 1, f"{i + 1}/{n}…") if worker.is_cancelled(): return None - doc.save(out_path) + # R11-M8: atomic write — avoids truncating a pre-existing + # output file if the save crashes or the process is killed. + BasePage._atomic_pdf_write(doc, out_path) finally: doc.close() return out_path diff --git a/app/tools/merge.py b/app/tools/merge.py index 9ec0661..6356e17 100644 --- a/app/tools/merge.py +++ b/app/tools/merge.py @@ -120,7 +120,10 @@ def _run(self): if reader.is_encrypted: pwd = self._pwd_map.get(p, "") if pwd: - reader.decrypt(pwd) + # R11-M4: wrong password yields 0 pages, which + # would silently produce an incomplete merge. + if reader.decrypt(pwd) == 0: + raise ValueError(t("tool.err.wrong_password")) for page in reader.pages: w.add_page(page) self._atomic_pdf_write(w, out, sources=paths) diff --git a/app/tools/nup.py b/app/tools/nup.py index 1c8a942..c7155c0 100644 --- a/app/tools/nup.py +++ b/app/tools/nup.py @@ -220,7 +220,12 @@ def do_work(worker): current=src_idx + 1, total=total)) if worker.is_cancelled(): return None - out.save(out_path, garbage=4, deflate=True) + # R11-M8: atomic write — write to a sibling temp file + # and os.replace into place so a crash mid-save can't + # truncate a pre-existing output PDF. + BasePage._atomic_pdf_write( + out, out_path, sources=[pdf_path], + save_opts={"garbage": 4, "deflate": True}) finally: out.close() finally: diff --git a/app/tools/page_numbers.py b/app/tools/page_numbers.py index 825bb73..284365a 100644 --- a/app/tools/page_numbers.py +++ b/app/tools/page_numbers.py @@ -140,6 +140,12 @@ def _run(self): # ("Page {n}" → "Seite {n}"). Resolve via t() to a concrete # string before .format(). fmt_template = t(_FORMATS[self.cmb_format.currentIndex()][1]) + # R11-L4: ``helv`` is a Type-1 Latin-1 font; any char above + # U+0100 (CJK, Cyrillic, Arabic, Hebrew, accented Greek etc.) + # renders as a ? glyph. Surface a status-bar warning so the user + # is not surprised by tofu in the output PDF. + if any(ord(c) > 0xFF for c in fmt_template): + self._status(t("tool.warn.font_latin_only")) pos_code = _POSITIONS[self.cmb_position.currentIndex()][1] font_size = self.spin_size.value() start_page = self.spin_start_page.value() - 1 # 0-indexed @@ -202,12 +208,16 @@ def _run(self): replace = False if existing: + # R11-M11: default No — replacing existing page numbers is + # destructive (no undo once saveIncr is called). Stray Enter + # should preserve, not overwrite. ans = QMessageBox.question( self, t("msg.warning"), t("tool.page_numbers.existing_found", n=len(existing)), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.No, ) if ans == QMessageBox.StandardButton.Cancel: return diff --git a/app/tools/split.py b/app/tools/split.py index 890d237..b90f2af 100644 --- a/app/tools/split.py +++ b/app/tools/split.py @@ -82,8 +82,28 @@ def _load_input(self, p: str): try: r = self._open_reader(p); self._total = len(r.pages) self.lbl_info.setText(t("tool.split.pages_info", n=self._total)) + # R11-M3: clamp any pre-existing rows so their endpoint stays + # within the new PDF's page count. Without this, swapping a + # 50-page PDF for a 10-page one leaves rows showing 1-50 and + # the row_invalid error fires on Apply. + self._clamp_rows_to_total() except Exception as e: self.lbl_info.setText(t("tool.split.error_info", e=e)) + def _clamp_rows_to_total(self) -> None: + """Ensure row start/end spinboxes are within ``self._total``.""" + total = max(1, int(self._total or 1)) + for r in range(self.table.rowCount()): + spn_s = self.table.cellWidget(r, 0) + spn_e = self.table.cellWidget(r, 1) + if spn_s is None or spn_e is None: + continue + if spn_s.value() > total: + spn_s.setValue(total) + if spn_e.value() > total: + spn_e.setValue(total) + if spn_e.value() < spn_s.value(): + spn_e.setValue(spn_s.value()) + def auto_load(self, path: str): if path and not self.drop_in.path(): self._load_input(path) diff --git a/app/tools/watermark.py b/app/tools/watermark.py index 2e970a0..18abed4 100644 --- a/app/tools/watermark.py +++ b/app/tools/watermark.py @@ -124,7 +124,11 @@ def _run(self): try: wm_reader = PdfReader(wm_path) if wm_reader.is_encrypted and wm_pwd: - wm_reader.decrypt(wm_pwd) + # R11-M4: wrong password yields a reader with zero + # accessible pages — caught below, but a clearer error + # helps users distinguish "wrong pwd" from "empty WM". + if wm_reader.decrypt(wm_pwd) == 0: + raise ValueError(t("tool.err.wrong_password")) if not wm_reader.pages: QMessageBox.warning(self, t("msg.warning"), t("tool.watermark.empty_wm")) return @@ -145,10 +149,14 @@ def _run(self): def do_work(worker): r = PdfReader(pdf_path) if r.is_encrypted and pwd: - r.decrypt(pwd) + # R11-M4: same guard as pre-flight; defence in depth in + # case the password gets cleared between checks. + if r.decrypt(pwd) == 0: + raise ValueError(t("tool.err.wrong_password")) wm = PdfReader(wm_path) if wm.is_encrypted and wm_pwd: - wm.decrypt(wm_pwd) + if wm.decrypt(wm_pwd) == 0: + raise ValueError(t("tool.err.wrong_password")) wm_page = wm.pages[0] w = PdfWriter() n = len(r.pages) diff --git a/app/translations.json b/app/translations.json index d16fd06..03d106e 100644 --- a/app/translations.json +++ b/app/translations.json @@ -265,6 +265,8 @@ "tool.compress.done": "{before} KB → {after} KB (−{pct}%)", "tool.compress.no_gain": "Could not reduce the file size.\n\n{e}\n\nThe output file was not saved.", "tool.compress.gs_hint": "💡 Install Ghostscript for even better compression results.\nhttps://ghostscript.com", + "tool.compress.deps_missing": "Install pypdf and/or PyMuPDF to compress PDFs.", + "tool.compress.no_gain_detail": "Compression produced no size reduction ({before} KB to {after} KB).", "tool.encrypt.name": "Encrypt / Decrypt", "tool.encrypt.desc": "Protect the PDF with a password or remove existing protection.", "tool.encrypt.btn": "Execute", @@ -598,7 +600,10 @@ "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.", - "edit.label.note_delete": "Delete note" + "edit.label.note_delete": "Delete note", + "tool.err.wrong_password": "Incorrect password — could not decrypt the PDF.", + "tool.warn.font_latin_only": "Non-Latin characters may not render correctly with the default font.", + "viewer.delete_no_match": "No matching comment found in the PDF — nothing to delete." }, "pt": { "app.name": "PDFApps", @@ -866,6 +871,8 @@ "tool.compress.done": "{before} KB → {after} KB (−{pct}%)", "tool.compress.no_gain": "Não foi possível reduzir o tamanho.\n\n{e}\n\nO ficheiro de saída não foi guardado.", "tool.compress.gs_hint": "💡 Instale o Ghostscript para resultados de compressão ainda melhores.\nhttps://ghostscript.com", + "tool.compress.deps_missing": "Instala pypdf e/ou PyMuPDF para comprimir PDFs.", + "tool.compress.no_gain_detail": "A compressão não reduziu o tamanho ({before} KB para {after} KB).", "tool.encrypt.name": "Encriptar / Desencriptar", "tool.encrypt.desc": "Protege o PDF com senha ou remove a proteção existente.", "tool.encrypt.btn": "Executar", @@ -1199,7 +1206,10 @@ "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.", - "edit.label.note_delete": "Eliminar nota" + "edit.label.note_delete": "Eliminar nota", + "tool.err.wrong_password": "Palavra-passe incorreta — não foi possível desencriptar o PDF.", + "tool.warn.font_latin_only": "Caracteres não-latinos podem não ser apresentados corretamente com o tipo de letra predefinido.", + "viewer.delete_no_match": "Nenhum comentário correspondente encontrado no PDF — nada para eliminar." }, "es": { "app.name": "PDFApps", @@ -1467,6 +1477,8 @@ "tool.compress.done": "{before} KB → {after} KB (−{pct}%)", "tool.compress.no_gain": "No se pudo reducir el tamaño.\n\n{e}\n\nEl archivo de salida no fue guardado.", "tool.compress.gs_hint": "💡 Instale Ghostscript para obtener mejores resultados de compresión.\nhttps://ghostscript.com", + "tool.compress.deps_missing": "Instala pypdf y/o PyMuPDF para comprimir PDFs.", + "tool.compress.no_gain_detail": "La compresión no redujo el tamaño ({before} KB a {after} KB).", "tool.encrypt.name": "Encriptar / Desencriptar", "tool.encrypt.desc": "Protege el PDF con contraseña o elimina la protección existente.", "tool.encrypt.btn": "Ejecutar", @@ -1800,7 +1812,10 @@ "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.", - "edit.label.note_delete": "Eliminar nota" + "edit.label.note_delete": "Eliminar nota", + "tool.err.wrong_password": "Contraseña incorrecta — no se pudo desencriptar el PDF.", + "tool.warn.font_latin_only": "Los caracteres no latinos pueden no mostrarse correctamente con la fuente predeterminada.", + "viewer.delete_no_match": "No se encontró ningún comentario coincidente en el PDF — nada que eliminar." }, "fr": { "app.name": "PDFApps", @@ -2068,6 +2083,8 @@ "tool.compress.done": "{before} Ko → {after} Ko (−{pct}%)", "tool.compress.no_gain": "Impossible de réduire la taille.\n\n{e}\n\nLe fichier de sortie n'a pas été enregistré.", "tool.compress.gs_hint": "💡 Installez Ghostscript pour de meilleurs résultats de compression.\nhttps://ghostscript.com", + "tool.compress.deps_missing": "Installez pypdf et/ou PyMuPDF pour compresser les PDF.", + "tool.compress.no_gain_detail": "La compression n'a pas réduit la taille ({before} Ko à {after} Ko).", "tool.encrypt.name": "Chiffrer / Déchiffrer", "tool.encrypt.desc": "Protège le PDF par mot de passe ou supprime la protection existante.", "tool.encrypt.btn": "Exécuter", @@ -2401,7 +2418,10 @@ "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.", - "edit.label.note_delete": "Supprimer la note" + "edit.label.note_delete": "Supprimer la note", + "tool.err.wrong_password": "Mot de passe incorrect — impossible de déchiffrer le PDF.", + "tool.warn.font_latin_only": "Les caractères non latins peuvent ne pas s'afficher correctement avec la police par défaut.", + "viewer.delete_no_match": "Aucun commentaire correspondant trouvé dans le PDF — rien à supprimer." }, "de": { "app.name": "PDFApps", @@ -2669,6 +2689,8 @@ "tool.compress.done": "{before} KB → {after} KB (−{pct}%)", "tool.compress.no_gain": "Die Dateigröße konnte nicht reduziert werden.\n\n{e}\n\nDie Ausgabedatei wurde nicht gespeichert.", "tool.compress.gs_hint": "💡 Installieren Sie Ghostscript für noch bessere Komprimierungsergebnisse.\nhttps://ghostscript.com", + "tool.compress.deps_missing": "Installieren Sie pypdf und/oder PyMuPDF, um PDFs zu komprimieren.", + "tool.compress.no_gain_detail": "Die Komprimierung hat die Größe nicht reduziert ({before} KB zu {after} KB).", "tool.encrypt.name": "Verschlüsseln / Entschlüsseln", "tool.encrypt.desc": "Schützt das PDF mit Passwort oder entfernt den vorhandenen Schutz.", "tool.encrypt.btn": "Ausführen", @@ -3002,7 +3024,10 @@ "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.", - "edit.label.note_delete": "Notiz löschen" + "edit.label.note_delete": "Notiz löschen", + "tool.err.wrong_password": "Falsches Passwort — PDF konnte nicht entschlüsselt werden.", + "tool.warn.font_latin_only": "Nicht-lateinische Zeichen werden mit der Standardschriftart möglicherweise nicht korrekt angezeigt.", + "viewer.delete_no_match": "Kein passender Kommentar im PDF gefunden — nichts zu löschen." }, "zh": { "app.name": "PDFApps", @@ -3270,6 +3295,8 @@ "tool.compress.done": "{before} KB → {after} KB (−{pct}%)", "tool.compress.no_gain": "无法减小文件大小。\n\n{e}\n\n输出文件未保存。", "tool.compress.gs_hint": "💡 安装 Ghostscript 可获得更好的压缩效果。\nhttps://ghostscript.com", + "tool.compress.deps_missing": "请安装 pypdf 和/或 PyMuPDF 以压缩 PDF。", + "tool.compress.no_gain_detail": "压缩未能减小文件大小({before} KB 到 {after} KB)。", "tool.encrypt.name": "加密 / 解密", "tool.encrypt.desc": "使用密码保护 PDF 或移除现有保护。", "tool.encrypt.btn": "执行", @@ -3603,7 +3630,10 @@ "editor.signature.empty_draw": "请在点击确定前绘制您的签名。", "editor.signature.empty_type": "请在点击确定前输入您的姓名。", "editor.signature.empty_import": "请在点击确定前选择一张图像。", - "edit.label.note_delete": "删除便笺" + "edit.label.note_delete": "删除便笺", + "tool.err.wrong_password": "密码不正确 — 无法解密 PDF。", + "tool.warn.font_latin_only": "默认字体可能无法正确显示非拉丁字符。", + "viewer.delete_no_match": "在 PDF 中未找到匹配的批注 — 无内容可删除。" }, "it": { "app.name": "PDFApps", @@ -3871,6 +3901,8 @@ "tool.compress.done": "{before} KB → {after} KB (−{pct}%)", "tool.compress.no_gain": "Impossibile ridurre le dimensioni del file.\n\n{e}\n\nIl file di output non è stato salvato.", "tool.compress.gs_hint": "💡 Installa Ghostscript per risultati di compressione ancora migliori.\nhttps://ghostscript.com", + "tool.compress.deps_missing": "Installa pypdf e/o PyMuPDF per comprimere i PDF.", + "tool.compress.no_gain_detail": "La compressione non ha ridotto le dimensioni ({before} KB a {after} KB).", "tool.encrypt.name": "Crittografa / Decifra", "tool.encrypt.desc": "Proteggi il PDF con una password o rimuovi la protezione esistente.", "tool.encrypt.btn": "Esegui", @@ -4204,7 +4236,10 @@ "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.", - "edit.label.note_delete": "Elimina nota" + "edit.label.note_delete": "Elimina nota", + "tool.err.wrong_password": "Password errata — impossibile decifrare il PDF.", + "tool.warn.font_latin_only": "I caratteri non latini potrebbero non essere visualizzati correttamente con il font predefinito.", + "viewer.delete_no_match": "Nessun commento corrispondente trovato nel PDF — niente da eliminare." }, "nl": { "app.name": "PDFApps", @@ -4472,6 +4507,8 @@ "tool.compress.done": "{before} KB → {after} KB (−{pct}%)", "tool.compress.no_gain": "Kon de bestandsgrootte niet verkleinen.\n\n{e}\n\nHet uitvoerbestand is niet opgeslagen.", "tool.compress.gs_hint": "💡 Installeer Ghostscript voor nog betere compressieresultaten.\nhttps://ghostscript.com", + "tool.compress.deps_missing": "Installeer pypdf en/of PyMuPDF om PDF's te comprimeren.", + "tool.compress.no_gain_detail": "Compressie heeft de grootte niet verkleind ({before} KB naar {after} KB).", "tool.encrypt.name": "Versleutelen / Ontsleutelen", "tool.encrypt.desc": "Beveilig de PDF met een wachtwoord of verwijder bestaande beveiliging.", "tool.encrypt.btn": "Uitvoeren", @@ -4805,6 +4842,9 @@ "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.", - "edit.label.note_delete": "Notitie verwijderen" + "edit.label.note_delete": "Notitie verwijderen", + "tool.err.wrong_password": "Onjuist wachtwoord — kon de PDF niet ontsleutelen.", + "tool.warn.font_latin_only": "Niet-Latijnse tekens worden mogelijk niet correct weergegeven met het standaardlettertype.", + "viewer.delete_no_match": "Geen overeenkomende reactie gevonden in het PDF — niets om te verwijderen." } -} +} \ No newline at end of file diff --git a/app/utils.py b/app/utils.py index 33aaeab..ecabfdf 100644 --- a/app/utils.py +++ b/app/utils.py @@ -621,8 +621,7 @@ def _prog(stage, cur=0, tot=0): os.unlink(p) if not temps: - raise RuntimeError("Install pypdf and/or PyMuPDF:\n" - " pip install pypdf pymupdf pillow") + raise RuntimeError(t("tool.compress.deps_missing")) # ── Choose the best result ────────────────────────────────────────── best = min(temps, key=lambda p: os.path.getsize(p)) @@ -636,7 +635,9 @@ def _prog(stage, cur=0, tot=0): if best_size >= before: with contextlib.suppress(Exception): os.unlink(best) - raise ValueError(f"No gain: {before/1024:.0f} KB → {best_size/1024:.0f} KB") + raise ValueError(t("tool.compress.no_gain_detail", + before=f"{before/1024:.0f}", + after=f"{best_size/1024:.0f}")) # Atomic write: rename within the same volume, else copy to a temp # file next to dst and atomic-rename. shutil.move falls back to a diff --git a/app/viewer/canvas.py b/app/viewer/canvas.py index cf3a5f0..a93a960 100644 --- a/app/viewer/canvas.py +++ b/app/viewer/canvas.py @@ -676,6 +676,20 @@ def contextMenuEvent(self, e): break if target_annot is not None: page.delete_annot(target_annot) + else: + # R11-L8: no annotation matched — there's + # nothing to persist. Skip saveIncr() so + # we don't bump the PDF's incremental + # update offset (and hash) for a no-op, + # and tell the user the comment is gone. + if backup_path: + with contextlib.suppress(Exception): + os.unlink(backup_path) + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning( + self, t("msg.warning"), + t("viewer.delete_no_match")) + return except Exception as exc: if backup_path: with contextlib.suppress(Exception): diff --git a/app/window.py b/app/window.py index 36e5027..642e532 100644 --- a/app/window.py +++ b/app/window.py @@ -3,8 +3,9 @@ import contextlib import os -from PySide6.QtCore import Qt, QSize, Signal +from PySide6.QtCore import Qt, QSize, Signal, QTimer from PySide6.QtGui import QIcon +from shiboken6 import isValid from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QListWidget, QListWidgetItem, @@ -268,7 +269,14 @@ def _a11y(btn, tip): self._update_release = None self._update_thread = None self._update_worker = None - self._check_for_updates_async() + # R11-M7: defer the update check 2s past __init__ so the main + # window can finish painting + showMaximized before any network + # I/O races the UI. Previously fired before the window was even + # visible, occasionally stalling first paint on slow networks. + QTimer.singleShot( + 2000, + lambda: self._check_for_updates_async() if isValid(self) else None, + ) root_v.addWidget(self._workspace_bar) @@ -510,10 +518,17 @@ def _a11y(btn, tip): QShortcut(QKeySequence("F11"), self, self._toggle_fullscreen) QShortcut(QKeySequence("Ctrl+O"), self, self._open_pdf) QShortcut(QKeySequence("Ctrl+P"), self, lambda: self._viewer._print_pdf()) - QShortcut(QKeySequence("Ctrl+W"), self, self._close_current_tab) - QShortcut(QKeySequence("Ctrl+S"), self, self._save_current_tool) - QShortcut(QKeySequence("PgUp"), self, self._goto_prev_page) - QShortcut(QKeySequence("PgDown"), self, self._goto_next_page) + # R11-M9: scope shortcuts that could otherwise be triggered while + # editing a QTextEdit / QLineEdit. PgUp/PgDown previously paged + # the viewer behind any open editor; Ctrl+S/Ctrl+W could fire + # mid-typing in a tool input. WidgetWithChildrenShortcut routes + # the key through the focused widget first. + sc_close = QShortcut(QKeySequence("Ctrl+W"), self, self._close_current_tab) + sc_save = QShortcut(QKeySequence("Ctrl+S"), self, self._save_current_tool) + sc_pgup = QShortcut(QKeySequence("PgUp"), self, self._goto_prev_page) + sc_pgdn = QShortcut(QKeySequence("PgDown"), self, self._goto_next_page) + for sc in (sc_close, sc_save, sc_pgup, sc_pgdn): + sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Quick tool shortcuts: Ctrl+1..9 for tools 1-9, # Ctrl+Shift+1..6 for tools 10-15. Tools beyond idx=14 have no # dedicated shortcut — guard against silent overflow. @@ -592,11 +607,14 @@ def _on_tab_changed(self, idx: int): def _close_tab(self, idx: int): viewer = self._viewers[idx] if idx < len(self._viewers) else self._viewers[0] if self._viewer_has_unsaved(viewer): + # R11-M11: default to Cancel — Discard is destructive and + # an accidental Enter should not throw away pipeline work. ans = QMessageBox.question( self, t("msg.warning"), t("pipeline.unsaved_prompt"), QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel) + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel) if ans == QMessageBox.StandardButton.Cancel: return if ans == QMessageBox.StandardButton.Save: @@ -1118,6 +1136,7 @@ def _on_pipeline_done(self, temp_path: str): def _save_pipeline(self): """Save the current pipeline result to a user-chosen file.""" import shutil + import tempfile vid = id(self._viewer) ps = self._pipeline_state.get(vid) if not ps or not ps.get("temp_path"): @@ -1129,7 +1148,36 @@ def _save_pipeline(self): self, t("btn.choose"), suggested, t("file_filter.pdf")) if not path: return - shutil.copy2(ps["temp_path"], path) + # R11-M6: warn if destination is a symlink. A pre-placed symlink + # could redirect the write to an unintended location; we still + # honour the user's chosen path (os.replace follows symlinks), + # but logging gives an audit trail. + try: + if os.path.lexists(path) and os.path.realpath(path) != os.path.abspath(path): + import logging as _logging + _logging.getLogger("pdfapps").warning( + "Pipeline save destination is a symlink: %s -> %s", + path, os.path.realpath(path)) + except Exception: + pass + # R11-M5: replace shutil.copy2 (non-atomic — a crash or power + # loss mid-copy would truncate the destination). Copy to a sibling + # temp file then os.replace it into place. Falls back to direct + # copy when the dst dir is unwritable (best effort). + try: + dst_dir = os.path.dirname(path) or "." + fd, tmp = tempfile.mkstemp(suffix=".pdf", dir=dst_dir) + os.close(fd) + try: + shutil.copyfile(ps["temp_path"], tmp) + os.replace(tmp, path) + except Exception: + with contextlib.suppress(Exception): + os.unlink(tmp) + raise + except OSError: + # Last-resort fallback if mkstemp can't write next to dst. + shutil.copy2(ps["temp_path"], path) # Load the saved file in the viewer (replaces temp) self._viewer.load(path) idx = self._viewer_stack.currentIndex() @@ -1206,11 +1254,14 @@ def closeEvent(self, event): # Check for unsaved pipeline changes for v in self._viewers: if self._viewer_has_unsaved(v): + # R11-M11: default Cancel — Enter on close shouldn't + # silently discard unsaved pipeline output. ans = QMessageBox.question( self, t("msg.warning"), t("pipeline.unsaved_prompt"), QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel) + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel) if ans == QMessageBox.StandardButton.Cancel: event.ignore(); return if ans == QMessageBox.StandardButton.Save: @@ -1224,10 +1275,12 @@ def closeEvent(self, event): # canvas rendering (loading a PDF with notes should not look # like "unsaved edits"). if edit_w and getattr(edit_w, "_user_pending", None): + # R11-M11: default Cancel for the editor unsaved-edits prompt. ans = QMessageBox.question( self, t("msg.warning"), t("pipeline.unsaved_prompt"), QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel) + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel) if ans == QMessageBox.StandardButton.Cancel: event.ignore(); return # Cleanup all pipeline temp files diff --git a/pdfapps.spec b/pdfapps.spec index 885a1f0..d46af77 100644 --- a/pdfapps.spec +++ b/pdfapps.spec @@ -1,7 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- -import importlib, os, re +import importlib, os, re, sys as _sys _qa = os.path.dirname(importlib.import_module('qtawesome').__file__) # Read APP_VERSION from app/constants.py so the macOS bundle stays in sync. @@ -9,6 +9,41 @@ with open('app/constants.py', encoding='utf-8') as _f: _m = re.search(r'APP_VERSION\s*=\s*"([^"]+)"', _f.read()) _app_version = _m.group(1) if _m else '0.0.0' +# R11-L5: regenerate version_info.txt with APP_VERSION on each build so +# the Windows PE header stays in sync. PyInstaller picks it up via the +# ``version=`` kwarg on EXE(). Helps mitigate AV false positives that +# flag PyInstaller binaries lacking a version-info resource. +_vparts = _app_version.split('.') +while len(_vparts) < 4: + _vparts.append('0') +_vtuple = ', '.join(_vparts[:4]) +with open('version_info.txt', 'w', encoding='utf-8') as _vf: + _vf.write( + "VSVersionInfo(\n" + " ffi=FixedFileInfo(\n" + f" filevers=({_vtuple}),\n" + f" prodvers=({_vtuple}),\n" + " mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1,\n" + " subtype=0x0, date=(0, 0),\n" + " ),\n" + " kids=[\n" + " StringFileInfo([\n" + " StringTable('040904B0', [\n" + " StringStruct('CompanyName', 'PDFApps'),\n" + " StringStruct('FileDescription', 'PDFApps - PDF Editor'),\n" + f" StringStruct('FileVersion', '{_app_version}'),\n" + " StringStruct('InternalName', 'PDFApps'),\n" + " StringStruct('LegalCopyright', 'Copyright (c) 2025-2026 PDFApps'),\n" + " StringStruct('OriginalFilename', 'PDFApps.exe'),\n" + " StringStruct('ProductName', 'PDFApps'),\n" + f" StringStruct('ProductVersion', '{_app_version}'),\n" + " ])\n" + " ]),\n" + " VarFileInfo([VarStruct('Translation', [1033, 1200])])\n" + " ]\n" + ")\n" + ) + a = Analysis( ['pdfapps.py'], pathex=[], @@ -49,10 +84,11 @@ exe = EXE( codesign_identity=None, entitlements_file=None, icon=['icon.ico'], + # R11-L5: stamp PE version-info on Windows builds (no-op elsewhere). + version='version_info.txt' if _sys.platform == 'win32' else None, ) # macOS .app bundle (ignored on other platforms) -import sys as _sys if _sys.platform == 'darwin': _icon = 'icon.icns' if os.path.isfile('icon.icns') else 'icon.ico' app = BUNDLE( diff --git a/tests/test_audit_mediums_lows.py b/tests/test_audit_mediums_lows.py new file mode 100644 index 0000000..c84460e --- /dev/null +++ b/tests/test_audit_mediums_lows.py @@ -0,0 +1,271 @@ +"""Source-level + behavioral regression tests for PR-I (Audit MEDIUMs + LOWs). + +Bug map (PR-I worklist — Rounds 5-11 leftovers): + #M1 _compress_pdf raw English errors (R11 A1) + #M2 encrypt._run wipes password on wrong-pwd retry (R11 A9) + #M3 split.py endpoint defaults stale across PDF changes (R11 A6) + #M4 reader.decrypt return ignored across tools (R5) + #M5 pipeline save uses non-atomic shutil.copy2 (R5 J4) + #M6 symlink check on save destination (R5 J3) + #M7 update check fires inside __init__ (R5 F4) + #M8 remaining tools without _atomic_pdf_write (R6 F1) + #M9 QShortcuts lack WidgetWithChildrenShortcut (R7 L1) + #M10 QToolTip has no themed style (R6 N1) + #M11 QMessageBox.question lacks defaultButton (R6 I1) + #M12 SetTabOrder missing on multi-widget dialogs (R7 H1/H3) + #L1 _IMG_EXTS dead in import_pdf.py (R5/R6 C9) + #L4 Latin-1 font warning for non-Latin text (R7/R8/R11 M3) + #L5 PE header version-info missing (R8 F1) + #L8 viewer note delete no-match still saveIncr (R11) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +# ── #M1 — _compress_pdf errors translated ──────────────────────────────── + + +def test_compress_deps_missing_translated(): + src = _read("app/utils.py") + assert 'RuntimeError("Install pypdf and/or PyMuPDF' not in src, ( + "Raw English RuntimeError should be replaced by t() lookup." + ) + assert 't("tool.compress.deps_missing")' in src + + +def test_compress_no_gain_uses_translated_key(): + src = _read("app/utils.py") + assert 'raise ValueError(f"No gain:' not in src, ( + "Raw English ValueError should be replaced by t() lookup." + ) + assert 'tool.compress.no_gain_detail' in src + + +def test_new_i18n_keys_parity(): + """All 8 languages must define the new keys added by PR-I.""" + with open(ROOT / "app/translations.json", encoding="utf-8") as f: + d = json.load(f) + new_keys = { + "tool.compress.deps_missing", + "tool.compress.no_gain_detail", + "tool.err.wrong_password", + "tool.warn.font_latin_only", + "viewer.delete_no_match", + } + for lang, table in d.items(): + missing = new_keys - set(table.keys()) + assert not missing, f"{lang} missing keys: {missing}" + + +# ── #M2 — encrypt._run no wipe on failure ─────────────────────────────── + + +def test_encrypt_run_only_clears_on_success(): + src = _read("app/tools/encrypt.py") + # The old unconditional finally-clear is gone. + assert "success = False" in src + # The clear is now guarded by `if success`. + assert "if success:" in src + assert "self._clear_password_fields()" in src + + +# ── #M3 — split clamp endpoints across PDF changes ────────────────────── + + +def test_split_clamps_rows_to_total(): + src = _read("app/tools/split.py") + assert "_clamp_rows_to_total" in src, ( + "Switching PDFs must clamp pre-existing row endpoints to the " + "new total." + ) + + +# ── #M4 — decrypt return value checked ────────────────────────────────── + + +@pytest.mark.parametrize("path", [ + "app/base.py", + "app/editor/tab.py", + "app/tools/encrypt.py", + "app/tools/watermark.py", + "app/tools/merge.py", +]) +def test_decrypt_return_checked(path): + src = _read(path) + # The new pattern is "if X.decrypt(Y) == 0" or equivalent — either + # a direct comparison or storing in `result` and checking result == 0. + assert (".decrypt(" in src), f"{path} has no decrypt call" + has_check = ("decrypt(" in src and ( + "== 0" in src or "result == 0" in src + )) + assert has_check, ( + f"{path} must validate decrypt() return value (0 = wrong pwd)." + ) + + +# ── #M5 / #M6 — pipeline save atomicity + symlink check ───────────────── + + +def test_pipeline_save_uses_os_replace(): + src = _read("app/window.py") + # The save_pipeline body must use mkstemp + os.replace, not just + # shutil.copy2. shutil.copy2 may still be present as a fallback. + save_block = src.split("def _save_pipeline")[1].split("def _cleanup_pipeline")[0] + assert "os.replace" in save_block, ( + "_save_pipeline must use os.replace for atomicity." + ) + assert "mkstemp" in save_block + + +def test_pipeline_save_logs_symlink_destination(): + src = _read("app/window.py") + save_block = src.split("def _save_pipeline")[1].split("def _cleanup_pipeline")[0] + assert "realpath" in save_block, ( + "_save_pipeline must detect symlinks at the destination." + ) + + +# ── #M7 — update check deferred ───────────────────────────────────────── + + +def test_update_check_deferred_post_init(): + src = _read("app/window.py") + # The bare self._check_for_updates_async() call inside __init__ is + # replaced by a QTimer.singleShot. + init_block = src.split("self._update_release = None")[1].split("# ── Viewer property")[0] + assert "QTimer.singleShot" in init_block, ( + "Update check must be deferred via QTimer.singleShot from __init__." + ) + # And guarded with isValid so a quick close won't crash. + assert "isValid(self)" in init_block + + +# ── #M8 — remaining tools use _atomic_pdf_write ───────────────────────── + + +def test_nup_uses_atomic_pdf_write(): + src = _read("app/tools/nup.py") + assert "_atomic_pdf_write" in src + # The raw direct save was gated by the same-source check — verify the + # only doc.save call left is via the helper, not the bare path call. + assert "out.save(out_path" not in src + + +def test_import_pdf_uses_atomic_pdf_write(): + src = _read("app/tools/import_pdf.py") + assert "_atomic_pdf_write" in src + # The bare 'doc.save(out_path)' calls (8 of them) must all be gone. + assert "doc.save(out_path)" not in src + + +# ── #M9 — shortcuts scoped to widget tree ─────────────────────────────── + + +def test_shortcuts_use_widget_with_children_context(): + src = _read("app/window.py") + assert "WidgetWithChildrenShortcut" in src, ( + "Critical shortcuts (PgUp/PgDown/Ctrl+S/Ctrl+W) must be scoped " + "via setContext(WidgetWithChildrenShortcut)." + ) + + +# ── #M10 — QToolTip is themed ─────────────────────────────────────────── + + +def test_qtooltip_themed_both_modes(): + src = _read("app/styles.py") + # Two style strings — one dark (STYLE), one light (STYLE_LIGHT). + # QToolTip rule must appear in both. + assert src.count("QToolTip") >= 2, ( + "QToolTip must be styled in both STYLE and STYLE_LIGHT." + ) + + +# ── #M11 — destructive QMessageBox.question gains defaultButton=No ────── + + +def test_pipeline_unsaved_prompts_default_to_cancel(): + src = _read("app/window.py") + # The two unsaved-pipeline prompts must end with `Cancel)` as the + # default-button kwarg. The presence of the closing pattern is enough + # to assert the fix without parsing AST. + assert "QMessageBox.StandardButton.Cancel)" in src + # Both call sites use Cancel as default. Heuristic: count. + assert src.count("QMessageBox.StandardButton.Cancel)") >= 2 + + +def test_page_numbers_replace_prompt_defaults_to_no(): + src = _read("app/tools/page_numbers.py") + # The "replace existing page numbers" prompt must default to No. + pn_block = src.split("if existing:")[1].split("if ans == QMessageBox.StandardButton.Cancel")[0] + assert "QMessageBox.StandardButton.No," in pn_block + + +# ── #M12 — setTabOrder explicitly defined ─────────────────────────────── + + +def test_signature_dialog_sets_tab_order(): + src = _read("app/editor/dialogs.py") + sig_block = src.split("class _SignatureDialog")[1] + assert "setTabOrder" in sig_block + + +# ── #L1 — _IMG_EXTS used as filter ────────────────────────────────────── + + +def test_import_pdf_uses_img_exts(): + src = _read("app/tools/import_pdf.py") + # _IMG_EXTS must now be referenced inside the convert path. + assert "_IMG_EXTS" in src + # At least 2 occurrences: the definition + a usage. + assert src.count("_IMG_EXTS") >= 2, ( + "_IMG_EXTS must be referenced (not just defined)." + ) + + +# ── #L4 — non-Latin warn ──────────────────────────────────────────────── + + +def test_page_numbers_warns_non_latin(): + src = _read("app/tools/page_numbers.py") + assert "tool.warn.font_latin_only" in src + + +def test_editor_apply_warns_non_latin(): + src = _read("app/editor/tab.py") + assert "tool.warn.font_latin_only" in src + + +# ── #L5 — PE version-info ─────────────────────────────────────────────── + + +def test_pdfapps_spec_has_version_info(): + src = _read("pdfapps.spec") + assert "version_info.txt" in src + assert "version=" in src + + +def test_version_info_file_exists(): + assert (ROOT / "version_info.txt").exists() + + +# ── #L8 — viewer delete no-match no-op ────────────────────────────────── + + +def test_viewer_delete_no_match_skips_save(): + src = _read("app/viewer/canvas.py") + # When the search loop exits with target_annot is None, the code + # path must show a warning and return BEFORE the saveIncr call. + # We check by searching for the key phrase + return in the block. + assert "viewer.delete_no_match" in src diff --git a/version_info.txt b/version_info.txt new file mode 100644 index 0000000..3a61ead --- /dev/null +++ b/version_info.txt @@ -0,0 +1,40 @@ +# UTF-8 +# +# R11-L5: Windows PE version-info resource. PyInstaller stamps these +# fields into the .exe header; adding a CompanyName / ProductName / +# OriginalFilename triple helps Bitdefender + similar heuristic AVs +# distinguish a legitimate signed-style executable from a randomly +# generated PyInstaller blob. The numeric tuple stays in sync with +# app.constants.APP_VERSION via the pdfapps.spec hook. +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 13, 15, 0), + prodvers=(1, 13, 15, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904B0', + [ + StringStruct('CompanyName', 'PDFApps'), + StringStruct('FileDescription', 'PDFApps - PDF Editor'), + StringStruct('FileVersion', '1.13.15'), + StringStruct('InternalName', 'PDFApps'), + StringStruct('LegalCopyright', 'Copyright (c) 2025-2026 PDFApps'), + StringStruct('OriginalFilename', 'PDFApps.exe'), + StringStruct('ProductName', 'PDFApps'), + StringStruct('ProductVersion', '1.13.15'), + ] + ) + ] + ), + VarFileInfo([VarStruct('Translation', [1033, 1200])]) + ] +)