Skip to content
Closed
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
26 changes: 26 additions & 0 deletions app/editor/canvas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""PDFApps – PdfEditCanvas: continuous-scroll visual PDF edit canvas."""

import contextlib

from PySide6.QtCore import Qt, Signal, QRect, QPoint, QObject, QRunnable, QThreadPool, QEvent
from PySide6.QtWidgets import QWidget, QSizePolicy, QLineEdit

Expand Down Expand Up @@ -458,6 +460,30 @@ def close_doc(self):
self.setMaximumSize(16777215, 16777215)
self.update()

# ── DPR change handling (R8/D1) ──────────────────────────────────────
def showEvent(self, event):
"""Re-render pages when the top-level window crosses a screen
with a different devicePixelRatio. ``_schedule_visible`` only
sampled the DPR at scroll/zoom time, so dragging the window from
a 100 % monitor to a 200 % monitor left previously rendered
pages blurry until the user changed zoom (R8/D1)."""
super().showEvent(event)
win = self.window().windowHandle() if self.window() else None
if win:
# Disconnect before reconnecting — re-show events can stack
# the same handler multiple times.
with contextlib.suppress(TypeError, RuntimeError):
win.screenChanged.disconnect(self._on_screen_changed)
win.screenChanged.connect(self._on_screen_changed)

def _on_screen_changed(self, _screen):
"""Drop cached pixmaps and re-queue visible pages at the new DPR."""
self._gen += 1
self._pending.clear()
self._page_pixmaps = [None] * len(self._page_pixmaps)
self._schedule_visible()
self.update()

def on_scroll(self):
"""Called when scroll position changes — renders newly visible pages."""
self._schedule_visible()
Expand Down
25 changes: 24 additions & 1 deletion app/tools/encrypt.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""PDFApps – TabEncriptar: encrypt/decrypt PDF tool."""

import contextlib
import os

from PySide6.QtCore import Qt
Expand Down Expand Up @@ -107,6 +108,22 @@ def _load_input(self, p: str):
def auto_load(self, path: str):
if path and not self.drop_in.path(): self._load_input(path)

def _clear_password_fields(self) -> None:
"""Best-effort wipe of password QLineEdits after encrypt/decrypt run.

QLineEdit text persisted in memory for the entire session prior
to R8-H1. Clearing the field both via ``setText('')`` and
``clear()`` drops the cached display string and any pending
completer state; the underlying QString allocation may still
linger in Qt's heap until GC, same caveat as
``wipe_pdf_password``.
"""
for field in (self.edit_owner, self.edit_owner_confirm,
self.edit_user, self.edit_pwd):
with contextlib.suppress(Exception):
field.setText("")
field.clear()

def _run(self):
pdf_path = self.drop_in.path()
if not pdf_path or not os.path.isfile(pdf_path):
Expand Down Expand Up @@ -153,4 +170,10 @@ def _run(self):
self._pipeline_success(msg, out_path)
else:
QMessageBox.information(self, t("msg.done"), msg)
except Exception as e: show_error(self, e)
except Exception as e:
show_error(self, e)
finally:
# R8-H1: wipe password fields whether _run() succeeded or
# raised. Keeping the user's password in the QLineEdit text
# buffer for the whole session was a needless heap leak.
self._clear_password_fields()
33 changes: 33 additions & 0 deletions app/tools/watermark.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ def _load_input(self, p: str):
def auto_load(self, path: str):
if path and not self.drop_in.path(): self._load_input(path)

def _prompt_watermark_password(self, wm_path: str) -> str | None:
"""Resolve an encryption password for the watermark PDF.

Returns:
"" — watermark PDF is plaintext (no password needed)
"<pwd>" — user supplied a valid password
None — user cancelled the prompt (caller aborts)

Kept separate from ``self._pdf_password`` (which holds the
*source* PDF's password) so a corporate stamp PDF and the user's
own document don't leak credentials into one another.
"""
from app.utils import prompt_pdf_password
ok, pwd = prompt_pdf_password(wm_path, self)
if not ok:
return None
return pwd

def _run(self):
pdf_path = self.drop_in.path(); wm_path = self.drop_wm.path()
if not pdf_path or not os.path.isfile(pdf_path):
Expand All @@ -90,10 +108,23 @@ def _run(self):
out_path = self._resolve_output_file(self.drop_out, pdf_path)
if not out_path: return

# R8 bonus #6: the watermark PDF can itself be encrypted (rare
# but observed when users borrow a corporate stamp PDF).
# PdfReader(wm_path) used to raise PdfReadError cryptically;
# prompt for the watermark's password explicitly here so the
# error surface matches the source-PDF flow. Tracked separately
# from self._pdf_password (which holds the *source* PDF's
# password) so neither leaks into the other.
wm_pwd = self._prompt_watermark_password(wm_path)
if wm_pwd is None:
return # user cancelled

