diff --git a/app/editor/canvas.py b/app/editor/canvas.py index bae78d7..992befd 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,9 @@ 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) else: hit = self._note_icon_at(pos) if hit < 0: 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) diff --git a/app/editor/tab.py b/app/editor/tab.py index 3671a31..f3dd422 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,17 @@ 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_IMAGE = 2 +_MODE_FORMS = 5 +_MODE_SIGNATURE = 6 + + class TabEditar(QWidget): """Visual editor: click/drag directly on the rendered PDF.""" @@ -250,6 +262,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 @@ -500,6 +520,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( @@ -524,9 +558,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() @@ -603,6 +643,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)) @@ -686,19 +733,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 ───────────────────────────────────────────────────── @@ -874,14 +928,28 @@ 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))) 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() @@ -889,6 +957,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))) @@ -898,15 +979,111 @@ 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.""" + """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``. 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") 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) + # 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"): + # 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, + "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() @@ -926,8 +1103,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: @@ -935,10 +1125,21 @@ 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"): + if e.get("_existing") and e.get("type") != "delete_annot": continue # already saved in the PDF pg = doc[e["page"]] if e["type"] == "redact": @@ -971,6 +1172,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)) @@ -999,7 +1213,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. + # ``_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, + 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 +1248,49 @@ 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) + # 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. + 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) 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 +} diff --git a/tests/test_editor_audit_r9.py b/tests/test_editor_audit_r9.py new file mode 100644 index 0000000..8f40eee --- /dev/null +++ b/tests/test_editor_audit_r9.py @@ -0,0 +1,497 @@ +"""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 +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_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.""" + 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"