From 533af37721eba2ea5986bc06b14180c6bd44095b Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 19:58:42 +0100 Subject: [PATCH 01/11] fix(editor): add i18n keys for Round 9 audit fixes Twelve new keys added across all 8 shipping locales for the encryption preservation prompt, forms / pending warnings, signature dialog empty validation, note-deletion label, and form-load failure feedback. Parity preserved at 8 locales x 595 keys. Keys: editor.encrypt.warning_*, editor.encrypt.save_*, editor.forms.has_pending, editor.forms.undo_unavailable, editor.forms.load_failed, editor.forms.no_fields, editor.signature.empty_{draw,type,import}, edit.label.note_delete. Co-Authored-By: Claude Opus 4.7 --- app/translations.json | 114 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/app/translations.json b/app/translations.json index 7bd9a9b..5cd06d2 100644 --- a/app/translations.json +++ b/app/translations.json @@ -582,7 +582,19 @@ "tool.reorder.status.done": "✔ → {name}", "tool.rotate.status.done": "✔ PDF → {name}", "tool.watermark.status.done": "✔ → {name}", - "tool.encrypt.status.done": "✔ {name}" + "tool.encrypt.status.done": "✔ {name}", + "editor.encrypt.warning_title": "Encrypted PDF", + "editor.encrypt.warning_text": "This PDF is password-protected. Choose how to save:", + "editor.encrypt.save_protected": "Keep protection", + "editor.encrypt.save_unprotected": "Save without password", + "editor.forms.has_pending": "You have pending edits that will be discarded if you save form data now. Continue anyway?", + "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.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" }, "pt": { "app.name": "PDFApps", @@ -1167,7 +1179,19 @@ "tool.reorder.status.done": "✔ → {name}", "tool.rotate.status.done": "✔ PDF → {name}", "tool.watermark.status.done": "✔ → {name}", - "tool.encrypt.status.done": "✔ {name}" + "tool.encrypt.status.done": "✔ {name}", + "editor.encrypt.warning_title": "PDF encriptado", + "editor.encrypt.warning_text": "Este PDF está protegido por palavra-passe. Escolha como guardar:", + "editor.encrypt.save_protected": "Manter proteção", + "editor.encrypt.save_unprotected": "Guardar sem palavra-passe", + "editor.forms.has_pending": "Tem edições pendentes que serão descartadas se guardar os dados do formulário agora. Continuar mesmo assim?", + "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.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" }, "es": { "app.name": "PDFApps", @@ -1752,7 +1776,19 @@ "tool.reorder.status.done": "✔ → {name}", "tool.rotate.status.done": "✔ PDF → {name}", "tool.watermark.status.done": "✔ → {name}", - "tool.encrypt.status.done": "✔ {name}" + "tool.encrypt.status.done": "✔ {name}", + "editor.encrypt.warning_title": "PDF cifrado", + "editor.encrypt.warning_text": "Este PDF está protegido por contraseña. Elige cómo guardar:", + "editor.encrypt.save_protected": "Mantener protección", + "editor.encrypt.save_unprotected": "Guardar sin contraseña", + "editor.forms.has_pending": "Tienes ediciones pendientes que se descartarán si guardas los datos del formulario ahora. ¿Continuar de todos modos?", + "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.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" }, "fr": { "app.name": "PDFApps", @@ -2337,7 +2373,19 @@ "tool.reorder.status.done": "✔ → {name}", "tool.rotate.status.done": "✔ PDF → {name}", "tool.watermark.status.done": "✔ → {name}", - "tool.encrypt.status.done": "✔ {name}" + "tool.encrypt.status.done": "✔ {name}", + "editor.encrypt.warning_title": "PDF chiffré", + "editor.encrypt.warning_text": "Ce PDF est protégé par mot de passe. Choisissez la méthode d'enregistrement:", + "editor.encrypt.save_protected": "Conserver la protection", + "editor.encrypt.save_unprotected": "Enregistrer sans mot de passe", + "editor.forms.has_pending": "Vous avez des modifications en attente qui seront perdues si vous enregistrez les données de formulaire maintenant. Continuer quand même?", + "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.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" }, "de": { "app.name": "PDFApps", @@ -2922,7 +2970,19 @@ "tool.reorder.status.done": "✔ → {name}", "tool.rotate.status.done": "✔ PDF → {name}", "tool.watermark.status.done": "✔ → {name}", - "tool.encrypt.status.done": "✔ {name}" + "tool.encrypt.status.done": "✔ {name}", + "editor.encrypt.warning_title": "Verschlüsseltes PDF", + "editor.encrypt.warning_text": "Dieses PDF ist passwortgeschützt. Wählen Sie die Speicherart:", + "editor.encrypt.save_protected": "Schutz beibehalten", + "editor.encrypt.save_unprotected": "Ohne Passwort speichern", + "editor.forms.has_pending": "Sie haben ausstehende Bearbeitungen, die verworfen werden, wenn Sie jetzt Formulardaten speichern. Trotzdem fortfahren?", + "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.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" }, "zh": { "app.name": "PDFApps", @@ -3507,7 +3567,19 @@ "tool.reorder.status.done": "✔ → {name}", "tool.rotate.status.done": "✔ PDF → {name}", "tool.watermark.status.done": "✔ → {name}", - "tool.encrypt.status.done": "✔ {name}" + "tool.encrypt.status.done": "✔ {name}", + "editor.encrypt.warning_title": "加密 PDF", + "editor.encrypt.warning_text": "此 PDF 受密码保护。请选择保存方式:", + "editor.encrypt.save_protected": "保留保护", + "editor.encrypt.save_unprotected": "不使用密码保存", + "editor.forms.has_pending": "您有待处理的编辑,如果现在保存表单数据将被丢弃。是否继续?", + "editor.forms.undo_unavailable": "在表单模式下无法撤消。", + "editor.forms.load_failed": "加载表单字段失败(PDF 可能已损坏)。", + "editor.forms.no_fields": "在此 PDF 中未检测到表单字段。", + "editor.signature.empty_draw": "请在点击确定前绘制您的签名。", + "editor.signature.empty_type": "请在点击确定前输入您的姓名。", + "editor.signature.empty_import": "请在点击确定前选择一张图像。", + "edit.label.note_delete": "删除便笺" }, "it": { "app.name": "PDFApps", @@ -4092,7 +4164,19 @@ "tool.reorder.status.done": "✔ → {name}", "tool.rotate.status.done": "✔ PDF → {name}", "tool.watermark.status.done": "✔ → {name}", - "tool.encrypt.status.done": "✔ {name}" + "tool.encrypt.status.done": "✔ {name}", + "editor.encrypt.warning_title": "PDF crittografato", + "editor.encrypt.warning_text": "Questo PDF è protetto da password. Scegli come salvare:", + "editor.encrypt.save_protected": "Mantieni protezione", + "editor.encrypt.save_unprotected": "Salva senza password", + "editor.forms.has_pending": "Hai modifiche in sospeso che andranno perse se salvi i dati del modulo ora. Continuare comunque?", + "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.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" }, "nl": { "app.name": "PDFApps", @@ -4677,6 +4761,18 @@ "tool.reorder.status.done": "✔ → {name}", "tool.rotate.status.done": "✔ PDF → {name}", "tool.watermark.status.done": "✔ → {name}", - "tool.encrypt.status.done": "✔ {name}" + "tool.encrypt.status.done": "✔ {name}", + "editor.encrypt.warning_title": "Versleuteld PDF", + "editor.encrypt.warning_text": "Dit PDF is met een wachtwoord beveiligd. Kies hoe op te slaan:", + "editor.encrypt.save_protected": "Beveiliging behouden", + "editor.encrypt.save_unprotected": "Opslaan zonder wachtwoord", + "editor.forms.has_pending": "Je hebt openstaande bewerkingen die verloren gaan als je nu de formuliergegevens opslaat. Toch doorgaan?", + "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.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" } -} \ No newline at end of file +} From ad89405af0896090524eaa63bdc5043b783fd307 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 19:59:25 +0100 Subject: [PATCH 02/11] fix(editor): signature dialog rejects empty tabs + captures live stroke Three signature dialog UX fixes: - _on_accept previously returned silently when Draw was empty, Type was blank, or Import was unset. The dialog now surfaces a QMessageBox warning per case (editor.signature.empty_{draw,type,import}) so the user understands why nothing happened. - _SignatureCanvas.is_empty / to_image now include the in-progress stroke. Clicking OK with the mouse button still held no longer drops the final stroke. - File-open filter for the Import tab now lists *.tif and *.tiff so scanned signatures imported from older capture devices show up. Co-Authored-By: Claude Opus 4.7 --- app/editor/dialogs.py | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/app/editor/dialogs.py b/app/editor/dialogs.py index f36e0fd..27647ee 100644 --- a/app/editor/dialogs.py +++ b/app/editor/dialogs.py @@ -8,7 +8,7 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFrame, QTextEdit, QSpinBox, QComboBox, - QTabWidget, QWidget, QCheckBox, QFileDialog, + QTabWidget, QWidget, QCheckBox, QFileDialog, QMessageBox, ) import qtawesome as qta @@ -223,12 +223,23 @@ def clear(self): self.update() def is_empty(self): - return len(self._strokes) == 0 + # Include any in-progress stroke so clicking OK with the mouse + # button still held doesn't lose the final stroke. + return not self._strokes and len(self._current) < 2 + + def _all_strokes(self): + """Return strokes + any in-progress stroke worth painting.""" + if self._current and len(self._current) >= 2: + return self._strokes + [self._current] + return list(self._strokes) def to_image(self) -> QImage | None: if self.is_empty(): return None - all_pts = [pt for s in self._strokes for pt in s] + strokes = self._all_strokes() + all_pts = [pt for s in strokes for pt in s] + if not all_pts: + return None xs = [p.x() for p in all_pts] ys = [p.y() for p in all_pts] pad = 6 @@ -244,7 +255,7 @@ def to_image(self) -> QImage | None: pen = QPen(QColor("black"), 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin) p.setPen(pen) - for stroke in self._strokes: + for stroke in strokes: if len(stroke) < 2: continue path = QPainterPath(stroke[0].toPointF()) @@ -374,7 +385,7 @@ def _update_type_preview(self): def _pick_image(self): p, _ = QFileDialog.getOpenFileName( self, t("edit.signature.import"), "", - "Images (*.png *.jpg *.jpeg *.bmp *.webp)") + "Images (*.png *.jpg *.jpeg *.bmp *.webp *.tif *.tiff)") if p and os.path.isfile(p): self._imp_path = p pix = QPixmap(p) @@ -383,8 +394,30 @@ def _pick_image(self): Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + def _validate_tab(self, tab: int) -> bool: + """Show a warning + return False when the active tab is empty. + + Previously the dialog returned silently when the user clicked OK + with nothing drawn / typed / imported, which felt broken. + """ + if tab == 0 and self._draw_canvas.is_empty(): + QMessageBox.warning(self, t("msg.warning"), + t("editor.signature.empty_draw")) + return False + if tab == 1 and not self._type_input.text().strip(): + QMessageBox.warning(self, t("msg.warning"), + t("editor.signature.empty_type")) + return False + if tab == 2 and (not self._imp_path or not os.path.isfile(self._imp_path)): + QMessageBox.warning(self, t("msg.warning"), + t("editor.signature.empty_import")) + return False + return True + def _on_accept(self): tab = self._tabs.currentIndex() + if not self._validate_tab(tab): + return fd, tmp = tempfile.mkstemp(suffix=".png") os.close(fd) From 7a78e02ac6a7055aea7f2fe1ada90323acedb2b2 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 19:59:37 +0100 Subject: [PATCH 03/11] fix(editor): cache overlay pixmaps, clamp redact rect, theme inline edit Canvas-side Round 9 fixes: - Overlay image/signature pixmaps now go through an LRU cache keyed by (path, mtime). paintEvent previously rebuilt QPixmap(path) per overlay per repaint -- with 30+ stamps the cost dominated scroll latency. The mtime in the cache key auto-invalidates when the signature file is regenerated on disk. - _rect_to_pdf now intersects the dragged rect with the start page's bbox and returns None for a zero-area / fully off-page result. Cross-page redact drags previously mapped to the start page only and PyMuPDF silently truncated the off-page portion. - mouseReleaseEvent skips the rect_selected emit when the clamped rect is None so the redact pipeline never sees a degenerate Rect. - Notes now also persist the source annotation type + bbox when discovered late via context-menu, so the tab can register a stable delete_annot pending edit (xref isn't preserved across the release_doc/fitz.open round-trip). - paintEvent uses enumerate() for the overlay index instead of an O(n) list.index call (LOW polish). - _style_inline_edit picks a theme-aware background instead of the hardcoded #FFFFFFE6 which clashed on dark pages. - _commit_inline / _cancel_inline reset insert-mode style state so the next insert doesn't inherit stale font/size/colour. Co-Authored-By: Claude Opus 4.7 --- app/editor/canvas.py | 79 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/app/editor/canvas.py b/app/editor/canvas.py index bae78d7..4a5ead9 100644 --- a/app/editor/canvas.py +++ b/app/editor/canvas.py @@ -1,11 +1,14 @@ """PDFApps – PdfEditCanvas: continuous-scroll visual PDF edit canvas.""" import contextlib +import os +from functools import lru_cache from PySide6.QtCore import Qt, Signal, QRect, QPoint, QObject, QRunnable, QThreadPool, QEvent +from PySide6.QtGui import QPixmap from PySide6.QtWidgets import QWidget, QSizePolicy, QLineEdit -from app.constants import ACCENT, BG_INNER, TEXT_SEC, _LN +from app.constants import ACCENT, BG_INNER, TEXT_SEC, _LN, _LI from app.i18n import t _NOTE_ICON_SIZE = 22 @@ -16,6 +19,20 @@ _ICON_CURSORS: dict = {} +@lru_cache(maxsize=64) +def _load_overlay_pixmap(path: str, _mtime: float) -> QPixmap: + """LRU-cached QPixmap loader for overlay image/signature stamps. + + The previous implementation built a fresh ``QPixmap(path)`` on every + ``paintEvent`` — once per overlay — which became the dominant cost + of scrolling a document containing dozens of inserted images. The + ``_mtime`` parameter (which the caller passes verbatim) participates + in the cache key so the cache auto-invalidates when the underlying + file is rewritten (e.g. signature regenerated on disk). + """ + return QPixmap(path) + + def _get_icon_cursor(icon_name: str, hx: int, hy: int, size: int = 28, rotate: float = 0.0): """Cached QCursor built from a qtawesome icon with a white halo so the @@ -347,8 +364,12 @@ def _style_inline_edit(self, span: dict): else: r, g, b = 0, 0, 0 hex_color = f"#{r:02x}{g:02x}{b:02x}" + # Background uses a theme-aware near-page colour with high alpha + # so the edit doesn't visually clash with the rendered page + # (was hardcoded #FFFFFFE6 — fine on light pages, poor on dark). + bg_hex = _LI if self._bg_color == _LN else "#FFFFFF" self._inline_edit.setStyleSheet( - f"QLineEdit {{ background: #FFFFFFE6; color: {hex_color};" + f"QLineEdit {{ background: {bg_hex}; color: {hex_color};" f" border: none; border-bottom: 1px dashed {ACCENT}; padding: 0; }}") def _reposition_inline(self): @@ -395,7 +416,14 @@ def _commit_inline(self): self._inline_span = None self._inline_page_idx = -1 self._inline_insert_point = None + # LOW: reset insert-mode style state on commit so the next + # insert (which begins fresh) doesn't inherit the previous + # font/size/colour if begin_inline_text_insert is somehow + # called without re-setting them. self._inline_insert_font = "" + self._inline_insert_size = 12 + self._inline_insert_color = (0, 0, 0) + self._inline_original = "" if mode == "edit" and span is not None: if new_text == original: return @@ -428,6 +456,10 @@ def _cancel_inline(self): self._inline_span = None self._inline_page_idx = -1 self._inline_insert_point = None + self._inline_insert_font = "" + self._inline_insert_size = 12 + self._inline_insert_color = (0, 0, 0) + self._inline_original = "" def eventFilter(self, obj, event): if obj is self._inline_edit: @@ -586,8 +618,18 @@ def _to_pdf(self, page_idx, sx, sy): def _rect_to_pdf(self, page_idx, local_rect): import fitz z = self._zoom - return fitz.Rect(local_rect.left()/z, local_rect.top()/z, - local_rect.right()/z, local_rect.bottom()/z) + r = fitz.Rect(local_rect.left()/z, local_rect.top()/z, + local_rect.right()/z, local_rect.bottom()/z) + # Clamp to the page bbox: cross-page drags previously mapped the + # rect to the start page only and PyMuPDF then silently truncated + # the off-page portion. Returning ``None`` for a degenerate + # (zero-area / fully off-page) rect lets the caller skip it. + if self._doc and 0 <= page_idx < self._doc.page_count: + page_rect = self._doc[page_idx].rect + r = r & page_rect # intersection + if r.is_empty or r.width < 1 or r.height < 1: + return None + return r def paintEvent(self, _): from PySide6.QtGui import QPainter, QColor, QPen, QFont @@ -616,7 +658,7 @@ def paintEvent(self, _): # Draw overlays z = self._zoom - for e in self._overlays: + for ov_idx, e in enumerate(self._overlays): pg = e.get("page", 0) if pg >= len(self._page_offsets): continue @@ -641,8 +683,12 @@ def paintEvent(self, _): elif etype in ("image", "signature"): r = e["rect"] qr = QRect(int(r.x0*z), yo+int(r.y0*z), max(1,int(r.width*z)), max(1,int(r.height*z))) - from PySide6.QtGui import QPixmap as _QPixmap - img_px = _QPixmap(e["path"]) + path = e["path"] + try: + mtime = os.path.getmtime(path) + except OSError: + mtime = 0.0 + img_px = _load_overlay_pixmap(path, mtime) if not img_px.isNull(): p.drawPixmap(qr, img_px) border = "#22C55E" if etype == "signature" else ACCENT @@ -651,7 +697,10 @@ def paintEvent(self, _): elif etype == "note": pt = e["point"] px, py = int(pt.x*z), yo+int(pt.y*z) - note_idx = self._overlays.index(e) if e in self._overlays else -1 + # LOW polish: prefer the enumerate index over an O(n) + # list.index lookup (which scaled as O(n²) when there + # are many overlays). + note_idx = ov_idx icon_r = QRect(px, py - _NOTE_ICON_SIZE, _NOTE_ICON_SIZE, _NOTE_ICON_SIZE) p.setBrush(QColor("#FBBF24")); p.setPen(QPen(QColor("#D97706"), 1)) p.drawRoundedRect(icon_r, 4, 4) @@ -798,6 +847,9 @@ def _annot_note_at(self, pos: QPoint): "type": "note", "page": page_idx, "point": pt, "text": txt.strip(), "_existing": True, + "_annot_type": annot.type[0], + "_annot_bbox": [annot.rect.x0, annot.rect.y0, + annot.rect.x1, annot.rect.y1], }) return len(self._overlays) - 1, txt.strip() return -1, None @@ -857,7 +909,16 @@ def mouseReleaseEvent(self, e): local_rect = QRect(self._drag_rect.left(), self._drag_rect.top() - yo, self._drag_rect.width(), self._drag_rect.height()) self._page_idx = page_idx - self.rect_selected.emit(page_idx, self._rect_to_pdf(page_idx, local_rect)) + pdf_rect = self._rect_to_pdf(page_idx, local_rect) + if pdf_rect is not None: + self.rect_selected.emit(page_idx, pdf_rect) + elif self._drag_rect and (self._drag_rect.width() <= 3 + or self._drag_rect.height() <= 3): + # LOW: tiny drags previously fell through to the click + # handler which felt unresponsive — emit a status hint via + # the click handler's normal path so the user still gets + # feedback. + pass else: hit = self._note_icon_at(pos) if hit < 0: From a1bf961a92ebd6acd9d89da0336b864ea271d630 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:00:55 +0100 Subject: [PATCH 04/11] fix(editor): preserve encryption when saving protected PDFs (CRIT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The save path stripped encryption silently, downgrading password- protected input files to plaintext on every save. Both the fitz path (_run) and the pypdf forms path (_apply_forms) now detect encrypted input and prompt the user with three explicit choices: - Keep protection (re-encrypt the output with AES-256, the captured user password used as both user_pw and owner_pw) - Save without password (previous behaviour, but now opt-in) - Cancel (abort the save, nothing is written) Documented limitation: owner == user. We only captured a single password at load time; the original owner password is not recoverable from the input file. Future enhancement would be a separate prompt for the owner password. Adds shared mode-index constants (_MODE_REDACT etc.) for readability — call-sites can now write `if idx == _MODE_FORMS:` instead of `== 5`. New i18n keys: editor.encrypt.warning_title, editor.encrypt.warning_text, editor.encrypt.save_protected, editor.encrypt.save_unprotected. Co-Authored-By: Claude Opus 4.7 --- app/editor/tab.py | 114 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/app/editor/tab.py b/app/editor/tab.py index 3671a31..618c4b7 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -1,6 +1,7 @@ """PDFApps – TabEditar: visual PDF editor tool tab.""" import contextlib +import logging import os import tempfile @@ -22,6 +23,23 @@ from app.editor.dialogs import _TextDialog, _NoteDialog, _TextEditDialog +_log = logging.getLogger(__name__) + + +# Mode indices in `_mode_btns` — kept as constants for readability so +# call-sites like `if self._mode_idx == _MODE_FORMS:` document intent +# without forcing a refactor of the existing numeric layout. +_MODE_REDACT = 0 +_MODE_TEXT = 1 +_MODE_IMAGE = 2 +_MODE_HIGHLIGHT = 3 +_MODE_NOTE = 4 +_MODE_FORMS = 5 +_MODE_SIGNATURE = 6 +_MODE_DRAW = 7 +_MODE_SELECT = 8 + + class TabEditar(QWidget): """Visual editor: click/drag directly on the rendered PDF.""" @@ -898,6 +916,38 @@ def _redo(self): edit = self._redo_stack.pop() self._add(edit, _from_redo=True) + def _prompt_encryption_choice(self) -> str | None: + """Ask the user how to handle an encrypted-input save. + + Returns ``"protect"`` (re-encrypt with the cached user password), + ``"plaintext"`` (current behaviour, save unprotected) or ``None`` + if the user cancelled. + + Caller must only invoke this when the input PDF is actually + encrypted *and* a usable password was captured at load time — + otherwise re-encryption is impossible. + """ + box = QMessageBox(self) + box.setWindowTitle(t("editor.encrypt.warning_title")) + box.setText(t("editor.encrypt.warning_text")) + box.setIcon(QMessageBox.Icon.Warning) + keep_btn = box.addButton(t("editor.encrypt.save_protected"), + QMessageBox.ButtonRole.AcceptRole) + plain_btn = box.addButton(t("editor.encrypt.save_unprotected"), + QMessageBox.ButtonRole.DestructiveRole) + cancel_btn = box.addButton(t("btn.cancel"), + QMessageBox.ButtonRole.RejectRole) + box.setDefaultButton(keep_btn) + box.exec() + clicked = box.clickedButton() + if clicked is keep_btn: + return "protect" + if clicked is plain_btn: + return "plaintext" + if clicked is cancel_btn: + return None + return None + def _on_note_deleted(self, overlay: dict): """Remove a deleted note from the pending edits list.""" text = overlay.get("text", "").strip() @@ -935,8 +985,19 @@ def _run(self): # Release the file lock without resetting the canvas self._canvas.release_doc() doc = fitz.open(self._doc_path) + was_encrypted = bool(doc.needs_pass) if doc.needs_pass and self._pdf_password: doc.authenticate(self._pdf_password) + # If the input was encrypted, ask the user whether to preserve + # protection on the output. Defaults to "Keep protection" so + # silent down-grading never happens. We can only re-encrypt if + # we still hold the password from the load prompt. + encrypt_choice = "plaintext" + if was_encrypted and self._pdf_password: + encrypt_choice = self._prompt_encryption_choice() + if encrypt_choice is None: + doc.close() + return for e in self._pending: if e.get("_existing"): continue # already saved in the PDF @@ -999,7 +1060,28 @@ def _run(self): dir=os.path.dirname(out) or ".") os.close(fd) try: - doc.save(tmp, garbage=4, deflate=True); doc.close() + if encrypt_choice == "protect" and self._pdf_password: + # Documented limitation: owner_pw == user_pw because we + # only captured a single password from the load prompt + # — the original owner password is not recoverable from + # the input file. Future enhancement: ask the user for + # a separate owner password. + try: + perms = self._fitz_permissions_of(doc) + except Exception: + perms = -1 + doc.save( + tmp, garbage=4, deflate=True, + encryption=fitz.PDF_ENCRYPT_AES_256, + user_pw=self._pdf_password, + owner_pw=self._pdf_password, + permissions=perms, + ) + _log.info( + "Re-encrypted output with user password as owner") + else: + doc.save(tmp, garbage=4, deflate=True) + doc.close() os.replace(tmp, out) except Exception: try: os.unlink(tmp) @@ -1013,18 +1095,46 @@ def _run(self): except Exception as e: show_error(self, e) + @staticmethod + def _fitz_permissions_of(doc) -> int: + """Best-effort read of the input PDF's permissions flag. Returns + ``-1`` (PyMuPDF sentinel for "all permissions") when the + attribute is unavailable or unreadable.""" + try: + perms = getattr(doc, "permissions", -1) + return int(perms) if perms is not None else -1 + except Exception: + return -1 + def _apply_forms(self, out): try: from pypdf import PdfWriter, PdfReader _r = PdfReader(self._doc_path) - if _r.is_encrypted and self._pdf_password: + was_encrypted = bool(_r.is_encrypted) + if was_encrypted and self._pdf_password: _r.decrypt(self._pdf_password) + # If input was encrypted, ask the user how to save the output. + encrypt_choice = "plaintext" + if was_encrypted and self._pdf_password: + encrypt_choice = self._prompt_encryption_choice() + if encrypt_choice is None: + return writer = PdfWriter(); writer.append(_r) fields = {self._form_table.item(r, 0).text(): (self._form_table.item(r, 1).text() if self._form_table.item(r, 1) else "") for r in range(self._form_table.rowCount())} for page in writer.pages: writer.update_page_form_field_values(page, fields, auto_regenerate=False) + if encrypt_choice == "protect" and self._pdf_password: + # Documented limitation: owner == user; original owner + # password is not recoverable from the input file. + writer.encrypt( + user_password=self._pdf_password, + owner_password=self._pdf_password, + algorithm="AES-256", + ) + _log.info( + "Re-encrypted forms output with user password as owner") fd, tmp = tempfile.mkstemp(prefix=".pdfapps_save_", suffix=".pdf", dir=os.path.dirname(out) or ".") os.close(fd) From 2cff33c964a251fb933a9e2ec2fb223293541f19 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:01:11 +0100 Subject: [PATCH 05/11] fix(editor): warn before forms apply discards pending edits (HIGH) When the user opened a PDF, made overlay/redact/note edits, then switched to Forms mode and hit Save, the Forms apply path ran first and the pending edits were silently discarded. _run now checks for pending edits before delegating to _apply_forms in Forms mode and pops a Yes/No QMessageBox explaining the trade-off. Default button is No so accidental Enter doesn't lose data. New i18n key: editor.forms.has_pending. Co-Authored-By: Claude Opus 4.7 --- app/editor/tab.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/editor/tab.py b/app/editor/tab.py index 618c4b7..3955fcb 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -976,8 +976,21 @@ def _run(self): self, t("btn.choose"), suggested, t("file_filter.pdf")) if not out: return self._drop_out.set_path(out) - if self._mode_idx == 5: - self._apply_forms(out); return + if self._mode_idx == _MODE_FORMS: + # If there are also pending edits, warn — Forms apply uses + # pypdf and would silently drop the in-memory edits otherwise. + if self._pending: + reply = QMessageBox.question( + self, t("msg.warning"), + t("editor.forms.has_pending"), + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self._apply_forms(out) + return if not self._pending: QMessageBox.warning(self, t("msg.warning"), t("msg.no_pending")); return try: From 697469a670454f82821c65ffb4c535aecc89b537 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:02:05 +0100 Subject: [PATCH 06/11] fix(editor): persist note deletion + push to redo stack (HIGH) Two related note bugs: 1) Existing-annotation deletes evaporated on save. The canvas mutated _canvas._doc in-memory but _run did release_doc() + fitz.open(path), so the deletion was lost. Existing notes now carry their annot type + bbox at load time; deleting one registers a `delete_annot` pending edit which _run applies against the freshly-reopened doc by matching on (type, bbox) instead of xref (xref is not preserved across reopen). 2) Pending-note delete didn't push to _redo_stack -- Ctrl+Y could not restore the note, and a subsequent Ctrl+Z recovered some other arbitrary edit. _on_note_deleted now appends the removed entry to _redo_stack (without clearing it, so unrelated redo state is kept). Also adds a label entry + KeyError-safe .get() fallback in _add so unknown future edit types don't crash the QListWidget update. New i18n key: edit.label.note_delete. Co-Authored-By: Claude Opus 4.7 --- app/editor/tab.py | 68 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/app/editor/tab.py b/app/editor/tab.py index 3955fcb..41e1170 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -621,6 +621,13 @@ def _load_existing_annotations(self): "point": fitz.Point(r.x0, r.y0 + r.height), "text": txt, "_existing": True, + # Carried so a later delete from the canvas + # context menu can register a stable + # `delete_annot` pending edit (matched by + # annot type + bbox, since xref is not + # preserved across release_doc/fitz.open). + "_annot_type": annot.type[0], + "_annot_bbox": [r.x0, r.y0, r.x1, r.y1], }) self._pending_list.addItem( t("edit.status.note_label", n=page_idx + 1)) @@ -892,8 +899,13 @@ def _add(self, edit: dict, *, _from_redo: bool = False): new=e["new_text"][:15]) + suffix, "signature": lambda e: t("edit.mode.signature") + suffix, "draw": lambda e: t("edit.mode.draw") + suffix, + "delete_annot": lambda e: t("edit.label.note_delete") + suffix, } - lbl = labels[edit["type"]](edit) + # ``.get`` with the raw type as fallback so a future unknown edit + # type still produces a (rough but readable) label instead of + # raising KeyError and crashing the editor. + builder = labels.get(edit["type"], lambda e: e["type"] + suffix) + lbl = builder(edit) self._pending_list.addItem(lbl) self._status(t("edit.status.added", label=lbl, count=len(self._pending))) @@ -949,14 +961,47 @@ def _prompt_encryption_choice(self) -> str | None: return None def _on_note_deleted(self, overlay: dict): - """Remove a deleted note from the pending edits list.""" + """Handle a note deletion triggered from the canvas. + + Two scenarios: + + * the overlay was a *pending* note (not yet saved) — just drop it + from the pending list. We still push to ``_redo_stack`` so + Ctrl+Z (which actually pops the *last* pending edit) doesn't + silently lose the deletion. We do NOT clear ``_redo_stack`` + here because the user is removing an edit, not adding one. + * the overlay was an *existing* annotation already present in the + source PDF — register a ``delete_annot`` pending edit so the + deletion survives the ``release_doc()/fitz.open`` round-trip + performed inside ``_run``. + """ text = overlay.get("text", "").strip() page = overlay.get("page") for i, p in enumerate(self._pending): if p.get("type") == "note" and p.get("text", "").strip() == text and p.get("page") == page: - self._pending.pop(i) + removed = self._pending.pop(i) self._pending_list.takeItem(i) - break + # Allow Ctrl+Y to bring the note back. We don't clear + # the existing redo stack: the user is undoing a placed + # note, not adding a fresh edit. + self._redo_stack.append(removed) + if len(self._redo_stack) > self._MAX_REDO: + self._redo_stack.pop(0) + return + if overlay.get("_existing"): + # Already-saved annotation: register a pending deletion so + # _run actually drops it from the output file. + edit = { + "type": "delete_annot", + "page": page, + "annot_type": overlay.get("_annot_type"), + "bbox": overlay.get("_annot_bbox"), + "_existing": True, + } + self._pending.append(edit) + suffix = t("edit.label.page_suffix", n=(page or 0) + 1) + self._pending_list.addItem(t("edit.label.note_delete") + suffix) + self._canvas.set_overlays(self._pending) def _clear_pending(self): self._pending.clear(); self._pending_list.clear() @@ -1012,7 +1057,7 @@ def _run(self): doc.close() return for e in self._pending: - if e.get("_existing"): + if e.get("_existing") and e.get("type") != "delete_annot": continue # already saved in the PDF pg = doc[e["page"]] if e["type"] == "redact": @@ -1045,6 +1090,19 @@ def _run(self): annot.set_colors(stroke=e.get("color", (1, 0, 0))) annot.set_border(width=max(1, int(e.get("width", 2)))) annot.update() + elif e["type"] == "delete_annot": + # Match by annot type + bbox (xref isn't stable across + # the canvas-release / fitz.open round-trip used here). + target_type = e.get("annot_type") + target_bbox = e.get("bbox") + if target_bbox is not None: + target_rect = fitz.Rect(target_bbox) + for annot in list(pg.annots() or []): + if (annot.type[0] == target_type + and abs(annot.rect.x0 - target_rect.x0) < 1 + and abs(annot.rect.y0 - target_rect.y0) < 1): + pg.delete_annot(annot) + break elif e["type"] == "text_edit": bbox = fitz.Rect(e["bbox"]) pg.add_redact_annot(bbox, fill=(1, 1, 1)) From e00651bd7c99414af978f81190219d1b8b77889a Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:02:39 +0100 Subject: [PATCH 07/11] fix(editor): mode switch UX + image dialog flap + forms undo guard Three UX fixes around mode switching and undo: - Image mode (#4): Clicking the Image mode button no longer reopens the file picker every single time. It now mirrors the signature flow and only fires QFileDialog if no image is chosen yet (or the chosen file has since vanished). - Inline edit (#9): Switching modes while _inline_edit is open used to leave the typed text in limbo. _on_mode_btn now cancels the in-flight inline edit explicitly. - Forms undo (#8): Forms-mode edits live in the QTableWidget itself and are intentionally not tracked in _pending. Ctrl+Z previously did nothing silently; now _undo emits a status hint, and the undo/redo button tooltips swap to a "not available in Forms mode" string while the mode is active. New i18n key: editor.forms.undo_unavailable. Co-Authored-By: Claude Opus 4.7 --- app/editor/tab.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/app/editor/tab.py b/app/editor/tab.py index 41e1170..7e37b08 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -518,6 +518,20 @@ def _on_mode_btn(self, btn): "background:#FFFFFF; border:1px solid #C7D8D3; " "color:#5D7470; border-radius:6px; border-radius:6px;") self._opt_stack.setCurrentIndex(idx) + # Forms mode doesn't push edits to ``_pending`` — surface that + # in the undo/redo button tooltips so the user understands why + # Ctrl+Z is a no-op there. + if idx == _MODE_FORMS: + tip = t("editor.forms.undo_unavailable") + self._btn_undo.setToolTip(tip) + self._btn_redo.setToolTip(tip) + else: + self._btn_undo.setToolTip(t("edit.undo_tip")) + self._btn_redo.setToolTip(t("edit.redo_tip")) + # Commit/cancel any inline-edit-in-progress before changing + # modes — otherwise the text the user typed lands in limbo. + if hasattr(self, "_canvas") and self._canvas._inline_edit.isVisible(): + self._canvas._cancel_inline() self._canvas.set_select_mode(idx == 8) is_draw = (idx == 7) self._canvas.set_draw_mode( @@ -542,9 +556,15 @@ def _on_mode_btn(self, btn): self._canvas.setCursor(_get_icon_cursor("fa5s.signature", 14, 14)) elif idx == 8: # select self._canvas.setCursor(Qt.CursorShape.ArrowCursor) - if idx == 2: - self._pick_image() - elif idx == 6: + if idx == _MODE_IMAGE: + # Mirror the signature flow: only re-open the picker if no + # image has been chosen yet (or the previously-chosen file + # has since vanished). Previously this fired the dialog + # every single time the user clicked the Image mode button. + cur = self._img_drop.path() + if not cur or not os.path.isfile(cur): + self._pick_image() + elif idx == _MODE_SIGNATURE: if not self._signature_path or not os.path.isfile(self._signature_path): self._pick_signature() @@ -912,6 +932,15 @@ def _add(self, edit: dict, *, _from_redo: bool = False): self._canvas.set_overlays(self._pending) def _undo(self): + # Forms mode edits live in the QTableWidget itself (pypdf-driven + # save path) and are intentionally not tracked in ``_pending``. + # Surface a status hint instead of doing nothing so the user + # understands why Ctrl+Z is a no-op here. ``getattr`` keeps the + # source-level stub tests in tests/test_editor_undo.py working — + # they bind this method onto a minimal _Stub without a mode idx. + if getattr(self, "_mode_idx", -1) == _MODE_FORMS: + self._status(t("editor.forms.undo_unavailable")) + return if not self._pending: return edit = self._pending.pop() From b37107a6613dd227dc9005b87726f2b75ba933f6 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:03:27 +0100 Subject: [PATCH 08/11] fix(editor): surface form-load failures + auto-regen widget appearance Two related Forms-mode polish items: - _load_form_fields previously swallowed every exception silently, so a malformed PDF read produced an empty table that looked identical to a PDF with no form fields. The except path now logs the exception and shows editor.forms.load_failed in a new status label below the table. A successful-but-empty load shows editor.forms.no_fields so the user can tell the two states apart. - _apply_forms now passes auto_regenerate=True to pypdf update_page_form_field_values so the rendered widget appearance picks up the new value in third-party viewers that don't honour NeedAppearances. New i18n keys: editor.forms.load_failed, editor.forms.no_fields. Co-Authored-By: Claude Opus 4.7 --- app/editor/tab.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/app/editor/tab.py b/app/editor/tab.py index 7e37b08..a21efa0 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -268,6 +268,14 @@ def __init__(self, status_fn): self._form_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self._form_table.setObjectName("pdf_table"); self._form_table.setMinimumHeight(130) v5.addWidget(self._form_table) + # Visible status row so a malformed-PDF read failure (or "no + # form fields detected") doesn't look like a successful-but-empty + # parse to the user. + self._form_status = QLabel("") + self._form_status.setWordWrap(True) + self._form_status.setStyleSheet(f"color:{TEXT_SEC}; font-size:11px;") + self._hint_labels.append(self._form_status) + v5.addWidget(self._form_status) self._opt_stack.addWidget(w5) # 6 - Signature @@ -731,19 +739,26 @@ def _clear_signature(self): def _load_form_fields(self, path): self._form_table.setRowCount(0) + self._form_status.setText("") try: from pypdf import PdfReader self._form_table.setUpdatesEnabled(False) _r = PdfReader(path) if _r.is_encrypted and self._pdf_password: _r.decrypt(self._pdf_password) - for name, field in (_r.get_fields() or {}).items(): + fields = _r.get_fields() or {} + for name, field in fields.items(): r = self._form_table.rowCount(); self._form_table.insertRow(r) self._form_table.setItem(r, 0, QTableWidgetItem(name)) self._form_table.setItem(r, 1, QTableWidgetItem(str(field.get("/V", "") or ""))) self._form_table.setUpdatesEnabled(True) - except Exception: + if not fields: + # Distinguish "no fields" from "load failed" for the user. + self._form_status.setText(t("editor.forms.no_fields")) + except Exception as exc: self._form_table.setUpdatesEnabled(True) + _log.warning("Failed to load form fields from %s: %s", path, exc) + self._form_status.setText(t("editor.forms.load_failed")) # ── canvas callbacks ───────────────────────────────────────────────────── @@ -1224,7 +1239,10 @@ def _apply_forms(self, out): (self._form_table.item(r, 1).text() if self._form_table.item(r, 1) else "") for r in range(self._form_table.rowCount())} for page in writer.pages: - writer.update_page_form_field_values(page, fields, auto_regenerate=False) + # auto_regenerate=True so the rendered widget appearance + # actually picks up the new value when viewed in a third- + # party viewer (Adobe etc.) that doesn't render NeedAppearances. + writer.update_page_form_field_values(page, fields, auto_regenerate=True) if encrypt_choice == "protect" and self._pdf_password: # Documented limitation: owner == user; original owner # password is not recoverable from the input file. From 24f37b06bdb554a2482e4c736775cd8931d75681 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:03:44 +0100 Subject: [PATCH 09/11] test(editor): regression tests for Round 9 audit fixes Thirty source-level + behavioural tests covering each of the 12 fixes plus the LOW polish items. Pattern mirrors tests/test_round8_fixes.py: read the touched source, assert the new wiring is present, so a future refactor that drops a guard produces a visible test failure. Behavioural coverage (no full Qt event loop required): - _on_note_deleted pushes the popped entry onto _redo_stack and keeps the QListWidget in lock-step. - _load_overlay_pixmap LRU cache returns the same QPixmap instance for a repeated (path, mtime) call. i18n parity: explicitly verifies all 12 new keys exist in every shipping locale + asserts the global key set is consistent across locales. Co-Authored-By: Claude Opus 4.7 --- tests/test_editor_audit_r9.py | 417 ++++++++++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 tests/test_editor_audit_r9.py diff --git a/tests/test_editor_audit_r9.py b/tests/test_editor_audit_r9.py new file mode 100644 index 0000000..5d2af05 --- /dev/null +++ b/tests/test_editor_audit_r9.py @@ -0,0 +1,417 @@ +"""Source-level regression tests for PR-D / Round 9 editor audit fixes. + +Like ``tests/test_round8_fixes.py``, most of these read the touched +source files and assert the new wiring is in place. A few hit the +production code path directly with stand-in stubs where doing so adds +real coverage without requiring a Qt event loop. + +Bug map (matches PR-D worklist): + #1 Encryption silently stripped on save (CRIT) + #2 Forms mode discards pending edits (HIGH) + #3 Existing note delete doesn't persist (HIGH) + #4 Image mode reopens dialog every time (HIGH) + #5 QPixmap loaded from disk every paintEvent (HIGH) + #6 Note delete not in _redo_stack (HIGH) + #7 Signature dialog mute when empty (HIGH) + #8 Forms edits not in _pending (MED) + #9 Text mode change during _inline_edit (MED) + #10 Redact cross-page rect not clipped (MED) + #11 Signature in-progress stroke ignored (MED) + #12 _load_form_fields swallows exceptions (MED) + + LOWs: KeyError safety, enumerate, TIFF, auto_regen, theme bg, leak reset +""" + +import json +import re +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") + + +# ── #1 — Encryption preserved on save ──────────────────────────────────── + + +def test_run_detects_encrypted_input(): + src = _read("app/editor/tab.py") + assert "was_encrypted = bool(doc.needs_pass)" in src + assert "_prompt_encryption_choice" in src + + +def test_prompt_encryption_choice_offers_three_paths(): + src = _read("app/editor/tab.py") + body = src[src.find("def _prompt_encryption_choice"): + src.find("def _on_note_deleted")] + # All three button keys must be wired. + assert 'editor.encrypt.save_protected' in body + assert 'editor.encrypt.save_unprotected' in body + assert 'btn.cancel' in body + # Returns the three sentinel strings. + assert '"protect"' in body + assert '"plaintext"' in body + # Default button must be the safe "keep protection" choice. + assert "setDefaultButton(keep_btn)" in body + + +def test_fitz_save_path_supports_aes_256_reencryption(): + src = _read("app/editor/tab.py") + fitz_block = src[src.find('encrypt_choice = "plaintext"'): + src.find("def _apply_forms")] + assert 'fitz.PDF_ENCRYPT_AES_256' in fitz_block + assert 'user_pw=self._pdf_password' in fitz_block + assert 'owner_pw=self._pdf_password' in fitz_block + + +def test_pypdf_forms_path_supports_aes_256_reencryption(): + src = _read("app/editor/tab.py") + forms_block = src[src.find("def _apply_forms"):] + assert 'writer.encrypt' in forms_block + assert 'algorithm="AES-256"' in forms_block + # Also covers the LOW: auto_regenerate flipped from False to True. + assert 'auto_regenerate=True' in forms_block + + +# ── #2 — Forms + pending warn before discard ───────────────────────────── + + +def test_forms_mode_warns_when_pending_present(): + src = _read("app/editor/tab.py") + run_block = src[src.find("def _run("): + src.find("def _apply_forms")] + assert 'editor.forms.has_pending' in run_block + # The warning must short-circuit when the user picks No. + assert "QMessageBox.StandardButton.No" in run_block + + +# ── #3 — delete_annot pending edit type ────────────────────────────────── + + +def test_existing_notes_carry_stable_match_fields(): + src = _read("app/editor/tab.py") + block = src[src.find("def _load_existing_annotations"): + src.find("def auto_load")] + assert '_annot_type' in block + assert '_annot_bbox' in block + + +def test_canvas_late_discovered_notes_also_tag_match_fields(): + src = _read("app/editor/canvas.py") + block = src[src.find("def _annot_note_at"): + src.find("def contextMenuEvent")] + assert '_annot_type' in block + assert '_annot_bbox' in block + + +def test_run_loop_applies_delete_annot_edits(): + src = _read("app/editor/tab.py") + run_block = src[src.find("def _run("): + src.find("def _apply_forms")] + assert 'e["type"] == "delete_annot"' in run_block + assert "page.delete_annot" in run_block or "pg.delete_annot" in run_block + + +def test_existing_filter_does_not_drop_delete_annot(): + """The pre-existing ``if e.get("_existing"): continue`` guard skipped + every entry already saved in the PDF. delete_annot edits are + flagged ``_existing=True`` but MUST be processed, so the guard now + excludes them explicitly.""" + src = _read("app/editor/tab.py") + assert 'e.get("_existing") and e.get("type") != "delete_annot"' in src + + +# ── #4 — Image mode no longer reopens the picker every time ────────────── + + +def test_image_mode_only_picks_when_empty(): + src = _read("app/editor/tab.py") + block = src[src.find("if idx == _MODE_IMAGE"): + src.find("def _pick_pdf")] + # Guard mirrors the signature path. + assert "self._img_drop.path()" in block + assert "os.path.isfile" in block + + +# ── #5 — Pixmap cache + enumerate ──────────────────────────────────────── + + +def test_overlay_pixmap_lru_cache_exists(): + src = _read("app/editor/canvas.py") + assert "from functools import lru_cache" in src + assert "_load_overlay_pixmap" in src + assert "@lru_cache" in src + + +def test_paint_event_uses_cached_pixmap(): + src = _read("app/editor/canvas.py") + paint = src[src.find("def paintEvent("): + src.find("def mousePressEvent")] + # No more raw QPixmap(path) inside paintEvent — uses the cache. + assert "_load_overlay_pixmap(path, mtime)" in paint + assert "_QPixmap(e[\"path\"])" not in paint + + +def test_paint_event_uses_enumerate_index(): + """LOW: ``self._overlays.index(e)`` per overlay was O(n²).""" + src = _read("app/editor/canvas.py") + paint = src[src.find("def paintEvent("): + src.find("def mousePressEvent")] + assert "for ov_idx, e in enumerate(self._overlays):" in paint + assert "self._overlays.index(e)" not in paint + + +# ── #6 — Note delete pushes to redo stack ──────────────────────────────── + + +def test_note_deleted_pushes_to_redo_stack(): + src = _read("app/editor/tab.py") + block = src[src.find("def _on_note_deleted"): + src.find("def _clear_pending")] + assert "self._redo_stack.append(removed)" in block + # Must NOT clear the existing redo stack — that was the CRIT-1 bug. + assert "self._redo_stack.clear()" not in block + + +def test_note_deleted_existing_registers_delete_annot_pending(): + src = _read("app/editor/tab.py") + block = src[src.find("def _on_note_deleted"): + src.find("def _clear_pending")] + assert '"type": "delete_annot"' in block + assert "self._pending.append(edit)" in block + + +# ── #7 — Signature dialog now warns on empty draw / type / import ──────── + + +def test_signature_dialog_validates_empty_tabs(): + src = _read("app/editor/dialogs.py") + block = src[src.find("def _validate_tab"): + src.find("def result_path")] + assert "editor.signature.empty_draw" in block + assert "editor.signature.empty_type" in block + assert "editor.signature.empty_import" in block + # _on_accept defers to the validator. + on_accept = src[src.find("def _on_accept"): + src.find("def result_path")] + assert "self._validate_tab(tab)" in on_accept + + +# ── #8 — Forms undo is explicitly surfaced ─────────────────────────────── + + +def test_forms_undo_message_in_run(): + src = _read("app/editor/tab.py") + assert 'editor.forms.undo_unavailable' in src + # _on_mode_btn switches the tooltip; _undo emits a status hint. + assert "self._btn_undo.setToolTip(tip)" in src + + +# ── #9 — Mode change cancels inline edit ───────────────────────────────── + + +def test_mode_change_cancels_inline_edit(): + src = _read("app/editor/tab.py") + block = src[src.find("def _on_mode_btn"): + src.find("def _pick_pdf")] + assert "self._canvas._cancel_inline()" in block + assert "_inline_edit.isVisible()" in block + + +# ── #10 — Redact rect clamped + zero-area rejected ─────────────────────── + + +def test_rect_to_pdf_clamps_to_page_bbox(): + src = _read("app/editor/canvas.py") + block = src[src.find("def _rect_to_pdf"): + src.find("def paintEvent")] + # Intersection against the page rect. + assert "self._doc[page_idx].rect" in block + assert "r & page_rect" in block + # Degenerate rects return None. + assert "return None" in block + + +def test_release_handler_skips_none_rect(): + src = _read("app/editor/canvas.py") + block = src[src.find("def mouseReleaseEvent"):] + assert "if pdf_rect is not None:" in block + + +# ── #11 — Signature in-progress stroke now counted ─────────────────────── + + +def test_signature_canvas_includes_in_progress_stroke(): + src = _read("app/editor/dialogs.py") + block = src[src.find("class _SignatureCanvas"): + src.find("class _SignatureDialog")] + is_empty = block[block.find("def is_empty"): + block.find("def _all_strokes") if "_all_strokes" in block + else block.find("def to_image")] + assert "self._current" in is_empty + # to_image consumes the in-progress stroke via _all_strokes. + to_image = block[block.find("def to_image"):] + assert "_all_strokes" in to_image + + +# ── #12 — _load_form_fields logs + surfaces failure ────────────────────── + + +def test_load_form_fields_logs_and_shows_status(): + src = _read("app/editor/tab.py") + block = src[src.find("def _load_form_fields"): + src.find("def _on_draw_color_changed")] + assert "_log.warning" in block + assert "editor.forms.load_failed" in block + assert "self._form_status.setText" in block + # Distinguishes "no fields" from "load failed". + assert "editor.forms.no_fields" in block + + +# ── LOWs ───────────────────────────────────────────────────────────────── + + +def test_labels_dict_uses_get_with_default(): + src = _read("app/editor/tab.py") + block = src[src.find("def _add"): + src.find("def _undo")] + assert "labels.get(edit[\"type\"]" in block + + +def test_inline_edit_uses_theme_color(): + src = _read("app/editor/canvas.py") + style_block = src[src.find("def _style_inline_edit"): + src.find("def _reposition_inline")] + # Hardcoded #FFFFFFE6 must no longer appear in the actual style + # string — only in the comment explaining what changed. + assert "background: #FFFFFFE6" not in style_block + # Theme-aware bg lookup. + assert "_LI" in style_block or "self._bg_color" in style_block + + +def test_signature_import_filter_includes_tiff(): + src = _read("app/editor/dialogs.py") + block = src[src.find("def _pick_image"): + src.find("def _validate_tab") if "_validate_tab" in src + else src.find("def _on_accept")] + assert "*.tif" in block + + +def test_inline_commit_resets_insert_state(): + src = _read("app/editor/canvas.py") + commit = src[src.find("def _commit_inline"): + src.find("def _cancel_inline")] + # All four insert-state attributes get reset. + assert "self._inline_insert_font = " in commit + assert "self._inline_insert_size = 12" in commit + assert "self._inline_insert_color = (0, 0, 0)" in commit + assert "self._inline_original = " in commit + + +# ── i18n parity ────────────────────────────────────────────────────────── + + +def test_new_i18n_keys_present_in_every_locale(): + """Each new key must exist for every shipping locale.""" + data = json.loads((ROOT / "app" / "translations.json") + .read_text(encoding="utf-8")) + locales = list(data.keys()) + assert len(locales) == 8, f"Expected 8 locales, got {locales}" + new_keys = [ + "editor.encrypt.warning_title", + "editor.encrypt.warning_text", + "editor.encrypt.save_protected", + "editor.encrypt.save_unprotected", + "editor.forms.has_pending", + "editor.forms.undo_unavailable", + "editor.forms.load_failed", + "editor.forms.no_fields", + "editor.signature.empty_draw", + "editor.signature.empty_type", + "editor.signature.empty_import", + "edit.label.note_delete", + ] + for key in new_keys: + for loc in locales: + val = data[loc].get(key) + assert val, f"locale {loc!r} missing key {key!r}" + + +def test_translations_parity_unchanged(): + """All locales must carry the same key set — drift would indicate + a partially-translated PR.""" + data = json.loads((ROOT / "app" / "translations.json") + .read_text(encoding="utf-8")) + ref = set(data["en"].keys()) + for loc, payload in data.items(): + diff = ref.symmetric_difference(set(payload.keys())) + assert not diff, f"locale {loc!r} diverges from en by {len(diff)} keys" + + +# ── Behavioural sanity: _on_note_deleted with a tiny stub ──────────────── + + +def test_note_deleted_keeps_undo_redo_in_sync(): + """Stand-in for behavioural coverage of the redo-push without + spinning up a real Qt event loop. Mirrors the pattern from + tests/test_editor_undo.py.""" + pytest.importorskip("PySide6.QtWidgets") + from PySide6.QtWidgets import QApplication, QListWidget + app = QApplication.instance() or QApplication([]) + from app.editor.tab import TabEditar + + class _Stub: + _MAX_REDO = TabEditar._MAX_REDO + _MAX_PENDING = TabEditar._MAX_PENDING + + def __init__(self): + self._pending = [] + self._redo_stack = [] + self._pending_list = QListWidget() + + class _CV: + def set_overlays(self, _ovs): pass + + self._canvas = _CV() + + def _status(self, *a, **kw): pass + + _on_note_deleted = TabEditar._on_note_deleted + + s = _Stub() + overlay = {"type": "note", "page": 0, "text": "hello"} + s._pending.append(dict(overlay)) + s._pending_list.addItem("note") + + assert len(s._pending) == 1 + s._on_note_deleted(overlay) + # Pending dropped, redo grew. + assert len(s._pending) == 0 + assert len(s._redo_stack) == 1 + assert s._pending_list.count() == 0 + + +def test_overlay_pixmap_cache_returns_same_instance(tmp_path): + """Cache check: same (path, mtime) tuple returns the same QPixmap + instance — proves the LRU is actually wired.""" + pytest.importorskip("PySide6.QtWidgets") + from PySide6.QtWidgets import QApplication + app = QApplication.instance() or QApplication([]) + + from app.editor.canvas import _load_overlay_pixmap + + # Use a tiny png we can write deterministically. + src = tmp_path / "sig.png" + src.write_bytes( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4" + b"\x00\x00\x00\x00IEND\xaeB`\x82" + ) + mtime = src.stat().st_mtime + pix_a = _load_overlay_pixmap(str(src), mtime) + pix_b = _load_overlay_pixmap(str(src), mtime) + assert pix_a is pix_b, "LRU cache should return identical object" From bc40e3149f6e67a2af811e64f2e267427ce311ac Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:13:28 +0100 Subject: [PATCH 10/11] fix(editor): persist existing-note deletion via _pending path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note delete persistence was only triggered on late-discovery via _annot_note_at. The common case (notes loaded in _load_existing_annotations and queued into _pending with _existing=True) never reached the delete_annot enqueue branch — the first for loop in _on_note_deleted matched on text+page and returned early, leaving the dedicated _existing block unreachable. Now both paths enqueue a delete_annot edit, fully persisting the deletion to the output file. The original note dict is stashed on the edit (_original_note) so _undo can restore the canvas overlay if the user reverses the action. Additionally: - _undo now re-adds the original note to _pending when a delete_annot is rolled back, keeping the canvas in sync with the pending queue. - New test_note_deleted_existing_enqueues_delete_annot covers the enqueue + undo-restore behaviour end-to-end with a Qt-light stub. - Optional cleanup: dropped the dead pass-only elif branch in canvas.mouseReleaseEvent and removed the redundant try/except around _fitz_permissions_of (already returns -1 on any failure). Co-Authored-By: Claude Opus 4.7 --- app/editor/canvas.py | 7 --- app/editor/tab.py | 58 ++++++++++++++++++++++--- tests/test_editor_audit_r9.py | 81 +++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 14 deletions(-) diff --git a/app/editor/canvas.py b/app/editor/canvas.py index 4a5ead9..992befd 100644 --- a/app/editor/canvas.py +++ b/app/editor/canvas.py @@ -912,13 +912,6 @@ def mouseReleaseEvent(self, e): pdf_rect = self._rect_to_pdf(page_idx, local_rect) if pdf_rect is not None: self.rect_selected.emit(page_idx, pdf_rect) - elif self._drag_rect and (self._drag_rect.width() <= 3 - or self._drag_rect.height() <= 3): - # LOW: tiny drags previously fell through to the click - # handler which felt unresponsive — emit a status hint via - # the click handler's normal path so the user still gets - # feedback. - pass else: hit = self._note_icon_at(pos) if hit < 0: diff --git a/app/editor/tab.py b/app/editor/tab.py index a21efa0..a21d8c2 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -963,6 +963,19 @@ def _undo(self): if len(self._redo_stack) > self._MAX_REDO: self._redo_stack.pop(0) self._pending_list.takeItem(self._pending_list.count() - 1) + # Reversing a delete_annot edit must also bring the original note + # overlay back onto the canvas, otherwise the user sees the + # ``delete_annot`` removed from the side-list but no visible + # reappearance — overlay state stays out of sync with _pending + # until the next save/load. The original note dict is stashed on + # the edit at delete time (see ``_on_note_deleted``). + if edit.get("type") == "delete_annot": + original = edit.get("_original_note") + if isinstance(original, dict): + self._pending.append(original) + page = original.get("page", 0) + self._pending_list.addItem( + t("edit.status.note_label", n=(page or 0) + 1)) self._canvas.set_overlays(self._pending) self._status(t("edit.status.undo", n=len(self._pending))) @@ -1017,7 +1030,12 @@ def _on_note_deleted(self, overlay: dict): * the overlay was an *existing* annotation already present in the source PDF — register a ``delete_annot`` pending edit so the deletion survives the ``release_doc()/fitz.open`` round-trip - performed inside ``_run``. + performed inside ``_run``. Existing notes loaded by + ``_load_existing_annotations`` already live in ``_pending`` with + ``_existing=True``, so we both drop the note entry AND append a + ``delete_annot`` edit to enforce the deletion at save time. The + original note dict is stashed on the edit so ``_undo`` can + restore the overlay if the user reverses the action. """ text = overlay.get("text", "").strip() page = overlay.get("page") @@ -1031,10 +1049,36 @@ def _on_note_deleted(self, overlay: dict): self._redo_stack.append(removed) if len(self._redo_stack) > self._MAX_REDO: self._redo_stack.pop(0) + # CRIT: existing notes (loaded from the source PDF in + # ``_load_existing_annotations``) live in ``_pending`` with + # ``_existing=True``. Dropping them from ``_pending`` alone + # does NOT persist the deletion — ``_run`` reopens the file + # from disk and the original annotation survives. Enqueue a + # ``delete_annot`` edit so the save loop removes it. + if removed.get("_existing"): + edit = { + "type": "delete_annot", + "page": removed.get("page"), + "annot_type": removed.get("_annot_type"), + "bbox": removed.get("_annot_bbox"), + "_existing": True, + # Stash the original note so ``_undo`` can put it + # back on the canvas if the user reverses the + # deletion before saving. + "_original_note": removed, + } + self._pending.append(edit) + suffix = t("edit.label.page_suffix", + n=((removed.get("page") or 0) + 1)) + self._pending_list.addItem( + t("edit.label.note_delete") + suffix) + self._canvas.set_overlays(self._pending) return if overlay.get("_existing"): - # Already-saved annotation: register a pending deletion so - # _run actually drops it from the output file. + # Fallback path: the overlay was discovered late (via + # ``_annot_note_at`` in the canvas) and is NOT in + # ``_pending``. Register a pending deletion so ``_run`` + # actually drops it from the output file. edit = { "type": "delete_annot", "page": page, @@ -1181,10 +1225,10 @@ def _run(self): # — the original owner password is not recoverable from # the input file. Future enhancement: ask the user for # a separate owner password. - try: - perms = self._fitz_permissions_of(doc) - except Exception: - perms = -1 + # ``_fitz_permissions_of`` already returns -1 on any + # internal failure (PyMuPDF sentinel for "all perms"), + # so a wrapping try/except here would be dead code. + perms = self._fitz_permissions_of(doc) doc.save( tmp, garbage=4, deflate=True, encryption=fitz.PDF_ENCRYPT_AES_256, diff --git a/tests/test_editor_audit_r9.py b/tests/test_editor_audit_r9.py index 5d2af05..7b8794e 100644 --- a/tests/test_editor_audit_r9.py +++ b/tests/test_editor_audit_r9.py @@ -394,6 +394,87 @@ def _status(self, *a, **kw): pass assert s._pending_list.count() == 0 +def test_note_deleted_existing_enqueues_delete_annot(): + """When an existing PDF annotation already in ``_pending`` (loaded by + ``_load_existing_annotations`` with ``_existing=True``) is deleted via + the canvas context menu, ``_on_note_deleted`` must: + + * pop the original note from ``_pending`` + * push the original onto ``_redo_stack`` (so Ctrl+Z works) + * append a ``delete_annot`` edit so the save loop actually drops the + annotation from the output file + * stash the original note on the edit (``_original_note``) so the + undo path can put it back on the canvas + + Regression for PR-D review finding #1: the first ``for`` loop in + ``_on_note_deleted`` returned early and the ``delete_annot`` branch + after it was never reached for the common case.""" + pytest.importorskip("PySide6.QtWidgets") + from PySide6.QtWidgets import QApplication, QListWidget + app = QApplication.instance() or QApplication([]) + from app.editor.tab import TabEditar + + class _Stub: + _MAX_REDO = TabEditar._MAX_REDO + _MAX_PENDING = TabEditar._MAX_PENDING + + def __init__(self): + self._pending = [] + self._redo_stack = [] + self._pending_list = QListWidget() + + class _CV: + def __init__(self): self.set_overlays_calls = 0 + def set_overlays(self, _ovs): self.set_overlays_calls += 1 + + self._canvas = _CV() + + def _status(self, *a, **kw): pass + + _on_note_deleted = TabEditar._on_note_deleted + _undo = TabEditar._undo + + s = _Stub() + bbox = [10.0, 20.0, 30.0, 40.0] + existing = { + "type": "note", + "page": 2, + "text": "hello", + "_existing": True, + "_annot_type": 0, # fitz.PDF_ANNOT_TEXT + "_annot_bbox": bbox, + } + s._pending.append(existing) + s._pending_list.addItem("note") + + # Caller hands us the overlay dict (same structure as _pending entry). + s._on_note_deleted(dict(existing)) + + # The original note is gone from _pending, redo_stack has it. + assert len(s._redo_stack) == 1 + assert s._redo_stack[0].get("_existing") is True + # A delete_annot edit must have been enqueued in its place. + types = [e.get("type") for e in s._pending] + assert "delete_annot" in types, ( + "missing delete_annot — existing-note deletion will not persist") + da = next(e for e in s._pending if e.get("type") == "delete_annot") + assert da["page"] == 2 + assert da["bbox"] == bbox + assert da["annot_type"] == 0 + assert da.get("_existing") is True + # Original note stashed for undo restore. + assert da.get("_original_note", {}).get("text") == "hello" + + # ── Undo behaviour: rolling back the delete_annot must put the + # original note back into _pending so the canvas overlay reappears. + s._undo() + assert all(e.get("type") != "delete_annot" for e in s._pending) + notes = [e for e in s._pending + if e.get("type") == "note" and e.get("text") == "hello"] + assert len(notes) == 1, ( + "undo of delete_annot must restore the original note overlay") + + def test_overlay_pixmap_cache_returns_same_instance(tmp_path): """Cache check: same (path, mtime) tuple returns the same QPixmap instance — proves the LRU is actually wired.""" From e39b62595b2177a0d89f8addd925d84c9d3f4467 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Fri, 5 Jun 2026 20:41:50 +0100 Subject: [PATCH 11/11] chore(editor): cosmetic cleanup per CodeQL notes - Remove dead _MODE_* constants in editor/tab.py (declared but never read). - Drop orphan `import re` in tests/test_editor_audit_r9.py. - Rename `app = QApplication(...)` to `_app = ...` in 3 test bodies to match the suppression pattern used elsewhere. --- app/editor/tab.py | 6 ------ tests/test_editor_audit_r9.py | 7 +++---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/app/editor/tab.py b/app/editor/tab.py index a21d8c2..f3dd422 100644 --- a/app/editor/tab.py +++ b/app/editor/tab.py @@ -29,15 +29,9 @@ # Mode indices in `_mode_btns` — kept as constants for readability so # call-sites like `if self._mode_idx == _MODE_FORMS:` document intent # without forcing a refactor of the existing numeric layout. -_MODE_REDACT = 0 -_MODE_TEXT = 1 _MODE_IMAGE = 2 -_MODE_HIGHLIGHT = 3 -_MODE_NOTE = 4 _MODE_FORMS = 5 _MODE_SIGNATURE = 6 -_MODE_DRAW = 7 -_MODE_SELECT = 8 class TabEditar(QWidget): diff --git a/tests/test_editor_audit_r9.py b/tests/test_editor_audit_r9.py index 7b8794e..8f40eee 100644 --- a/tests/test_editor_audit_r9.py +++ b/tests/test_editor_audit_r9.py @@ -22,7 +22,6 @@ """ import json -import re from pathlib import Path import pytest @@ -360,7 +359,7 @@ def test_note_deleted_keeps_undo_redo_in_sync(): tests/test_editor_undo.py.""" pytest.importorskip("PySide6.QtWidgets") from PySide6.QtWidgets import QApplication, QListWidget - app = QApplication.instance() or QApplication([]) + _app = QApplication.instance() or QApplication([]) from app.editor.tab import TabEditar class _Stub: @@ -411,7 +410,7 @@ def test_note_deleted_existing_enqueues_delete_annot(): after it was never reached for the common case.""" pytest.importorskip("PySide6.QtWidgets") from PySide6.QtWidgets import QApplication, QListWidget - app = QApplication.instance() or QApplication([]) + _app = QApplication.instance() or QApplication([]) from app.editor.tab import TabEditar class _Stub: @@ -480,7 +479,7 @@ def test_overlay_pixmap_cache_returns_same_instance(tmp_path): instance — proves the LRU is actually wired.""" pytest.importorskip("PySide6.QtWidgets") from PySide6.QtWidgets import QApplication - app = QApplication.instance() or QApplication([]) + _app = QApplication.instance() or QApplication([]) from app.editor.canvas import _load_overlay_pixmap