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
56 changes: 48 additions & 8 deletions app/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,15 @@ def set_compact_mode(self, active: bool, path: str = "") -> None:
f"background:transparent; padding:2px 4px; text-align:left; }}"
f"QPushButton#compact_link:hover {{ text-decoration: underline; }}"
)
link.clicked.connect(lambda: self.set_compact_mode(False))
# R6 O2: guard against the page being destroyed between the
# Qt click queueing and the slot actually running. PySide6
# has no QPointer, so use shiboken6.isValid() — without it
# the lambda may touch a dead C++ QWidget and crash with
# "Internal C++ object already deleted". The lambda is the
# only callback installed here, so cost is one isValid call
# per compact-mode exit.
link.clicked.connect(
lambda: self.set_compact_mode(False) if isValid(self) else None)
self._form.insertWidget(0, link)
self._compact_link = link

Expand Down Expand Up @@ -294,6 +302,24 @@ def _resolve_output_dir(self, drop_widget, input_path: str = "") -> str:

# ── encrypted-PDF helpers ──────────────────────────────────────────────

@staticmethod
def _nfc(pwd: str) -> str:
"""Return the NFC-normalized form of ``pwd`` (R6 C1).

Thin compatibility wrapper that now delegates to
:func:`app.utils.normalize_password` so the codebase has a
single source of truth for password normalisation (the R11
review of PR-H flagged that tools reading ``self._pdf_password``
directly bypassed the per-helper normalisation). Kept around so
BasePage subclasses calling ``self._nfc(...)`` keep working,
but new code should normalise at the WRITE site of the cache
(see PdfViewerPanel / EditorTab._load_pdf) so every consumer
— including the ~30 ``self._pdf_password`` reads across
``tools/*.py`` — receives a deterministic value.
"""
from app.utils import normalize_password
return normalize_password(pwd)

def _maybe_prompt_password(self, path: str) -> bool:
"""If the PDF at `path` is encrypted, prompt the user; on success
store the password on `self._pdf_password` and return True. Plain
Expand All @@ -312,33 +338,47 @@ def _maybe_prompt_password(self, path: str) -> bool:
if not doc.needs_pass:
self._pdf_password = ""
return True
if self._pdf_password and doc.authenticate(self._pdf_password):
if self._pdf_password and doc.authenticate(self._nfc(self._pdf_password)):
return True
finally:
doc.close()
from app.utils import prompt_pdf_password
from app.utils import prompt_pdf_password, normalize_password
ok, pwd = prompt_pdf_password(path, self)
if not ok:
return False
self._pdf_password = pwd
# NFC-normalise at WRITE time so every downstream consumer
# (tools/* that read self._pdf_password directly, plus the
# _open_reader / _open_fitz helpers) sees a deterministic
# value. R11 review C2 — see utils.normalize_password.
self._pdf_password = normalize_password(pwd)
return True

def _open_reader(self, path: str):
"""Open a pypdf PdfReader, decrypting with the stored password if
the file is encrypted."""
the file is encrypted.

The cached password is NFC-normalized before being passed to
:meth:`pypdf.PdfReader.decrypt`. See :meth:`_nfc` for the
rationale (R6 C1).
"""
from pypdf import PdfReader
r = PdfReader(path)
if r.is_encrypted and self._pdf_password:
r.decrypt(self._pdf_password)
r.decrypt(self._nfc(self._pdf_password))
return r

def _open_fitz(self, path: str):
"""Open a PyMuPDF Document, authenticating with the stored
password if needed."""
password if needed.