# Pre-flight on the main thread: validate inputs and resolve page
# targets so the worker can be a tight loop with no Qt calls.
try:
wm_reader = PdfReader(wm_path)
if wm_reader.is_encrypted and wm_pwd:
wm_reader.decrypt(wm_pwd)
if not wm_reader.pages:
QMessageBox.warning(self, t("msg.warning"), t("tool.watermark.empty_wm"))
return
Expand All @@ -116,6 +147,8 @@ def do_work(worker):
if r.is_encrypted and pwd:
r.decrypt(pwd)
wm = PdfReader(wm_path)
if wm.is_encrypted and wm_pwd:
wm.decrypt(wm_pwd)
wm_page = wm.pages[0]
w = PdfWriter()
n = len(r.pages)
Expand Down
32 changes: 32 additions & 0 deletions app/viewer/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import contextlib

from PySide6.QtCore import Qt, Signal, QRect, QObject, QRunnable, QThreadPool
from PySide6.QtWidgets import QWidget, QApplication
from PySide6.QtGui import QPixmap, QColor, QPainter, QPen, QFont
Expand Down Expand Up @@ -203,6 +205,36 @@ def close_doc(self):
self.setFixedSize(300, 400)
self.update()

# ── DPR change handling (D1) ─────────────────────────────────────────
def showEvent(self, event):
"""Hook into the top-level QWindow.screenChanged signal so a
screen migration (drag to another monitor) or DPR mutation
(Windows Display Settings change) invalidates the cached
pixmaps and re-renders at the new device pixel ratio.

Without this handler ``_schedule_visible`` only sampled the DPR
on zoom changes, leaving pages blurry until the user interacted
(R8/D1)."""
super().showEvent(event)
win = self.window().windowHandle() if self.window() else None
if win:
# Re-show events may fire after a tab switch — disconnect
# first to avoid stacking duplicate handlers.
with contextlib.suppress(TypeError, RuntimeError):
win.screenChanged.disconnect(self._on_screen_changed)
win.screenChanged.connect(self._on_screen_changed)

def _on_screen_changed(self, _screen):
"""Invalidate cached pixmaps and re-render at the new DPR."""
# Bump generation so any in-flight render jobs are discarded
# by _on_page_ready when they finally land on the main thread.
self._gen += 1
self._pending.clear()
for entry in self._entries:
entry.pixmap = None
self._schedule_visible()
self.update()

# ── Layout ───────────────────────────────────────────────────────────────

def _invalidate_and_relayout(self):
Expand Down
54 changes: 38 additions & 16 deletions app/viewer/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,30 +369,52 @@ def current_path(self) -> str:

# ── TOC / Bookmarks ─────────────────────────────────────────────────
def _populate_toc(self, doc):
"""Read the PDF outline and build the tree. Hides the panel if empty."""
"""Read the PDF outline and build the tree. Hides the panel if empty.

Wrapped in try/except: a malformed outline (cyclic refs, bad
page indexes, unexpected entry shape) used to leave the TOC
panel half-populated and could raise mid-build, surfacing as a
cryptic stack trace. Now the failure is logged and the panel
hides gracefully so the rest of the viewer stays usable
(R8 bonus #7).
"""
self._toc_tree.clear()
try:
toc = doc.get_toc()
except Exception:
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"Failed to read TOC for %s: %s", self._current_path, exc)
toc = []
if not toc:
self._toc_tree.setVisible(False)
self._toc_btn.setVisible(False)
return
# toc is a list of [level, title, page] (page is 1-indexed)
stack = [(0, self._toc_tree.invisibleRootItem())]
for level, title, page in toc:
while stack and stack[-1][0] >= level:
stack.pop()
parent = stack[-1][1] if stack else self._toc_tree.invisibleRootItem()
item = QTreeWidgetItem(parent, [title])
item.setData(0, Qt.ItemDataRole.UserRole, max(0, page - 1))
item.setToolTip(0, title)
stack.append((level, item))
self._toc_tree.expandToDepth(1)
self._toc_tree.setVisible(True)
self._toc_btn.setVisible(True)
self._toc_btn.setEnabled(True)
try:
# toc is a list of [level, title, page] (page is 1-indexed)
stack = [(0, self._toc_tree.invisibleRootItem())]
for level, title, page in toc:
while stack and stack[-1][0] >= level:
stack.pop()
parent = stack[-1][1] if stack else self._toc_tree.invisibleRootItem()
item = QTreeWidgetItem(parent, [title])
item.setData(0, Qt.ItemDataRole.UserRole, max(0, page - 1))
item.setToolTip(0, title)
stack.append((level, item))
self._toc_tree.expandToDepth(1)
self._toc_tree.setVisible(True)
self._toc_btn.setVisible(True)
self._toc_btn.setEnabled(True)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"Failed to build TOC tree for %s: %s",
self._current_path, exc)
# Reset to a known-empty state so a partial build does not
# leave dangling QTreeWidgetItems pointing at invalid pages.
self._toc_tree.clear()
self._toc_tree.setVisible(False)
self._toc_btn.setVisible(False)

def _on_toc_clicked(self, item, column):
page_idx = item.data(0, Qt.ItemDataRole.UserRole)
Expand Down
43 changes: 32 additions & 11 deletions app/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@ def _build_nav_items():


class MainWindow(QMainWindow):
_update_ready = Signal()

def __init__(self):
super().__init__()
self.setWindowTitle(t("app.name"))
Expand Down Expand Up @@ -268,7 +266,8 @@ def _a11y(btn, tip):
self._update_btn.clicked.connect(self._show_update_dialog)
wb_h.addWidget(self._update_btn)
self._update_release = None
self._update_ready.connect(self._notify_update)
self._update_thread = None
self._update_worker = None
self._check_for_updates_async()

root_v.addWidget(self._workspace_bar)
Expand Down Expand Up @@ -1155,13 +1154,11 @@ def closeEvent(self, event):
wait_fn()
# Same for the update-check thread (usually a short HTTP
# request, but the user can close the app immediately on
# launch and Qt will warn if it's still running).
upd = getattr(self, "_update_thread", None)
if upd is not None:
with contextlib.suppress(RuntimeError):
if upd.isRunning():
upd.quit()
upd.wait(1000)
# launch and Qt will warn if it's still running). Also drops
# the QObject worker to release its closure / release dict
# if the user closes the window before the check completes
# (R8-H2 defensive path).
self._release_update_worker()
try:
from app.i18n import _update_config
sizes = self._splitter.sizes()
Expand Down Expand Up @@ -1339,8 +1336,32 @@ def run(self):
self._update_thread.start()

def _on_update_found(self):
self._update_release = self._update_worker.release
if self._update_worker is not None:
self._update_release = self._update_worker.release
self._notify_update()
# R8-H2: the worker QObject lived for the lifetime of the
# application before this — only the QThread was scheduled for
# deleteLater. Drop the worker after the check completes so the
# closure (and its captured release dict) is freed.
self._release_update_worker()

def _release_update_worker(self):
"""Tear down the update worker/thread defensively.

Safe to call from both ``_on_update_found`` (the happy path) and
``closeEvent`` (in case the worker never emitted ``done`` — e.g.
no update available, network failure)."""
worker = getattr(self, "_update_worker", None)
if worker is not None:
with contextlib.suppress(RuntimeError):
worker.deleteLater()
self._update_worker = None
thread = getattr(self, "_update_thread", None)
if thread is not None:
with contextlib.suppress(RuntimeError):
if thread.isRunning():
thread.quit()
thread.wait(1000)

def _notify_update(self):
"""Show update notification dialog automatically."""
Expand Down
43 changes: 39 additions & 4 deletions pdfapps.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""PDFApps – entry point."""
import argparse
import sys
import os

Expand All @@ -21,6 +22,7 @@
"Install PyMuPDF:\n\npip install pymupdf")
sys.exit(1)

from app.constants import APP_VERSION
from app.window import MainWindow
from app.styles import STYLE, STYLE_LIGHT
from app.utils import _make_palette, setup_logging
Expand All @@ -36,8 +38,39 @@ def _load_dark_pref() -> bool:
return True


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse the PDFApps command-line.

Accepting *multiple* positional PDF paths fixes R8-M1: previously
only ``sys.argv[1]`` was loaded, so dragging-and-dropping more than
one file onto the executable (or "Open With" multi-selection on
Windows / macOS) silently dropped every file after the first.
``argparse`` also brings standard ``--help`` / ``--version`` for
free; the old hand-rolled parser treated ``-h`` as an invalid path
and launched into the welcome screen.
"""
parser = argparse.ArgumentParser(
prog="pdfapps",
description="PDFApps — fast desktop PDF editor.",
add_help=True,
)
parser.add_argument(
"files", nargs="*", metavar="PDF",
help=("One or more PDF files to open. Each file opens in its "
"own tab. Defaults to the welcome screen."),
)
parser.add_argument(
"-v", "--version", action="version",
version=f"PDFApps {APP_VERSION}",
)
return parser.parse_args(argv)


def main():
setup_logging()
# Parse BEFORE QApplication so --help / --version exit cleanly
# without bringing up the Qt event loop (and the splash screen).
args = _parse_args(sys.argv[1:])
app = QApplication(sys.argv)
app.setApplicationName(" ")
app.setApplicationDisplayName(" ")
Expand All @@ -49,11 +82,13 @@ def main():
window = MainWindow()
window.show()

# Open PDF passed as argument (e.g.: double-click on a .pdf file)
if len(sys.argv) > 1:
pdf_arg = sys.argv[1]
# Open PDFs passed as arguments (e.g.: double-click on a .pdf file
# or multi-select "Open With" on Windows/macOS). Each valid path
# opens through _load_and_track so it lands in a new tab and is
# appended to the recents list, matching drag-and-drop semantics.
for pdf_arg in args.files:
if os.path.isfile(pdf_arg) and pdf_arg.lower().endswith(".pdf"):
window._viewer.load(pdf_arg)
window._load_and_track(pdf_arg)

sys.exit(app.exec())

Expand Down
Loading