Skip to content

fix(editor): 13 audit findings (1 CRIT + 7 HIGH + 5 MED + LOWs)#82

Merged
nelsonduarte merged 11 commits into
mainfrom
fix/editor-audit-r9
Jun 5, 2026
Merged

fix(editor): 13 audit findings (1 CRIT + 7 HIGH + 5 MED + LOWs)#82
nelsonduarte merged 11 commits into
mainfrom
fix/editor-audit-r9

Conversation

@nelsonduarte
Copy link
Copy Markdown
Owner

Summary

Closes 13 editor-tool bugs from Round 9 audit. The CRIT is silent dataloss of encryption; 7 HIGHs cover persistence, performance, UX and lifecycle; 5 MEDs and several LOWs are polish.

Critical

  • Encryption silently stripped on save (editor/tab.py) — doc.save(...) and writer.write(f) lacked encryption=. PDFs protected with a password were saved as plaintext. Now prompts with QMessageBox.question (3 options: Keep / Save unprotected / Cancel; default = Keep). Re-encrypts with AES-256 using self._pdf_password as both user and owner (documented limitation: owner pwd not recoverable from a single load prompt). Permissions preserved via _fitz_permissions_of helper.

HIGH

  • Forms mode discarded pending edits — early return in _run bypassed _pending. Now warns user with QMessageBox.question (defaultButton=No).
  • Existing-note deletion did not persistdelete_annot mutated _canvas._doc but _run reopened from disk. Now enqueues a delete_annot edit via _pending for BOTH common path (notes loaded at init with _existing=True) AND late-discovery path. Save loop processes them via bbox+type match. Undo restores via stashed _original_note.
  • Image mode reopened dialog every time_pick_image() was called every mode-switch. Now guarded by os.path.isfile.
  • QPixmap loaded from disk per paintEvent — N overlays * 60fps = I/O thrash. Now cached via @lru_cache(maxsize=64) keyed by (path, mtime).
  • Note delete not in _redo_stackCtrl+Z recovered an arbitrary prior edit. Now pushes the original to redo stack symmetrically.
  • Signature dialog mute on empty — Draw vazio / Type em branco — OK silent. Now QMessageBox.warning + early return.

MEDIUM

  • Forms edits not in _pending -> tooltip swap warning Ctrl+Z unavailable in Forms mode.
  • Mode change during _inline_edit left typed text in limbo -> now _cancel_inline() on mode change.
  • Redact cross-page rect not clipped -> now pdf_rect.intersect(page_rect) + reject empty.
  • Signature in-progress stroke ignored -> is_empty and to_image now include _current.
  • _load_form_fields swallowed exceptions -> logged via _log.warning + status feedback.

LOWs (polish bundle)

  • labels.get(edit['type'], edit['type']) — KeyError safety
  • enumerate(self._overlays) — O(n^2) avoided
  • _inline_edit background uses theme color (was hardcoded #FFFFFFE6)
  • Signature dialog file filter includes *.tif/*.tiff
  • _apply_forms(..., auto_regenerate=True) for viewer compatibility
  • _inline_insert_size/_color reset on commit
  • Removed dead pass branch in canvas.py:914
  • Removed redundant try/except around _fitz_permissions_of

i18n

12 new keys * 8 languages (en/pt/es/fr/de/zh/it/nl):

  • editor.encrypt.warning_title/text/save_protected/save_unprotected
  • editor.forms.has_pending/undo_unavailable/load_failed/no_fields
  • editor.signature.empty_draw/empty_type/empty_import
  • edit.label.note_delete

Parity verified: 8 * 595 keys = 4760 total.

Tests

tests/test_editor_audit_r9.py — 31 source-level + behavioral tests (30 initial + 1 added for review finding). Behavioral: LRU pixmap identity, note-deleted redo sync (both pending + existing branches).

Final: 226 passed, 1 failed (Flatpak tag pre-existing), 1 skipped.

Audit context

Round 9 was a focused drill-down per editor tool (vs the cross-cutting Rounds 5-8). After this PR, the editor reaches CRIT/HIGH zero from the catalogued audit. Round 10 (suggested by reviewer): viewer tools for similar persistence/encryption strip gaps.

Limitations documented

  • Re-encryption uses user pwd as owner (original owner not recoverable from single load prompt).
  • delete_annot matching by (annot_type, bbox) with 1pt tolerance — two identical overlapping annots would match only the first.
  • Forms undo: simple implementation (tooltip warning) vs complex (cell-change tracking in _pending).
  • LRU cache invalidates by mtime — touch with same mtime would be stale (impossible in practice).

Validation

  • 10 atomic commits
  • pytest 226/1/1
  • No new pyflakes warnings
  • No APP_VERSION bump
  • Compatible with all merged PRs (A/B/C/MSIX/presentation/converters)

nelsonduarte and others added 10 commits June 5, 2026 19:58
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@nelsonduarte nelsonduarte added bug Something isn't working enhancement New feature or request labels Jun 5, 2026
Comment thread app/editor/tab.py Fixed
Comment thread app/editor/tab.py Fixed
Comment thread app/editor/tab.py Fixed
Comment thread app/editor/tab.py Fixed
Comment thread app/editor/tab.py Fixed
Comment thread app/editor/tab.py Fixed
Comment thread tests/test_editor_audit_r9.py Fixed
Comment thread tests/test_editor_audit_r9.py Fixed
Comment thread tests/test_editor_audit_r9.py Fixed
Comment thread tests/test_editor_audit_r9.py Fixed
- 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.
tests/test_editor_undo.py."""
pytest.importorskip("PySide6.QtWidgets")
from PySide6.QtWidgets import QApplication, QListWidget
_app = QApplication.instance() or QApplication([])
after it was never reached for the common case."""
pytest.importorskip("PySide6.QtWidgets")
from PySide6.QtWidgets import QApplication, QListWidget
_app = QApplication.instance() or QApplication([])
instance — proves the LRU is actually wired."""
pytest.importorskip("PySide6.QtWidgets")
from PySide6.QtWidgets import QApplication
_app = QApplication.instance() or QApplication([])
@nelsonduarte nelsonduarte merged commit d255b74 into main Jun 5, 2026
3 checks passed
@nelsonduarte nelsonduarte deleted the fix/editor-audit-r9 branch June 5, 2026 19:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants