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
6 changes: 5 additions & 1 deletion app/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,11 @@ def _open_reader(self, path: str):
from pypdf import PdfReader
r = PdfReader(path)
if r.is_encrypted and self._pdf_password:
r.decrypt(self._nfc(self._pdf_password))
# R11-M4: pypdf returns 0 on a wrong password and silently
# exposes a reader with zero accessible pages — every
# downstream tool then writes an empty PDF. Raise instead.
if r.decrypt(self._nfc(self._pdf_password)) == 0:
raise ValueError(t("tool.err.wrong_password"))
return r

def _open_fitz(self, path: str):
Expand Down
9 changes: 9 additions & 0 deletions app/editor/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,15 @@ def __init__(self, parent=None):
btns.addWidget(ca); btns.addWidget(ok)
v.addLayout(btns)

# R11-M12: explicit tab order — without this, focus jumps
# erratically between tabs/buttons depending on widget add order.
# Path: tabs -> type input -> font combo -> save cb -> cancel -> ok.
self.setTabOrder(tabs, self._type_input)
self.setTabOrder(self._type_input, self._font_combo)
self.setTabOrder(self._font_combo, self._save_cb)
self.setTabOrder(self._save_cb, ca)
self.setTabOrder(ca, ok)

def _update_type_preview(self):
text = self._type_input.text().strip()
if not text:
Expand Down
31 changes: 28 additions & 3 deletions app/editor/tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,8 +652,15 @@ def _load_pdf(self, p: str):
self._update_nav()
# Defer annotation/form loading so the UI stays responsive
from PySide6.QtCore import QTimer
from shiboken6 import isValid
QTimer.singleShot(100, self._load_existing_annotations)
QTimer.singleShot(200, lambda: self._load_form_fields(p))
# R11-L3: guard the lambda closure — if the tab is closed during
# the 200 ms wait, the underlying QWidget may be deleted and
# calling self._load_form_fields raises RuntimeError.
QTimer.singleShot(
200,
lambda: self._load_form_fields(p) if isValid(self) else None,
)

def _load_existing_annotations(self):
"""Load existing text annotations from the PDF as note overlays."""
Expand Down Expand Up @@ -779,7 +786,10 @@ def _load_form_fields(self, path):
self._form_table.setUpdatesEnabled(False)
_r = PdfReader(path)
if _r.is_encrypted and self._pdf_password:
_r.decrypt(self._pdf_password)
# R11-M4: 0 == wrong password — surface to the user
# instead of silently parsing an empty PDF.
if _r.decrypt(self._pdf_password) == 0:
raise ValueError(t("tool.err.wrong_password"))
fields = _r.get_fields() or {}
for name, field in fields.items():
r = self._form_table.rowCount(); self._form_table.insertRow(r)
Expand Down Expand Up @@ -1200,6 +1210,18 @@ def _run(self):
doc = fitz.open(self._doc_path)
if doc.needs_pass and self._pdf_password:
doc.authenticate(self._pdf_password)
# R11-L4: warn once if any text/note edit uses chars that the
# PyMuPDF built-in Latin-1 fonts can't render. We still write
# the edit (PyMuPDF substitutes ?) — the warning just sets
# user expectations instead of letting them discover tofu
# after the save completes.
_non_latin = any(
e.get("type") in ("text", "note")
and any(ord(c) > 0xFF for c in (e.get("text") or ""))
for e in self._pending
)
if _non_latin:
self._status(t("tool.warn.font_latin_only"))
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 @@ -1336,7 +1358,10 @@ def _apply_forms(self, out):
_r = PdfReader(_src)
was_encrypted = bool(_r.is_encrypted)
if was_encrypted and self._pdf_password:
_r.decrypt(self._pdf_password)
# R11-M4: catch the wrong-password silent-fail path
# so we never write an empty PDF over the user's file.
if _r.decrypt(self._pdf_password) == 0:
raise ValueError(t("tool.err.wrong_password"))
# If input was encrypted, ask the user how to save.
encrypt_choice = "plaintext"
if was_encrypted and self._pdf_password:
Expand Down
7 changes: 7 additions & 0 deletions app/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
QMenu {{ background: {BG_CARD}; color: {TEXT_PRI}; border: 1px solid {BORDER}; }}
QMenu::item:selected {{ background: #22303A; }}
QMessageBox {{ background: {BG_CARD}; color: {TEXT_PRI}; }}
/* R11-M10: themed tooltip — default Qt tooltips inherit OS chrome that
blends poorly with the dark palette (white-on-light-yellow). */
QToolTip {{ background: {BG_CARD}; color: {TEXT_PRI};
border: 1px solid {BORDER}; padding: 4px 6px; }}
QScrollArea {{ background: transparent; border: none; }}
QScrollArea > QWidget > QWidget {{ background: transparent; }}

Expand Down Expand Up @@ -272,6 +276,9 @@
QMenu {{ background: {_LC}; color: {_LP}; border: 1px solid {_LO}; }}
QMenu::item:selected {{ background: #E7F0ED; }}
QMessageBox {{ background: {_LC}; color: {_LP}; }}
/* R11-M10: themed tooltip in light mode too. */
QToolTip {{ background: {_LC}; color: {_LP};
border: 1px solid {_LO}; padding: 4px 6px; }}
QScrollArea {{ background: transparent; border: none; }}
QScrollArea > QWidget > QWidget {{ background: transparent; }}

Expand Down
14 changes: 10 additions & 4 deletions app/tools/encrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ def _run(self):
QMessageBox.warning(self, t("msg.warning"), t("msg.select_valid_pdf")); return
out_path = self._resolve_output_file(self.drop_out, pdf_path)
if not out_path: return
# R11-M2: only wipe password fields on a fully successful run.
# Previously the finally-clause cleared fields on every exit,
# forcing users to retype on wrong-password / mismatch errors.
success = False
try:
reader = self._open_reader(pdf_path)
if self.cmb_mode.currentIndex() == 0:
Expand All @@ -151,6 +155,7 @@ def _run(self):
self._pipeline_success(msg, out_path)
else:
QMessageBox.information(self, t("msg.done"), msg)
success = True
else:
# _open_reader already decrypted with self._pdf_password (if any).
# The edit_pwd field acts as a manual override — if non-empty,
Expand All @@ -170,10 +175,11 @@ def _run(self):
self._pipeline_success(msg, out_path)
else:
QMessageBox.information(self, t("msg.done"), msg)
success = True
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()
# R11-M2: wipe only on success. Wrong-pwd / mismatch keep
# user input so they can correct and retry.
if success:
self._clear_password_fields()
39 changes: 31 additions & 8 deletions app/tools/import_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ def do_work(worker):
est_lines = max(1, len(line) * fontsize * 0.5 / max_width + 1)
y += line_height * est_lines
try:
doc.save(out_path)
# R11-M8: atomic write — avoids truncating a pre-existing
# output file if the save crashes or the process is killed.
BasePage._atomic_pdf_write(doc, out_path)
finally:
doc.close()
return out_path
Expand All @@ -184,6 +186,13 @@ def do_work(worker):
on_done=lambda _r: self._done(out_path))

def _convert_images(self, sources: list, out_path: str):
# R11-L1: filter to recognised image extensions up front. The
# _IMG_EXTS tuple was previously declared but unused (dead code);
# using it here catches obvious mistakes (user picked a .pdf or
# .txt in the multi-select dialog) before fitz.open raises an
# unhelpful error.
sources = [p for p in sources
if os.path.splitext(p)[1].lower() in _IMG_EXTS]
n = len(sources)

def do_work(worker):
Expand All @@ -205,7 +214,9 @@ def do_work(worker):
finally:
img.close()
worker.progress.emit(i + 1, f"{i + 1}/{n}…")
doc.save(out_path)
# R11-M8: atomic write — avoids truncating a pre-existing
# output file if the save crashes or the process is killed.
BasePage._atomic_pdf_write(doc, out_path)
finally:
doc.close()
return skipped
Expand Down Expand Up @@ -252,7 +263,9 @@ def do_work(worker):
page.insert_text(fitz.Point(50, y), text,
fontsize=size, fontname="helv")
y += size * 1.5
doc.save(out_path)
# R11-M8: atomic write — avoids truncating a pre-existing
# output file if the save crashes or the process is killed.
BasePage._atomic_pdf_write(doc, out_path)
finally:
doc.close()
return out_path
Expand Down Expand Up @@ -336,7 +349,9 @@ def do_work(worker):
worker.progress.emit(i + 1, f"{i + 1}/{n}…")
if worker.is_cancelled():
return None
doc.save(out_path)
# R11-M8: atomic write — avoids truncating a pre-existing
# output file if the save crashes or the process is killed.
BasePage._atomic_pdf_write(doc, out_path)
finally:
doc.close()
return out_path
Expand Down Expand Up @@ -403,7 +418,9 @@ def do_work(worker):
f"{fi + 1}/{n}: {i + 1}/{total_slides}…")
if worker.is_cancelled():
return None
doc.save(out_path)
# R11-M8: atomic write — avoids truncating a pre-existing
# output file if the save crashes or the process is killed.
BasePage._atomic_pdf_write(doc, out_path)
finally:
doc.close()
return out_path
Expand Down Expand Up @@ -445,7 +462,9 @@ def do_work(worker):
worker.progress.emit(i + 1, f"{i + 1}/{n}…")
if worker.is_cancelled():
return None
doc.save(out_path)
# R11-M8: atomic write — avoids truncating a pre-existing
# output file if the save crashes or the process is killed.
BasePage._atomic_pdf_write(doc, out_path)
finally:
doc.close()
return out_path
Expand Down Expand Up @@ -479,7 +498,9 @@ def do_work(worker):
worker.progress.emit(i + 1, f"{i + 1}/{n}…")
if worker.is_cancelled():
return None
doc.save(out_path)
# R11-M8: atomic write — avoids truncating a pre-existing
# output file if the save crashes or the process is killed.
BasePage._atomic_pdf_write(doc, out_path)
finally:
doc.close()
return out_path
Expand Down Expand Up @@ -553,7 +574,9 @@ def do_work(worker):
worker.progress.emit(i + 1, f"{i + 1}/{n}…")
if worker.is_cancelled():
return None
doc.save(out_path)
# R11-M8: atomic write — avoids truncating a pre-existing
# output file if the save crashes or the process is killed.
BasePage._atomic_pdf_write(doc, out_path)
finally:
doc.close()
return out_path
Expand Down
5 changes: 4 additions & 1 deletion app/tools/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ def _run(self):
if reader.is_encrypted:
pwd = self._pwd_map.get(p, "")
if pwd:
reader.decrypt(pwd)
# R11-M4: wrong password yields 0 pages, which
# would silently produce an incomplete merge.
if reader.decrypt(pwd) == 0:
raise ValueError(t("tool.err.wrong_password"))
for page in reader.pages:
w.add_page(page)
self._atomic_pdf_write(w, out, sources=paths)
Expand Down
7 changes: 6 additions & 1 deletion app/tools/nup.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,12 @@ def do_work(worker):
current=src_idx + 1, total=total))
if worker.is_cancelled():
return None
out.save(out_path, garbage=4, deflate=True)
# R11-M8: atomic write — write to a sibling temp file
# and os.replace into place so a crash mid-save can't
# truncate a pre-existing output PDF.
BasePage._atomic_pdf_write(
out, out_path, sources=[pdf_path],
save_opts={"garbage": 4, "deflate": True})
finally:
out.close()
finally:
Expand Down
10 changes: 10 additions & 0 deletions app/tools/page_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ def _run(self):
# ("Page {n}" → "Seite {n}"). Resolve via t() to a concrete
# string before .format().
fmt_template = t(_FORMATS[self.cmb_format.currentIndex()][1])
# R11-L4: ``helv`` is a Type-1 Latin-1 font; any char above
# U+0100 (CJK, Cyrillic, Arabic, Hebrew, accented Greek etc.)
# renders as a ? glyph. Surface a status-bar warning so the user
# is not surprised by tofu in the output PDF.
if any(ord(c) > 0xFF for c in fmt_template):
self._status(t("tool.warn.font_latin_only"))
pos_code = _POSITIONS[self.cmb_position.currentIndex()][1]
font_size = self.spin_size.value()
start_page = self.spin_start_page.value() - 1 # 0-indexed
Expand Down Expand Up @@ -202,12 +208,16 @@ def _run(self):

