Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,24 @@ jobs:
], True),
("aur/pdfapps/.SRCINFO", [
(rf'pkgver = {ANY}', f'pkgver = {new}'),
(rf'pdfapps-{ANY}', f'pdfapps-{new}'),
(rf'v{ANY}', f'v{new}'),
# R10 #9: anchor the source-tarball replacement to
# the ``.tar.gz`` suffix + URL contexts. The previous
# unanchored ``v{ANY}`` would happily rewrite a
# future comment line like ``# since v1.10.0``.
(rf'pdfapps-{ANY}\.tar\.gz', f'pdfapps-{new}.tar.gz'),
(rf'/v{ANY}\.tar\.gz', f'/v{new}.tar.gz'),
], True),
("aur/pdfapps-bin/PKGBUILD", [
(rf'pkgver={ANY}', f'pkgver={new}'),
], True),
("aur/pdfapps-bin/.SRCINFO", [
(rf'pkgver = {ANY}', f'pkgver = {new}'),
(rf'provides = pdfapps={ANY}', f'provides = pdfapps={new}'),
(rf'v{ANY}', f'v{new}'),
(rf'pdfapps-{ANY}', f'pdfapps-{new}'),
# R10 #9: as for the source AUR variant — anchor on
# path/tarball context so a future "# since v1.10.0"
# comment is not rewritten by an unanchored v{ANY}.
(rf'/v{ANY}/', f'/v{new}/'),
(rf'pdfapps-{ANY}\.tar\.gz', f'pdfapps-{new}.tar.gz'),
], True),
("winget/nelsonduarte.PDFApps.installer.yaml", [
(rf'PackageVersion:\s*{ANY}', f'PackageVersion: {new}'),
Expand Down
75 changes: 64 additions & 11 deletions app/editor/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

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
Expand All @@ -18,19 +17,50 @@

_ICON_CURSORS: dict = {}

# Manual FIFO cache for overlay QPixmaps (R10 #3).
# The previous ``@lru_cache(maxsize=64)`` had no clear() hook the tab
# close path could call — across long-running sessions with several
# tabs each cache slot may hold a multi-MB QPixmap, leaking until
# process exit. The explicit dict gives us:
# - eviction by FIFO insertion order (next(iter(dict)) is O(1));
# - a clear_overlay_pixmap_cache() public helper for tab teardown;
# - an "uncached" code path for mtime lookup failures (R10 #11) so an
# OSError doesn't poison the cache with a null QPixmap that stays
# forever even after the file reappears on disk.
_OVERLAY_PIXMAP_CACHE: "dict[tuple[str, float], QPixmap]" = {}
_OVERLAY_PIXMAP_CACHE_MAX = 64

@lru_cache(maxsize=64)
def _load_overlay_pixmap(path: str, _mtime: float) -> QPixmap:
"""LRU-cached QPixmap loader for overlay image/signature stamps.

def _load_overlay_pixmap(path: str, mtime: float) -> QPixmap:
"""Explicit FIFO-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).
``mtime`` parameter participates in the cache key so the cache
auto-invalidates when the underlying file is rewritten (e.g.
signature regenerated on disk).
"""
key = (path, mtime)
pix = _OVERLAY_PIXMAP_CACHE.get(key)
if pix is None:
pix = QPixmap(path)
if len(_OVERLAY_PIXMAP_CACHE) >= _OVERLAY_PIXMAP_CACHE_MAX:
# Evict the oldest entry (insertion order is preserved
# from Python 3.7+).
_OVERLAY_PIXMAP_CACHE.pop(next(iter(_OVERLAY_PIXMAP_CACHE)))
_OVERLAY_PIXMAP_CACHE[key] = pix
return pix


def clear_overlay_pixmap_cache() -> None:
"""Drop every cached overlay QPixmap.

Called from :meth:`PdfEditCanvas.close_doc` so closing a tab
releases the (potentially many MB worth of) pixmaps held by the
module-level cache. Safe to call multiple times.
"""
return QPixmap(path)
_OVERLAY_PIXMAP_CACHE.clear()


def _get_icon_cursor(icon_name: str, hx: int, hy: int,
Expand Down Expand Up @@ -488,6 +518,11 @@ def close_doc(self):
self._page_pixmaps.clear()
self._page_offsets.clear()
self._overlays = []; self._open_note = None
# R10 #3: drop module-level overlay QPixmap cache so closing
# a tab actually releases the (potentially many MB) of cached
# image/signature pixmaps. The previous lru_cache had no
# clear hook and grew unboundedly across long sessions.
clear_overlay_pixmap_cache()
self.setMinimumSize(300, 400)
self.setMaximumSize(16777215, 16777215)
self.update()
Expand Down Expand Up @@ -684,11 +719,16 @@ def paintEvent(self, _):
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)))
path = e["path"]
# R10 #11: if getmtime fails (file removed, permission
# error, transient FS issue) we MUST NOT cache the
# resulting null QPixmap — otherwise the cache would
# keep returning the empty pixmap even after the file
# comes back. Load uncached in that case.
try:
mtime = os.path.getmtime(path)
img_px = _load_overlay_pixmap(path, mtime)
except OSError:
mtime = 0.0
img_px = _load_overlay_pixmap(path, mtime)
img_px = QPixmap(path)
if not img_px.isNull():
p.drawPixmap(qr, img_px)
border = "#22C55E" if etype == "signature" else ACCENT
Expand Down Expand Up @@ -860,11 +900,24 @@ def contextMenuEvent(self, e):
if hit < 0:
hit, _ = self._annot_note_at(pos)
if hit >= 0:
from PySide6.QtWidgets import QMenu
from PySide6.QtWidgets import QMenu, QMessageBox
menu = QMenu(self)
delete_action = menu.addAction(t("viewer.delete_comment"))
action = menu.exec(e.globalPos())
if action == delete_action:
# R10 #8: match the viewer's UX — delete is destructive
# (especially for _existing notes which persist on save),
# so confirm before pulling the trigger. defaultButton
# is No so a stray Enter cannot wipe a note.
reply = QMessageBox.question(
self, t("msg.confirm"),
t("viewer.confirm_delete_comment"),
QMessageBox.StandardButton.Yes
| QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
overlay = self._overlays[hit]
if self._doc and overlay.get("_existing"):
import fitz
Expand Down
46 changes: 35 additions & 11 deletions app/editor/tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -1122,22 +1122,35 @@ def _run(self):
QMessageBox.warning(self, t("msg.warning"), t("msg.no_pending")); return
try:
import fitz
# 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.
# CRIT-2 (R10): peek the encryption status BEFORE releasing
# the canvas. PR-D moved release_doc() ahead of the prompt
# so the canvas dropped its _doc reference even when the
# user then cancelled the encryption dialog — leaving the
# canvas stuck on the placeholder until the user manually
# reloaded the file. Now we open a short-lived peek doc,
# ask the user how to save, and only release the canvas
# once we know we will proceed.
peek = fitz.open(self._doc_path)
was_encrypted = bool(peek.needs_pass)
if was_encrypted and self._pdf_password:
peek.authenticate(self._pdf_password)
encrypt_choice = "plaintext"
if was_encrypted and self._pdf_password:
encrypt_choice = self._prompt_encryption_choice()
if encrypt_choice is None:
doc.close()
# User cancelled — peek must be closed BUT the
# canvas must still hold the original doc so the
# editor view survives the dismiss.
peek.close()
return
peek.close()
# Encryption choice confirmed (or no prompt needed) —
# now safe to release the canvas's file lock so the
# real save reopen can take exclusive access.
self._canvas.release_doc()
doc = fitz.open(self._doc_path)
if doc.needs_pass and self._pdf_password:
doc.authenticate(self._pdf_password)
for e in self._pending:
if e.get("_existing") and e.get("type") != "delete_annot":
continue # already saved in the PDF
Expand Down Expand Up @@ -1276,6 +1289,17 @@ def _apply_forms(self, out):
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())}
# R10 #6: pypdf's update_page_form_field_values raises
# PyPdfError("No /AcroForm dictionary in PDF…") on PDFs
# without form fields. The user hits this whenever they
# click Apply in Forms mode on a regular PDF; the cryptic
# error message looked like an internal crash. Detect
# up-front and short-circuit with a friendly status
# instead, leaving the file untouched.
if "/AcroForm" not in writer._root_object:
self._status(t("editor.forms.no_fields"))
self._form_status.setText(t("editor.forms.no_fields"))
return
for page in writer.pages:
# auto_regenerate=True so the rendered widget appearance
# actually picks up the new value when viewed in a third-
Expand Down
8 changes: 8 additions & 0 deletions app/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"viewer.error_open_msg": "Could not open the file:\n{ex}",
"viewer.delete_comment": "Delete comment",
"viewer.confirm_delete_comment": "Are you sure you want to delete this comment? This change is saved immediately and cannot be undone.",
"viewer.drop_url_not_supported": "Online URLs are not supported. Please download the PDF first, then drag the local file here.",
"viewer.copy_chars": " Copy ({n} chars)",
"search.placeholder": "Search in PDF...",
"search.prev": "Previous match",
Expand Down Expand Up @@ -657,6 +658,7 @@
"viewer.error_open_msg": "Não foi possível abrir o ficheiro:\n{ex}",
"viewer.delete_comment": "Apagar comentário",
"viewer.confirm_delete_comment": "Tens a certeza que queres apagar este comentário? Esta alteração é guardada imediatamente e não pode ser desfeita.",
"viewer.drop_url_not_supported": "URLs da internet não são suportados. Descarrega primeiro o PDF e depois arrasta o ficheiro local para aqui.",
"viewer.copy_chars": " Copiar ({n} car.)",
"search.placeholder": "Pesquisar no PDF...",
"search.prev": "Resultado anterior",
Expand Down Expand Up @@ -1254,6 +1256,7 @@
"viewer.error_open_msg": "No se pudo abrir el archivo:\n{ex}",
"viewer.delete_comment": "Eliminar comentario",
"viewer.confirm_delete_comment": "¿Seguro que quieres eliminar este comentario? Este cambio se guarda inmediatamente y no se puede deshacer.",
"viewer.drop_url_not_supported": "Las URLs en línea no son compatibles. Descarga primero el PDF y luego arrastra el archivo local aquí.",
"viewer.copy_chars": " Copiar ({n} caract.)",
"search.placeholder": "Buscar en PDF...",
"search.prev": "Resultado anterior",
Expand Down Expand Up @@ -1851,6 +1854,7 @@
"viewer.error_open_msg": "Impossible d'ouvrir le fichier :\n{ex}",
"viewer.delete_comment": "Supprimer le commentaire",
"viewer.confirm_delete_comment": "Voulez-vous vraiment supprimer ce commentaire ? Cette modification est enregistrée immédiatement et ne peut pas être annulée.",
"viewer.drop_url_not_supported": "Les URL en ligne ne sont pas prises en charge. Téléchargez d'abord le PDF, puis faites glisser le fichier local ici.",
"viewer.copy_chars": " Copier ({n} caract.)",
"search.placeholder": "Rechercher dans le PDF...",
"search.prev": "Résultat précédent",
Expand Down Expand Up @@ -2448,6 +2452,7 @@
"viewer.error_open_msg": "Die Datei konnte nicht geöffnet werden:\n{ex}",
"viewer.delete_comment": "Kommentar löschen",
"viewer.confirm_delete_comment": "Soll dieser Kommentar wirklich gelöscht werden? Diese Änderung wird sofort gespeichert und kann nicht rückgängig gemacht werden.",
"viewer.drop_url_not_supported": "Online-URLs werden nicht unterstützt. Laden Sie die PDF zuerst herunter und ziehen Sie dann die lokale Datei hierher.",
"viewer.copy_chars": " Kopieren ({n} Zeichen)",
"search.placeholder": "Im PDF suchen...",
"search.prev": "Vorheriges Ergebnis",
Expand Down Expand Up @@ -3045,6 +3050,7 @@
"viewer.error_open_msg": "无法打开文件:\n{ex}",
"viewer.delete_comment": "删除批注",
"viewer.confirm_delete_comment": "确定要删除此批注吗?此更改将立即保存且无法撤销。",
"viewer.drop_url_not_supported": "不支持在线 URL。请先下载 PDF,然后将本地文件拖到此处。",
"viewer.copy_chars": " 复制 ({n} 个字符)",
"search.placeholder": "在 PDF 中搜索...",
"search.prev": "上一个匹配",
Expand Down Expand Up @@ -3642,6 +3648,7 @@
"viewer.error_open_msg": "Impossibile aprire il file:\n{ex}",
"viewer.delete_comment": "Elimina commento",
"viewer.confirm_delete_comment": "Sei sicuro di voler eliminare questo commento? Questa modifica viene salvata immediatamente e non può essere annullata.",
"viewer.drop_url_not_supported": "Gli URL online non sono supportati. Scarica prima il PDF, poi trascina il file locale qui.",
"viewer.copy_chars": " Copia ({n} caratteri)",
"search.placeholder": "Cerca nel PDF...",
"search.prev": "Corrispondenza precedente",
Expand Down Expand Up @@ -4239,6 +4246,7 @@
"viewer.error_open_msg": "Kon het bestand niet openen:\n{ex}",
"viewer.delete_comment": "Opmerking verwijderen",
"viewer.confirm_delete_comment": "Weet je zeker dat je deze opmerking wilt verwijderen? Deze wijziging wordt onmiddellijk opgeslagen en kan niet ongedaan worden gemaakt.",
"viewer.drop_url_not_supported": "Online-URL's worden niet ondersteund. Download eerst de PDF en sleep vervolgens het lokale bestand hierheen.",
"viewer.copy_chars": " Kopiëren ({n} tekens)",
"search.placeholder": "Zoeken in PDF...",
"search.prev": "Vorige overeenkomst",
Expand Down
Loading