The cached password is NFC-normalized before being passed to
:meth:`fitz.Document.authenticate`. See :meth:`_nfc` for the
rationale (R6 C1).
"""
import fitz
doc = fitz.open(path)
if doc.needs_pass and self._pdf_password:
doc.authenticate(self._pdf_password)
doc.authenticate(self._nfc(self._pdf_password))
return doc

def _clear_pdf_password(self) -> None:
Expand Down
32 changes: 28 additions & 4 deletions app/editor/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ def __init__(self, filename: str, wrong: bool = False, parent=None):
btns.addWidget(ca); btns.addWidget(ok)
v.addLayout(btns)

# R11 G1: without an explicit tab order Qt walks widgets in the
# construction order (icon → title → password → Cancel → OK),
# but the dialog opens with focus on Cancel via the addStretch
# quirk on some platforms. Force a predictable, accessible
# path: password field → OK → Cancel. Keyboard-only users now
# land on the field they need to fill in first.
self._edit.setFocus()
self.setTabOrder(self._edit, ok)
self.setTabOrder(ok, ca)

def password(self) -> str:
return self._edit.text()

Expand Down Expand Up @@ -112,9 +122,23 @@ def __init__(self, old_text: str, font_size: float, parent=None):
lbl_new.setStyleSheet(f"color:{pri}; font-size:10pt;")
v.addWidget(lbl_new)

self._edit = QTextEdit()
self._edit.setPlainText(old_text)
self._edit.setMinimumHeight(80)
# R11 B2: the previous QTextEdit accepted newlines that
# ``page.insert_text`` (PyMuPDF) cannot render — each \n got
# rasterized as a "?" glyph in the output PDF. Use QLineEdit
# so the input is restricted to what the writer can actually
# produce; Enter submits via returnPressed, matching the
# password dialog above.
self._edit = QLineEdit()
# R11 review F6: ``old_text`` can contain ``\n``/``\r`` when the
# viewer extracted text that spans multiple PDF lines. QLineEdit
# silently truncates at the first newline, which hides the
# remainder of the original content from the user. Collapse
# newlines into spaces so the whole detected string stays
# editable; PyMuPDF's writer cannot render real newlines anyway
# (see R11 B2 above).
safe_text = (old_text or "").replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
self._edit.setText(safe_text)
self._edit.returnPressed.connect(self.accept)
v.addWidget(self._edit)

btns = QHBoxLayout(); btns.setSpacing(8); btns.addStretch()
Expand All @@ -125,7 +149,7 @@ def __init__(self, old_text: str, font_size: float, parent=None):
v.addLayout(btns)

def new_text(self) -> str:
return self._edit.toPlainText()
return self._edit.text()


class _TextDialog(QDialog):
Expand Down
30 changes: 28 additions & 2 deletions app/editor/tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
import qtawesome as qta

from app.constants import ACCENT, TEXT_PRI, TEXT_SEC, DESKTOP, _LQ, _LP
from app.utils import ToolHeader, ActionBar, info_lbl, _paint_bg, show_error
from app.utils import (
ToolHeader, ActionBar, info_lbl, _paint_bg, show_error,
normalize_password,
)
from app.i18n import t
from app.widgets import DropFileEdit, ColorPickerButton
from app.editor.canvas import PdfEditCanvas, _get_icon_cursor
Expand Down Expand Up @@ -621,7 +624,12 @@ def _load_pdf(self, p: str):
ok, pwd = prompt_pdf_password(p, self)
if not ok:
return
self._pdf_password = pwd
# NFC-normalise at the WRITE site so every reader of
# self._pdf_password (~10 call sites in this file plus
# ~8 tools under tools/) receives a deterministic value
# without needing per-site normalisation. See R11 review
# C2 / utils.normalize_password.
self._pdf_password = normalize_password(pwd)
elif not needs_pass:
self._pdf_password = ""
self._doc_path = p
Expand Down Expand Up @@ -1350,6 +1358,24 @@ def _apply_forms(self, out):
self._status(t("editor.forms.no_fields"))
self._form_status.setText(t("editor.forms.no_fields"))
return
# R10 review follow-up: an /AcroForm dict can exist with
# zero actual widgets (e.g. forms whose fields were
# flattened by a third-party tool but the dict was left
# behind). update_page_form_field_values then runs a
# silent no-op and the user gets no feedback. Surface
# the same no_fields status so the result matches the
# 'plain PDF' case above. Use the get_fields() count
# since pypdf already exposes it cheaply via the cached
# AcroForm tree — avoids importing fitz just for a
# widget count.
try:
_w_fields = _r.get_fields() or {}
except Exception:
_w_fields = {}
if not _w_fields:
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
37 changes: 37 additions & 0 deletions app/i18n.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"""PDFApps – Internationalization (i18n) module."""

import contextlib
import json
import locale
import logging
import os
import shutil
import sys
import threading
from typing import Callable

_log = logging.getLogger(__name__)

_TRANSLATIONS: dict = {}
_LANG: str = "en"

Expand Down Expand Up @@ -146,13 +151,45 @@
"""
with _CONFIG_LOCK:
cfg: dict = {}
corrupt = False
try:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
cfg = json.load(f)
except FileNotFoundError:
cfg = {}
except Exception:
cfg = {}
corrupt = True
if not isinstance(cfg, dict):
cfg = {}
corrupt = True
# R8 N1: previously a corrupt config.json was silently overwritten
# with `{}`, wiping the user's saved language / dark_mode / recents
# without warning. Snapshot the broken file before resetting so
# support can recover settings (and so we have evidence the
# corruption happened rather than a silent reset triggered by us).
if corrupt:
try:
if (os.path.isfile(_CONFIG_PATH)
and os.path.getsize(_CONFIG_PATH) > 0):
# R11 review B5: include a timestamp in the backup
# name so multiple corruption events don't clobber
# each other (the previous `.corrupt.bak` was a
# single slot, so the second corruption would
# overwrite the first — losing the original
# evidence support needed to recover the user's
# settings).
from datetime import datetime
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = _CONFIG_PATH + f".corrupt-{ts}.bak"
with contextlib.suppress(Exception):
shutil.copy2(_CONFIG_PATH, backup_path)
_log.warning(
"config.json appeared corrupt; saved backup "
"to %s before reset", backup_path,
)
except OSError:

Check notice

Code scanning / CodeQL

Empty except Note

'except' clause does nothing but pass and there is no explanatory comment.
pass
mutator(cfg)
_atomic_write_config(cfg)

Expand Down
16 changes: 16 additions & 0 deletions app/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
"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.drop_many_pdfs_confirm": "Open {count} PDF files at once?",
"widgets.drop_first_only": "Only the first of {count} files was loaded.",
"viewer.copy_chars": " Copy ({n} chars)",
"search.placeholder": "Search in PDF...",
"search.prev": "Previous match",
Expand Down Expand Up @@ -660,6 +662,8 @@
"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.drop_many_pdfs_confirm": "Abrir {count} ficheiros PDF de uma vez?",
"widgets.drop_first_only": "Apenas o primeiro de {count} ficheiros foi carregado.",
"viewer.copy_chars": " Copiar ({n} car.)",
"search.placeholder": "Pesquisar no PDF...",
"search.prev": "Resultado anterior",
Expand Down Expand Up @@ -1259,6 +1263,8 @@
"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.drop_many_pdfs_confirm": "¿Abrir {count} archivos PDF a la vez?",
"widgets.drop_first_only": "Solo se cargó el primero de {count} archivos.",
"viewer.copy_chars": " Copiar ({n} caract.)",
"search.placeholder": "Buscar en PDF...",
"search.prev": "Resultado anterior",
Expand Down Expand Up @@ -1858,6 +1864,8 @@
"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.drop_many_pdfs_confirm": "Ouvrir {count} fichiers PDF en une fois ?",
"widgets.drop_first_only": "Seul le premier des {count} fichiers a été chargé.",
"viewer.copy_chars": " Copier ({n} caract.)",
"search.placeholder": "Rechercher dans le PDF...",
"search.prev": "Résultat précédent",
Expand Down Expand Up @@ -2457,6 +2465,8 @@
"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.drop_many_pdfs_confirm": "{count} PDF-Dateien gleichzeitig öffnen?",
"widgets.drop_first_only": "Nur die erste der {count} Dateien wurde geladen.",
"viewer.copy_chars": " Kopieren ({n} Zeichen)",
"search.placeholder": "Im PDF suchen...",
"search.prev": "Vorheriges Ergebnis",
Expand Down Expand Up @@ -3056,6 +3066,8 @@
"viewer.delete_comment": "删除批注",
"viewer.confirm_delete_comment": "确定要删除此批注吗?此更改将立即保存且无法撤销。",
"viewer.drop_url_not_supported": "不支持在线 URL。请先下载 PDF,然后将本地文件拖到此处。",
"viewer.drop_many_pdfs_confirm": "一次打开 {count} 个 PDF 文件?",
"widgets.drop_first_only": "仅加载了 {count} 个文件中的第一个。",
"viewer.copy_chars": " 复制 ({n} 个字符)",
"search.placeholder": "在 PDF 中搜索...",
"search.prev": "上一个匹配",
Expand Down Expand Up @@ -3655,6 +3667,8 @@
"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.drop_many_pdfs_confirm": "Aprire {count} file PDF contemporaneamente?",
"widgets.drop_first_only": "È stato caricato solo il primo dei {count} file.",
"viewer.copy_chars": " Copia ({n} caratteri)",
"search.placeholder": "Cerca nel PDF...",
"search.prev": "Corrispondenza precedente",
Expand Down Expand Up @@ -4254,6 +4268,8 @@
"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.drop_many_pdfs_confirm": "{count} PDF-bestanden tegelijk openen?",
"widgets.drop_first_only": "Alleen het eerste van {count} bestanden is geladen.",
"viewer.copy_chars": " Kopiëren ({n} tekens)",
"search.placeholder": "Zoeken in PDF...",
"search.prev": "Vorige overeenkomst",
Expand Down
Loading