replace = False
if existing:
# R11-M11: default No — replacing existing page numbers is
# destructive (no undo once saveIncr is called). Stray Enter
# should preserve, not overwrite.
ans = QMessageBox.question(
self, t("msg.warning"),
t("tool.page_numbers.existing_found", n=len(existing)),
QMessageBox.StandardButton.Yes
| QMessageBox.StandardButton.No
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.No,
)
if ans == QMessageBox.StandardButton.Cancel:
return
Expand Down
20 changes: 20 additions & 0 deletions app/tools/split.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,28 @@ def _load_input(self, p: str):
try:
r = self._open_reader(p); self._total = len(r.pages)
self.lbl_info.setText(t("tool.split.pages_info", n=self._total))
# R11-M3: clamp any pre-existing rows so their endpoint stays
# within the new PDF's page count. Without this, swapping a
# 50-page PDF for a 10-page one leaves rows showing 1-50 and
# the row_invalid error fires on Apply.
self._clamp_rows_to_total()
except Exception as e: self.lbl_info.setText(t("tool.split.error_info", e=e))

def _clamp_rows_to_total(self) -> None:
"""Ensure row start/end spinboxes are within ``self._total``."""
total = max(1, int(self._total or 1))
for r in range(self.table.rowCount()):
spn_s = self.table.cellWidget(r, 0)
spn_e = self.table.cellWidget(r, 1)
if spn_s is None or spn_e is None:
continue
if spn_s.value() > total:
spn_s.setValue(total)
if spn_e.value() > total:
spn_e.setValue(total)
if spn_e.value() < spn_s.value():
spn_e.setValue(spn_s.value())

