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
8 changes: 5 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,12 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.artifact }}
# Intentionally do NOT upload dist/PDFApps.exe (the raw
# PyInstaller binary). Users install via PDFAppsSetup.exe
# (NSIS installer) or the .msix package; shipping the raw
# exe just publishes the runner's internal build paths
# embedded in PyInstaller debug strings.
path: |
dist/PDFApps.exe
dist/PDFAppsSetup.exe
dist/PDFAppsUninstall.exe
dist/PDFApps-*.msix
Expand Down Expand Up @@ -298,7 +302,6 @@ jobs:
echo "" >> release_notes.md
echo "## Checksums (SHA256)" >> release_notes.md
for f in artifacts/PDFApps-Windows/PDFAppsSetup.exe \
artifacts/PDFApps-Windows/PDFApps.exe \
artifacts/PDFApps-Windows/PDFApps-*.msix \
artifacts/PDFApps-Linux/PDFApps-Linux.tar.gz \
artifacts/PDFApps-macOS/PDFApps-macOS.dmg \
Expand All @@ -321,7 +324,6 @@ jobs:
body_path: release_notes.md
files: |
artifacts/PDFApps-Windows/PDFAppsSetup.exe
artifacts/PDFApps-Windows/PDFApps.exe
artifacts/PDFApps-Windows/PDFApps-*.msix
artifacts/PDFApps-Linux/PDFApps-Linux.tar.gz
artifacts/PDFApps-macOS/PDFApps-macOS.dmg
Expand Down
14 changes: 7 additions & 7 deletions app/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,13 @@ def _show_toast(self, message: str, file_path: str = "",

toast = QWidget(); toast.setObjectName("toast")
toast.setStyleSheet(
f"#toast {{ background: #065F46; border: 1px solid #10B981; "
f"border-radius: 8px; padding: 8px 12px; }}"
f"#toast QLabel {{ color: white; font-size: 10pt; background: transparent; }}"
f"#toast QPushButton {{ color: #A7F3D0; border: none; background: transparent; "
f"font-size: 10pt; text-decoration: underline; padding: 0 4px; }}"
f"#toast QPushButton:hover {{ color: white; }}"
f"#toast QPushButton#toast_save {{ color: white; font-weight: 600; }}")
"#toast { background: #065F46; border: 1px solid #10B981; "
"border-radius: 8px; padding: 8px 12px; }"
"#toast QLabel { color: white; font-size: 10pt; background: transparent; }"
"#toast QPushButton { color: #A7F3D0; border: none; background: transparent; "
"font-size: 10pt; text-decoration: underline; padding: 0 4px; }"
"#toast QPushButton:hover { color: white; }"
"#toast QPushButton#toast_save { color: white; font-weight: 600; }")
h = QHBoxLayout(toast); h.setContentsMargins(8, 4, 8, 4); h.setSpacing(8)
h.addWidget(QLabel(f"✔ {message}"), 1)
if with_save:
Expand Down
2 changes: 1 addition & 1 deletion app/editor/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ def _style_inline_insert(self):
f" border: none; border-bottom: 1px dashed {ACCENT}; padding: 0; }}")

def _style_inline_edit(self, span: dict):
from PySide6.QtGui import QFont, QColor
from PySide6.QtGui import QFont
bb = span["bbox"]
visual_size = max(float(span.get("size") or 0), float(bb[3] - bb[1]))
fname = (span.get("font", "") or "").lower()
Expand Down
27 changes: 24 additions & 3 deletions app/editor/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
import tempfile

from PySide6.QtCore import Qt, QPoint
from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QPainter, QPen, QColor, QFont, QPainterPath, QPixmap
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
Expand Down Expand Up @@ -43,8 +43,18 @@ def __init__(self, filename: str, wrong: bool = False, parent=None):

top = QHBoxLayout(); top.setSpacing(14)
ico = QLabel()
_pix = qta.icon("fa5s.lock", color=ACCENT).pixmap(72, 72)
_pix.setDevicePixelRatio(2.0)
# Honour the actual screen DPR instead of hardcoding 2.0 — on
# a 1.0 (regular) monitor the icon was over-sized then scaled
# down (blur); on a 1.5/2.5 (mixed-DPI multi-monitor) layout
# the position drifted by a few pixels. Sampling the dialog's
# devicePixelRatioF gives a crisp 40x40 logical-pixel render
# on every monitor without per-platform fork.
dpr = self.devicePixelRatioF() if hasattr(self, "devicePixelRatioF") else 1.0
if dpr <= 0: # paranoia: some headless / mocked widgets report 0
dpr = 1.0
size = int(40 * dpr)
_pix = qta.icon("fa5s.lock", color=ACCENT).pixmap(size, size)
_pix.setDevicePixelRatio(dpr)
ico.setPixmap(_pix)
ico.setFixedSize(40, 40)
top.addWidget(ico)
Expand Down Expand Up @@ -420,6 +430,17 @@ def _pick_image(self):
self, t("edit.signature.import"), "",
"Images (*.png *.jpg *.jpeg *.bmp *.webp *.tif *.tiff)")
if p and os.path.isfile(p):
# Reject pathological / malicious gigapixel images BEFORE
# QPixmap allocates a multi-GB buffer (a 50000x50000 TIFF
# would crash the editor process). See utils.check_image_size.
from app.utils import check_image_size
ok, w, h = check_image_size(p)
if not ok:
QMessageBox.warning(self, t("msg.warning"),
t("editor.image_too_large",
width=w, height=h,
megapix=w * h // 1_000_000))
return
self._imp_path = p
pix = QPixmap(p)
self._imp_preview.setPixmap(pix.scaled(
Expand Down
31 changes: 23 additions & 8 deletions app/editor/tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QFrame, QStackedWidget, QGroupBox,
QGridLayout, QLayout, QSizePolicy, QListWidget, QTableWidget,
QTableWidgetItem, QHeaderView, QTextEdit, QComboBox, QFileDialog,
QSizePolicy, QListWidget, QTableWidget,
QTableWidgetItem, QHeaderView, QTextEdit, QFileDialog,
QMessageBox, QDialog, QApplication, QSlider,
)
import qtawesome as qta
Expand All @@ -23,7 +23,7 @@
from app.i18n import t
from app.widgets import DropFileEdit, ColorPickerButton
from app.editor.canvas import PdfEditCanvas, _get_icon_cursor
from app.editor.dialogs import _TextDialog, _NoteDialog, _TextEditDialog
from app.editor.dialogs import _NoteDialog


_log = logging.getLogger(__name__)
Expand Down Expand Up @@ -500,8 +500,8 @@ def update_theme(self, dark: bool) -> None:
f"color:{ACCENT}; border-radius:6px; border-radius:6px;")
else:
b.setStyleSheet(
f"background:#D6F2EC; border:1px solid #83CABB; "
f"color:#0E5A51; border-radius:6px; border-radius:6px;")
"background:#D6F2EC; border:1px solid #83CABB; "
"color:#0E5A51; border-radius:6px; border-radius:6px;")

def _update_nav(self):
n = self._canvas.page_count()
Expand Down Expand Up @@ -538,8 +538,8 @@ def _on_mode_btn(self, btn):
f"color:{ACCENT}; border-radius:6px; border-radius:6px;")
else:
b.setStyleSheet(
f"background:#D6F2EC; border:1px solid #83CABB; "
f"color:#0E5A51; border-radius:6px; border-radius:6px;")
"background:#D6F2EC; border:1px solid #83CABB; "
"color:#0E5A51; border-radius:6px; border-radius:6px;")
else:
if self._dark_mode:
b.setStyleSheet(
Expand Down Expand Up @@ -735,6 +735,17 @@ def _pick_image(self):
p, _ = QFileDialog.getOpenFileName(self, t("edit.image"), DESKTOP,
t("file_filter.images"))
if p:
# Reject gigapixel images before any downstream consumer
# (QPixmap preview, fitz.Pixmap on save) allocates a huge
# buffer. Mirrors the guard in _SignatureDialog._pick_image.
from app.utils import check_image_size
ok, w, h = check_image_size(p)
if not ok:
QMessageBox.warning(self, t("msg.warning"),
t("editor.image_too_large",
width=w, height=h,
megapix=w * h // 1_000_000))
return
self._img_drop.blockSignals(True)
self._img_drop.set_path(p)
self._img_drop.blockSignals(False)
Expand Down Expand Up @@ -1216,7 +1227,11 @@ def _run(self):
# user expectations instead of letting them discover tofu
# after the save completes.
_non_latin = any(
e.get("type") in ("text", "note")
# text_edit also writes via the built-in helv font
# (see the type-dispatch a few lines below), so the
# warning must cover it too — previously the user
# got tofu on edited spans without any heads-up.
e.get("type") in ("text", "note", "text_edit")
and any(ord(c) > 0xFF for c in (e.get("text") or ""))
for e in self._pending
)
Expand Down
36 changes: 34 additions & 2 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,39 @@

# ── Recent files ──────────────────────────────────────────────────────────

_MAX_RECENT = 5
#: Conservative ceiling so a stray ``max_recent_files`` entry (corrupt
#: config, manual edit) cannot blow the recents menu out to thousands
#: of items and freeze the UI for seconds while it re-renders.
_RECENT_FILES_MIN = 1
_RECENT_FILES_MAX = 50
#: Default bumped from 5 to 10 — modern app conventions (Office, Chrome,
#: Acrobat) keep ~10 recents; 5 was leftover from the 1.0 prototype.
_DEFAULT_MAX_RECENT = 10
# Back-compat alias for any external code that still imports the legacy
# constant. New call sites should use _get_max_recent() so the user's
# config override is honoured.
_MAX_RECENT = _DEFAULT_MAX_RECENT

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable '_MAX_RECENT' is not used.


def _get_max_recent() -> int:
"""Return the user-configured max recent-files count, clamped.

Reads ``max_recent_files`` from the on-disk config (falls back to
:data:`_DEFAULT_MAX_RECENT` for missing / non-int values). The value
is clamped to [_RECENT_FILES_MIN, _RECENT_FILES_MAX] so a hostile
or corrupt config cannot make the recents menu unusable.
"""
try:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
cfg = json.load(f)
except Exception:
return _DEFAULT_MAX_RECENT
val = cfg.get("max_recent_files", _DEFAULT_MAX_RECENT)
try:
n = int(val)
except (TypeError, ValueError):
return _DEFAULT_MAX_RECENT
return max(_RECENT_FILES_MIN, min(_RECENT_FILES_MAX, n))


def get_recent_files() -> list[str]:
Expand Down Expand Up @@ -281,7 +313,7 @@
if path in recents:
recents.remove(path)
recents.insert(0, path)
cfg["recent_files"] = recents[:_MAX_RECENT]
cfg["recent_files"] = recents[:_get_max_recent()]

_update_config(_mutate)

Expand Down
4 changes: 1 addition & 3 deletions app/tools/compress.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@
QGroupBox, QFormLayout, QComboBox, QLabel, QFileDialog, QMessageBox,
QProgressDialog,
)
from pypdf import PdfReader

from app.base import BasePage
from app.i18n import t
from app.utils import (section, info_lbl, _compress_pdf, _find_gs,
show_error, is_dark, result_label_style)
show_error, result_label_style)
from app.worker import TaskRunner, run_task
from app.constants import DESKTOP, TEXT_SEC
from app.widgets import DropFileEdit
Expand Down
80 changes: 64 additions & 16 deletions app/tools/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import os
import re
import tempfile

_log = logging.getLogger(__name__)

Expand All @@ -14,6 +15,33 @@ def _clean(text: str) -> str:
"""Strip control characters that break XML-based formats."""
return _CTRL_RE.sub('', text)


def _atomic_save(out_path: str, write_cb):
"""Run ``write_cb(tmp_path)`` then atomically rename onto ``out_path``.

Mirrors BasePage._atomic_pdf_write for non-PDF outputs (DOCX, TXT,
PPTX, XLSX, HTML, EPUB) so a crash or cancel mid-save can no longer
leave a half-written file in place of the user's previous output —
the original output (if any) survives untouched until the rename
succeeds. Same-directory tempfile ensures os.replace is a single
filesystem operation (no cross-device move).
"""
suffix = os.path.splitext(out_path)[1] or ".tmp"
out_dir = os.path.dirname(out_path) or "."
fd, tmp = tempfile.mkstemp(suffix=suffix, dir=out_dir)
os.close(fd)
try:
write_cb(tmp)
os.replace(tmp, out_path)
except BaseException:
# BaseException so KeyboardInterrupt / cancellation also cleans
# up. Suppress the cleanup OSError because the original error
# is the one the caller needs to see.
with contextlib.suppress(OSError):
if os.path.exists(tmp):
os.unlink(tmp)
raise

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QGroupBox, QFormLayout, QComboBox, QLabel, QFileDialog,
Expand All @@ -22,7 +50,10 @@ def _clean(text: str) -> str:

from app.base import BasePage
from app.i18n import t
from app.utils import section, info_lbl, pick_folder, show_error, result_label_style
from app.utils import (
section, info_lbl, pick_folder, show_error, result_label_style,
CancelledError,
)
from app.constants import DESKTOP
from app.widgets import DropFileEdit

Expand Down Expand Up @@ -612,7 +643,7 @@ def _flush_until(y_limit: float) -> None:
docx_doc.add_paragraph(f"[Note: {a.type}] {content}")
if worker.is_cancelled():
return None
docx_doc.save(out_path)
_atomic_save(out_path, lambda p: docx_doc.save(p))
finally:
doc.close()
return total
Expand Down Expand Up @@ -647,16 +678,29 @@ def do_work(worker):
doc = fitz.open(pdf_path)
if doc.needs_pass and pwd:
doc.authenticate(pwd)
cancelled = False
try:
with open(out_path, 'w', encoding='utf-8') as f:
for i, page in enumerate(doc):
if worker.is_cancelled():
return None
if i > 0:
f.write(t("tool.convert.txt.page_separator",
n=i + 1))
f.write(page.get_text())
worker.progress.emit(i, f"{i + 1}/{total}…")
def _write_txt(tmp_path: str) -> None:
nonlocal cancelled
with open(tmp_path, 'w', encoding='utf-8') as f:
for i, page in enumerate(doc):
if worker.is_cancelled():
cancelled = True
# Bail out via exception so _atomic_save
# discards the half-written tmp and does
# not replace the user's previous output.
raise CancelledError()
if i > 0:
f.write(t("tool.convert.txt.page_separator",
n=i + 1))
f.write(page.get_text())
worker.progress.emit(i, f"{i + 1}/{total}…")
try:
_atomic_save(out_path, _write_txt)
except CancelledError:
return None
if cancelled:
return None
finally:
doc.close()
return total
Expand Down Expand Up @@ -942,7 +986,7 @@ def _rgb(c):
worker.progress.emit(i, f"{i + 1}/{total}…")
if worker.is_cancelled():
return None
prs.save(out_path)
_atomic_save(out_path, lambda p: prs.save(p))
finally:
doc.close()
return total
Expand Down Expand Up @@ -1007,7 +1051,7 @@ def do_work(worker):
worker.progress.emit(i, f"{i + 1}/{total}…")
if worker.is_cancelled():
return None
wb.save(out_path)
_atomic_save(out_path, lambda p: wb.save(p))
finally:
doc.close()
return total
Expand Down Expand Up @@ -1091,8 +1135,12 @@ def do_work(worker):
parts.append("</body></html>")
if worker.is_cancelled():
return None
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(parts))
_html = "\n".join(parts)

def _write_html(tmp_path: str) -> None:
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(_html)
_atomic_save(out_path, _write_html)
finally:
doc.close()
return total
Expand Down Expand Up @@ -1159,7 +1207,7 @@ def do_work(worker):
book.add_item(epub.EpubNav())
book.spine = ["nav"] + chapters
book.toc = chapters
epub.write_epub(out_path, book)
_atomic_save(out_path, lambda p: epub.write_epub(p, book))
finally:
doc.close()
return total
Expand Down
Loading