def auto_load(self, path: str):
if path and not self.drop_in.path(): self._load_input(path)

Expand Down
14 changes: 11 additions & 3 deletions app/tools/watermark.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ def _run(self):
try:
wm_reader = PdfReader(wm_path)
if wm_reader.is_encrypted and wm_pwd:
wm_reader.decrypt(wm_pwd)
# R11-M4: wrong password yields a reader with zero
# accessible pages — caught below, but a clearer error
# helps users distinguish "wrong pwd" from "empty WM".
if wm_reader.decrypt(wm_pwd) == 0:
raise ValueError(t("tool.err.wrong_password"))
if not wm_reader.pages:
QMessageBox.warning(self, t("msg.warning"), t("tool.watermark.empty_wm"))
return
Expand All @@ -145,10 +149,14 @@ def _run(self):
def do_work(worker):
r = PdfReader(pdf_path)
if r.is_encrypted and pwd:
r.decrypt(pwd)
# R11-M4: same guard as pre-flight; defence in depth in
# case the password gets cleared between checks.
if r.decrypt(pwd) == 0:
raise ValueError(t("tool.err.wrong_password"))
wm = PdfReader(wm_path)
if wm.is_encrypted and wm_pwd:
wm.decrypt(wm_pwd)
if wm.decrypt(wm_pwd) == 0:
raise ValueError(t("tool.err.wrong_password"))
wm_page = wm.pages[0]
w = PdfWriter()
n = len(r.pages)
Expand Down
Loading