Download PDFApps
Free forever, open source. Choose your platform below; installation takes less than a minute.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a2d8ec..72499e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,9 +80,35 @@ jobs: (rf'"softwareVersion":\s*"{ANY}"', f'"softwareVersion": "{new}"'), (rf'v{old_re}', f'v{new}'), ], True), + # changelog.html carries the *whole* release history: every past + # release has a ``v...`` heading + # that must NEVER change. A global ``v{old}`` rewrite clobbered the + # topmost historical heading every release (this is why v1.13.16 + # "disappeared" in 72f25e8: its heading was renamed to v1.13.17). + # Anchor the bump to the footer changelog link ONLY. New release + # headings (incl. moving the "Latest" badge) are an editorial step + # done by hand in the same PR that ships the feature work. ("docs/changelog.html", [ + (rf'()v{old_re}()', rf'\g<1>v{new}\g<2>'), + ], True), + # Every remaining marketing page carries the current version only, + # in an eyebrow badge and/or the footer changelog link (no historical + # version strings — verified by grep). The global ``v{old}`` -> ``v{new}`` + # rewrite is therefore safe and keeps all pages in lock-step. required=True + # so the release fails loudly if a page ever drifts off the old tag + # (instead of silently masking the drift). + ("docs/download.html", [ + (rf'v{old_re}', f'v{new}'), + ], True), + ("docs/features.html", [ + (rf'v{old_re}', f'v{new}'), + ], True), + ("docs/privacy.html", [ + (rf'v{old_re}', f'v{new}'), + ], True), + ("docs/docs.html", [ (rf'v{old_re}', f'v{new}'), - ], False), + ], True), ("snap/snapcraft.yaml", [ (rf"version:\s*'{ANY}'", f"version: '{new}'"), ], True), diff --git a/.gitignore b/.gitignore index 5153297..0734393 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ test_*.pdf test_*.xlsx *.tmp *.bak +# Debug repro artifacts for the thumbnails sidebar +thumb_repro*.png # Build logs locais pyinstaller.log diff --git a/app/base.py b/app/base.py index 81e501b..bf5a95d 100644 --- a/app/base.py +++ b/app/base.py @@ -382,7 +382,12 @@ def _open_fitz(self, path: str): import fitz doc = fitz.open(path) if doc.needs_pass and self._pdf_password: - doc.authenticate(self._nfc(self._pdf_password)) + # PyMuPDF's authenticate() returns a falsy value (0) on a + # wrong password and leaves the document locked — mirror + # _open_reader and raise instead of handing back a Document + # whose pages can't be read. + if not doc.authenticate(self._nfc(self._pdf_password)): + raise ValueError(t("tool.err.wrong_password")) return doc def _clear_pdf_password(self) -> None: diff --git a/app/i18n.py b/app/i18n.py index e669e5a..ba3799b 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -5,6 +5,7 @@ import locale import logging import os +import re import shutil import sys import threading @@ -208,6 +209,43 @@ def _save_config_language(lang: str): # `check_for_store_update` can suppress further nags until a NEWER # version is published. +# A dismissed version must be a plain dotted-numeric string (1 to 4 +# components, e.g. "1", "1.13", "1.13.17", "1.13.17.0"). Anything else — +# a bare integer serialized without dots (e.g. "281530812399616") or +# junk — is treated as corrupt and ignored. Without this guard a bogus +# huge value like "281530812399616" parses (via _parse_version) to a +# tuple that dwarfs every real Store version, so `check_for_store_update` +# would suppress ALL future update notifications forever. +# +# The format regex alone is NOT enough: a single gigantic component like +# "281530812399616" is a valid dotted-numeric string (0 dots), so it +# passes the regex and would still poison the comparison. MSIX / Windows +# package versions are Major.Minor.Build.Revision where every component +# is a UInt16 (0-65535), so we additionally reject any component that +# exceeds that ceiling. This rejects "281530812399616" while keeping +# real versions like "1.13.17", "1.13.17.0" and small bare ints like +# "12345". +_STORE_VERSION_RE = re.compile(r"^\d+(\.\d+){0,3}$") +_STORE_VERSION_COMPONENT_MAX = 65535 # UInt16 ceiling per MSIX spec + + +def _is_valid_store_version(value) -> bool: + """True if ``value`` is a well-formed dotted-numeric version string. + + Beyond matching the dotted-numeric *format*, each integer component + must fit in a UInt16 (<= 65535) — the MSIX/Windows package-version + range. This rejects an oversized bare integer (e.g. + ``"281530812399616"``) that would otherwise pass the format check + and permanently suppress update notifications. + """ + if not isinstance(value, str) or not _STORE_VERSION_RE.match(value): + return False + # Regex guarantees every split part is a non-empty run of digits, so + # int() cannot raise here; the only remaining check is magnitude. + return all(int(part) <= _STORE_VERSION_COMPONENT_MAX + for part in value.split(".")) + + def get_dismissed_store_version() -> str | None: """Return the Store version the user last dismissed, or ``None``. @@ -215,6 +253,10 @@ def get_dismissed_store_version() -> str | None: version; do not nag again unless the Store publishes something newer". Stored under the ``dismissed_store_version`` key in config.json. + + If the persisted value is malformed (not a dotted-numeric version), + it is treated as "nothing dismissed" AND scrubbed from config so the + corrupt key can't keep suppressing notifications on every launch. """ try: with open(_CONFIG_PATH, "r", encoding="utf-8") as f: @@ -224,8 +266,13 @@ def get_dismissed_store_version() -> str | None: if not isinstance(cfg, dict): return None val = cfg.get("dismissed_store_version") - if isinstance(val, str) and val: + if val is None: + return None + if _is_valid_store_version(val): return val + # Corrupt value: drop it so it stops masking real updates. + with contextlib.suppress(Exception): + _update_config(lambda c: c.pop("dismissed_store_version", None)) return None @@ -235,12 +282,17 @@ def set_dismissed_store_version(version: str | None) -> None: Pass a version string (e.g. ``"1.13.17"``) to record the dismiss. Pass ``None`` to clear the flag (so the next available-update notification is shown unconditionally). + + Malformed version strings are rejected (never persisted) to avoid + writing a value that would later suppress all notifications. """ def _mutate(cfg: dict) -> None: - if version: + if version and _is_valid_store_version(version): cfg["dismissed_store_version"] = version - else: + elif not version: cfg.pop("dismissed_store_version", None) + # An invalid non-empty version is silently ignored: neither + # written nor used to clear an existing valid value. _update_config(_mutate) diff --git a/app/tools/_pdf_extract.py b/app/tools/_pdf_extract.py index ab2813b..a998ae2 100644 --- a/app/tools/_pdf_extract.py +++ b/app/tools/_pdf_extract.py @@ -1180,7 +1180,6 @@ def _split_component_on_y_gaps( drawing_idx_list: list[int] = [] any_text = False consumed_text: list[int] = [] - seen_text: set[int] = set() # Precompute cell bboxes so we can pre-assign each text block # to whichever cell contains its centroid (handles the common # PyMuPDF case where adjacent columns of one table row land in diff --git a/app/translations.json b/app/translations.json index 1e3b071..232ec7a 100644 --- a/app/translations.json +++ b/app/translations.json @@ -416,6 +416,9 @@ "viewer.presentation": "Presentation", "viewer.fullscreen": "Fullscreen", "viewer.toc": "Bookmarks", + "viewer.sidebar.contents": "Contents", + "viewer.sidebar.pages": "Pages", + "viewer.thumbnails.loading": "Rendering thumbnails...", "viewer.toc_empty": "No bookmarks in this PDF", "viewer.night_mode": "Night reading mode (invert colors)", "update.changes": "What's new in this version:", @@ -1028,6 +1031,9 @@ "viewer.presentation": "Apresentação", "viewer.fullscreen": "Ecrã inteiro", "viewer.toc": "Marcadores", + "viewer.sidebar.contents": "Índice", + "viewer.sidebar.pages": "Páginas", + "viewer.thumbnails.loading": "A renderizar miniaturas...", "viewer.toc_empty": "Este PDF não tem marcadores", "viewer.night_mode": "Modo noturno (inverter cores)", "update.changes": "Novidades nesta versão:", @@ -1640,6 +1646,9 @@ "viewer.presentation": "Presentación", "viewer.fullscreen": "Pantalla completa", "viewer.toc": "Marcadores", + "viewer.sidebar.contents": "Índice", + "viewer.sidebar.pages": "Páginas", + "viewer.thumbnails.loading": "Renderizando miniaturas...", "viewer.toc_empty": "Este PDF no tiene marcadores", "viewer.night_mode": "Modo nocturno (invertir colores)", "update.changes": "Novedades en esta versión:", @@ -2252,6 +2261,9 @@ "viewer.presentation": "Présentation", "viewer.fullscreen": "Plein écran", "viewer.toc": "Signets", + "viewer.sidebar.contents": "Sommaire", + "viewer.sidebar.pages": "Pages", + "viewer.thumbnails.loading": "Génération des miniatures...", "viewer.toc_empty": "Ce PDF n'a pas de signets", "viewer.night_mode": "Mode nuit (inverser les couleurs)", "update.changes": "Nouveautés de cette version :", @@ -2864,6 +2876,9 @@ "viewer.presentation": "Präsentation", "viewer.fullscreen": "Vollbild", "viewer.toc": "Lesezeichen", + "viewer.sidebar.contents": "Inhalt", + "viewer.sidebar.pages": "Seiten", + "viewer.thumbnails.loading": "Miniaturansichten werden erstellt...", "viewer.toc_empty": "Dieses PDF hat keine Lesezeichen", "viewer.night_mode": "Nachtmodus (Farben invertieren)", "update.changes": "Neuigkeiten in dieser Version:", @@ -3476,6 +3491,9 @@ "viewer.presentation": "演示", "viewer.fullscreen": "全屏", "viewer.toc": "书签", + "viewer.sidebar.contents": "目录", + "viewer.sidebar.pages": "页面", + "viewer.thumbnails.loading": "生成缩略图...", "viewer.toc_empty": "此PDF没有书签", "viewer.night_mode": "夜间阅读模式(反转颜色)", "update.changes": "此版本的新功能:", @@ -4088,6 +4106,9 @@ "viewer.presentation": "Presentazione", "viewer.fullscreen": "Schermo intero", "viewer.toc": "Segnalibri", + "viewer.sidebar.contents": "Sommario", + "viewer.sidebar.pages": "Pagine", + "viewer.thumbnails.loading": "Generazione miniature...", "viewer.toc_empty": "Questo PDF non ha segnalibri", "viewer.night_mode": "Modalità notturna (inverti colori)", "update.changes": "Novità in questa versione:", @@ -4700,6 +4721,9 @@ "viewer.presentation": "Presentatie", "viewer.fullscreen": "Volledig scherm", "viewer.toc": "Bladwijzers", + "viewer.sidebar.contents": "Inhoud", + "viewer.sidebar.pages": "Pagina's", + "viewer.thumbnails.loading": "Miniaturen genereren...", "viewer.toc_empty": "Deze PDF heeft geen bladwijzers", "viewer.night_mode": "Nachtmodus (kleuren inverteren)", "update.changes": "Wat is er nieuw in deze versie:", diff --git a/app/viewer/panel.py b/app/viewer/panel.py index fc7f082..a16b0f8 100644 --- a/app/viewer/panel.py +++ b/app/viewer/panel.py @@ -6,7 +6,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QFileDialog, QMessageBox, QDialog, - QLineEdit, QSplitter, QTreeWidget, QTreeWidgetItem, + QLineEdit, QSplitter, QTabWidget, QTreeWidget, QTreeWidgetItem, ) from PySide6.QtGui import QKeySequence, QShortcut from shiboken6 import isValid @@ -16,6 +16,7 @@ from app.utils import _paint_bg, normalize_password from app.i18n import t from app.viewer.canvas import _SelectCanvas +from app.viewer.thumbnails import ThumbnailPanel class PdfViewerPanel(QWidget): @@ -143,13 +144,29 @@ def _a11y(btn, tip): self._placeholder = ph_widget layout.addWidget(self._placeholder, 1) - # ── TOC tree (left of canvas) ────────────────────────────────── + # ── TOC tree (Contents tab of the sidebar) ───────────────────── self._toc_tree = QTreeWidget() self._toc_tree.setObjectName("toc_tree") self._toc_tree.setHeaderHidden(True) self._toc_tree.setMinimumWidth(180) self._toc_tree.itemClicked.connect(self._on_toc_clicked) + # ── Thumbnails (Pages tab of the sidebar) ─────────────────────── + self._thumbnails = ThumbnailPanel(self) + self._thumbnails.page_requested.connect(self._on_thumbnail_clicked) + + # Sidebar tab widget: [Contents | Pages]. Individual tabs are + # shown/hidden dynamically: Contents only when the PDF has a + # non-empty TOC, Pages always once a doc is loaded. + self._sidebar_tabs = QTabWidget() + self._sidebar_tabs.setObjectName("viewer_sidebar_tabs") + self._sidebar_tabs.setDocumentMode(True) + self._sidebar_tabs.setMinimumWidth(180) + self._toc_tab_idx = self._sidebar_tabs.addTab( + self._toc_tree, t("viewer.sidebar.contents")) + self._pages_tab_idx = self._sidebar_tabs.addTab( + self._thumbnails, t("viewer.sidebar.pages")) + # ── Canvas with continuous scroll of all pages ────────────────── self._canvas = _SelectCanvas() self._canvas.zoom_changed.connect(self._on_zoom_changed) @@ -162,9 +179,9 @@ def _a11y(btn, tip): self._canvas_scroll.viewport().installEventFilter(self) self._canvas_scroll.verticalScrollBar().valueChanged.connect(self._on_scroll) - # Splitter: TOC | canvas + # Splitter: sidebar | canvas self._viewer_splitter = QSplitter(Qt.Orientation.Horizontal) - self._viewer_splitter.addWidget(self._toc_tree) + self._viewer_splitter.addWidget(self._sidebar_tabs) self._viewer_splitter.addWidget(self._canvas_scroll) self._viewer_splitter.setStretchFactor(0, 0) self._viewer_splitter.setStretchFactor(1, 1) @@ -172,7 +189,7 @@ def _a11y(btn, tip): self._viewer_splitter.setCollapsible(0, True) self._viewer_splitter.setCollapsible(1, False) self._viewer_splitter.setVisible(False) - self._toc_tree.setVisible(False) # hidden until a PDF with TOC is loaded + self._sidebar_tabs.setVisible(False) # hidden until a PDF is loaded layout.addWidget(self._viewer_splitter, 1) # ── Search bar (Ctrl+F) ─────────────────────────────────────────── @@ -300,6 +317,8 @@ def update_theme(self, dark: bool) -> None: link.setStyleSheet(link_style) for btn in self._recent_del_btns: btn.setIcon(qta.icon("fa5s.trash-alt", color=c)) + # Thumbnail delegate re-reads dark flag to pick hover colour. + self._thumbnails.update_theme(dark) # Drag & drop is handled at the MainWindow level (see window.py). @@ -405,8 +424,30 @@ def current_path(self) -> str: return self._current_path # ── TOC / Bookmarks ───────────────────────────────────────────────── + def _set_toc_tab_visible(self, visible: bool) -> None: + """Show/hide the Contents tab without removing it from the tab + widget. Qt has no first-class ``setTabVisible`` on older + PySide6 releases, but 6.11 does — guard with hasattr so we + keep working on legacy bindings by inserting/removing instead. + """ + if hasattr(self._sidebar_tabs, "setTabVisible"): + self._sidebar_tabs.setTabVisible(self._toc_tab_idx, visible) + return + # Legacy path: remove/insert. Best-effort; safe if already gone. + present = self._sidebar_tabs.indexOf(self._toc_tree) != -1 + if visible and not present: + self._sidebar_tabs.insertTab( + 0, self._toc_tree, t("viewer.sidebar.contents")) + self._toc_tab_idx = 0 + # Pages moved to index 1 after re-insert. + self._pages_tab_idx = self._sidebar_tabs.indexOf(self._thumbnails) + elif not visible and present: + self._sidebar_tabs.removeTab(self._toc_tab_idx) + self._pages_tab_idx = self._sidebar_tabs.indexOf(self._thumbnails) + 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 Contents + tab (but keeps Pages) if the outline is empty. Wrapped in try/except: a malformed outline (cyclic refs, bad page indexes, unexpected entry shape) used to leave the TOC @@ -424,8 +465,12 @@ def _populate_toc(self, doc): "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) + self._set_toc_tab_visible(False) + # Pages tab is still there → activate it so the user sees + # something useful when they open the sidebar. + pages_idx = self._sidebar_tabs.indexOf(self._thumbnails) + if pages_idx >= 0: + self._sidebar_tabs.setCurrentIndex(pages_idx) return try: # toc is a list of [level, title, page] (page is 1-indexed) @@ -439,9 +484,15 @@ def _populate_toc(self, doc): 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) + self._set_toc_tab_visible(True) + # Make Contents the active tab by default when the PDF has a + # TOC — the original (pre-sidebar-tabs) behaviour showed the + # outline as the sidebar itself. Without this, a prior no-TOC + # document that switched the active tab to "Pages" (see the + # empty-TOC branch above) leaves the sidebar parked on Pages, + # so re-showing the Contents tab button here never actually + # surfaces the outline: the tab appears absent to the user. + self._sidebar_tabs.setCurrentIndex(self._toc_tab_idx) except Exception as exc: import logging logging.getLogger(__name__).warning( @@ -450,8 +501,7 @@ def _populate_toc(self, doc): # 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) + self._set_toc_tab_visible(False) def _on_toc_clicked(self, item, column): page_idx = item.data(0, Qt.ItemDataRole.UserRole) @@ -460,11 +510,24 @@ def _on_toc_clicked(self, item, column): y = self._canvas.scroll_to_page(int(page_idx)) self._canvas_scroll.verticalScrollBar().setValue(y) + def _on_thumbnail_clicked(self, page_idx: int) -> None: + """User clicked a page thumbnail — scroll the canvas there.""" + y = self._canvas.scroll_to_page(int(page_idx)) + self._canvas_scroll.verticalScrollBar().setValue(y) + def _toggle_toc(self): - visible = self._toc_tree.isVisible() - self._toc_tree.setVisible(not visible) + """Toggle the whole sidebar (Contents + Pages tab widget). + + The button still lives on the header with the bookmark icon — + the semantics widened when thumbnails joined the sidebar, but + renaming the attribute would break existing accessibility hooks + and translation strings on this release. + """ + visible = self._sidebar_tabs.isVisible() + self._sidebar_tabs.setVisible(not visible) if not visible: - self._viewer_splitter.setSizes([220, max(800, self._viewer_splitter.width() - 220)]) + self._viewer_splitter.setSizes( + [220, max(800, self._viewer_splitter.width() - 220)]) # Re-layout pages after splitter change if self._canvas._doc and self._canvas._zoom_factor == 1.0: from PySide6.QtCore import QTimer @@ -492,6 +555,9 @@ def load(self, path: str): if self._fitz_doc: self._canvas.close_doc() self._fitz_doc = None + # Stop the thumbnail worker and drop cached pixmaps of the + # previous doc before we point the panel at a new file. + self._thumbnails.clear() # Forget the previous file's password before we start the # new one's prompt flow (R5/D2). _clear_pdf_password is a # best-effort wipe — Python str immutability blocks a true @@ -527,12 +593,27 @@ def load(self, path: str): self._canvas_scroll.verticalScrollBar().setValue(0) self._placeholder.setVisible(False) self._viewer_splitter.setVisible(True) + # Sidebar is always available once a PDF loads — thumbnails + # don't depend on the PDF having a TOC. + self._sidebar_tabs.setVisible(True) self._sel_status.setVisible(True) self._name_lbl.setText(os.path.basename(path)) self._zoom_lbl.setText(t("zoom.fit")) for btn in (self._zoom_out_btn, self._zoom_in_btn, self._fit_btn, self._print_btn, self._night_btn): btn.setEnabled(True) + # The sidebar toggle used to live behind a "PDF has TOC" gate; + # now that Pages is a first-class tab the button is always + # meaningful. Keep the icon/tooltip — see _toggle_toc note. + self._toc_btn.setVisible(True) + self._toc_btn.setEnabled(True) + # Populate thumbnails BEFORE the TOC so the worker starts + # rendering while _populate_toc walks the outline on the UI + # thread. + self._thumbnails.set_document( + path, doc.page_count, + password=getattr(self, "_pdf_password", "")) + self._thumbnails.set_current_page(0) self._populate_toc(doc) # ── Search ────────────────────────────────────────────────────────── @@ -610,13 +691,11 @@ def _update_search_highlight(self): all_highlights = [] flat_idx = 0 current_page = 0 - current_rect_idx = 0 for page_idx, rects in self._search_results: for rect in rects: all_highlights.append((page_idx, rect)) if flat_idx == self._search_current: current_page = page_idx - current_rect_idx = flat_idx flat_idx += 1 self._canvas.set_search_highlights(all_highlights, self._search_current) # Scroll to current match @@ -643,6 +722,9 @@ def _update_page_label(self): self._page_lbl.setText(f"{idx + 1} / {total}") self._prev_btn.setEnabled(idx > 0) self._next_btn.setEnabled(idx < total - 1) + # Keep the thumbnail sidebar highlight in sync with whichever + # page the user is currently reading in the canvas. + self._thumbnails.set_current_page(idx) def _prev_page(self): if not self._canvas._entries: @@ -769,4 +851,10 @@ def closeEvent(self, event): # Wipe cached password before the C++ widget is destroyed so a # heap dump after teardown no longer surfaces it. self._clear_pdf_password() + # Cancel + join the thumbnail render thread so tearing the + # panel down doesn't leak a QThread past the C++ deletion. + try: + self._thumbnails.clear() + except Exception: + pass super().closeEvent(event) diff --git a/app/viewer/thumbnails.py b/app/viewer/thumbnails.py new file mode 100644 index 0000000..d7d719f --- /dev/null +++ b/app/viewer/thumbnails.py @@ -0,0 +1,893 @@ +"""PDFApps – PDF page thumbnails panel for the viewer sidebar. + +A lightweight ``QListView`` that shows one clickable thumbnail per +page. Thumbnails are rendered off the UI thread by a ``QThread`` worker +(pymupdf's ``fitz.open`` is not thread-safe across the same +``Document`` handle, so the worker opens its own copy) and cached in an +insertion-ordered dict capped at ``CACHE_MAX`` entries — long documents +never blow up memory. + +Public API +---------- +``ThumbnailPanel.set_document(path, page_count, password="")`` + Load a document. Resets the model and kicks off rendering. + +``ThumbnailPanel.clear()`` + Unload the current document. + +``ThumbnailPanel.set_current_page(idx)`` + Highlight + scroll to a page. + +``ThumbnailPanel.page_requested (Signal[int])`` + User clicked / keyed a thumbnail. The viewer should scroll canvas + there. + +``ThumbnailPanel.update_theme(dark)`` + Repaint delegate so ACCENT / TEXT_SEC track the theme. +""" + +from __future__ import annotations + +import contextlib +import logging +import os + +from PySide6.QtCore import ( + QAbstractListModel, QModelIndex, QRect, QSize, QStandardPaths, Qt, + QThread, QTimer, Signal, Slot, +) +from PySide6.QtGui import QColor, QImage, QPainter, QPixmap +from PySide6.QtWidgets import ( + QAbstractItemView, QListView, QStyle, QStyledItemDelegate, + QVBoxLayout, QWidget, +) + +from app.constants import ACCENT, TEXT_SEC + + +_log = logging.getLogger(__name__) + + +class _FlushFileHandler(logging.FileHandler): + """FileHandler that flushes + fsyncs after every record. + + The project's hang-debugging notes warn that PowerShell terminal + redirection is unreliable for capturing the moments right before a + freeze / kill. Writing to a dedicated file with line-buffering plus + an explicit ``fsync`` guarantees the thumbnail diagnostics survive a + hard process kill so the user can re-send the log and we can close + the diagnosis without guessing (see memory/feedback_diag_logging.md). + """ + + def emit(self, record): # noqa: D401 + super().emit(record) + with contextlib.suppress(Exception): + self.flush() + os.fsync(self.stream.fileno()) + + +# Basename of the kill-proof diagnostic log. Only written when the env +# var PDFAPPS_THUMB_DEBUG is set (any value) so production runs stay +# quiet; the resolved path is stable so the user can be told exactly +# where to look. +THUMB_LOG_NAME = "pdfapps_thumbnails.log" + + +def _thumb_log_path() -> str: + """Resolve the diagnostic log path inside a per-user data directory. + + The old implementation dropped the log in a FIXED, predictable name + under the *shared* system temp dir (``/tmp`` on Linux/macOS). That is + exactly the symlink-following / attacker-pre-created-file pattern the + project's security notes warn about (see memory/feedback_security.md): + another user can pre-create or symlink ``/tmp/pdfapps_thumbnails.log`` + and our append-mode open would follow it. + + Writing under a per-user location owned by the current user (Windows: + ``%LOCALAPPDATA%/PDFApps``; Linux: ``~/.local/share/PDFApps``; macOS: + ``~/Library/Application Support/PDFApps``) removes the shared-dir + race: the directory is not world-writable, so a plain append open is + safe. A fixed ``PDFApps`` subfolder keeps the path deterministic + regardless of the (deliberately blank) QApplication name. + """ + base = QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.GenericDataLocation) + if not base: + # No usable data location (headless / unset HOME): fall back to a + # per-user dotdir in the home directory rather than shared temp. + base = os.path.join(os.path.expanduser("~"), ".local", "share") + d = os.path.join(base, "PDFApps") + os.makedirs(d, exist_ok=True) + return os.path.join(d, THUMB_LOG_NAME) + + +def _install_debug_log() -> None: + """Attach the kill-proof file handler when PDFAPPS_THUMB_DEBUG is set. + + Idempotent — safe to call from every ThumbnailPanel constructor. + """ + if not os.environ.get("PDFAPPS_THUMB_DEBUG"): + return + for h in _log.handlers: + if isinstance(h, _FlushFileHandler): + return + with contextlib.suppress(Exception): + log_path = _thumb_log_path() + handler = _FlushFileHandler(log_path, mode="a", encoding="utf-8") + handler.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)s %(message)s")) + handler.setLevel(logging.DEBUG) + _log.addHandler(handler) + _log.setLevel(logging.DEBUG) + _log.info("── thumbnail debug log opened (pid=%d) ──", os.getpid()) + + +THUMB_WIDTH = 120 # px – target thumbnail width before aspect-fit +THUMB_HEIGHT = 160 # px – target thumbnail height (portrait ~ 1.33) +THUMB_PADDING = 12 # px around the thumbnail inside a row +PAGE_NUM_HEIGHT = 20 # px reserved for the page-number label +CACHE_MAX = 200 # LRU-ish cap on cached QPixmaps +# How many rows above/below the viewport to pre-render so scrolling a +# little doesn't reveal a wall of "…" placeholders before the worker +# catches up. +VISIBLE_BUFFER = 4 +# When the panel is hidden (e.g. parked behind the "Contents" tab for a +# PDF that has a TOC) the viewport has no usable geometry, so we render a +# window of this many pages around the anchor (the current reading page) +# up-front. When the tab is later revealed, showEvent recomputes the +# real visible range and fills any gaps. This is the core of the fix: +# we NEVER eagerly render the whole document (which, for a doc longer +# than CACHE_MAX, evicted its own early pages before the user could see +# them and left the top of the list on placeholders forever). +HIDDEN_WINDOW = 12 + + +# ── Worker ──────────────────────────────────────────────────────────── + + +class ThumbnailWorker(QThread): + """Render a batch of page thumbnails in a background thread. + + The worker opens its own ``fitz.Document`` copy — pymupdf documents + are NOT thread-safe across the main-thread handle used by the + canvas. Password is applied when set. ``cancel()`` sets a flag the + render loop polls between pages; wait 2 s in the caller for a + graceful shutdown before dropping the reference. + """ + + # Emits QImage (thread-safe) rather than QPixmap: Qt requires + # QPixmap to be constructed / mutated only in the main GUI thread, + # so the receiver slot ``ThumbnailPanel._on_image_ready`` performs + # the ``QPixmap.fromImage()`` conversion on the main thread before + # handing the pixmap to the model. + thumbnail_ready = Signal(int, QImage, int) # (page_index, thumbnail, epoch) + # Emitted once when the worker finishes having rendered ZERO pages + # despite pages being requested (import failure, open failure, or + # every page raising). Carries (epoch, reason) so the panel can log + # it against the right generation instead of failing silently. + render_failed = Signal(int, str) # (epoch, reason) + + def __init__(self, doc_path: str, page_indices: list[int], + password: str = "", epoch: int = 0, dpr: float = 1.0, + parent=None) -> None: + super().__init__(parent) + self._doc_path = doc_path + self._pages = list(page_indices) + self._password = password + # Device-pixel ratio of the screen the panel is on. Thumbnails + # are rendered at target_size * dpr real pixels and tagged with + # this ratio so a HiDPI display shows crisp (not upscaled-blurry) + # pages. Never hardcoded — the panel reads the live value off its + # widget/screen and passes it in (see memory/feedback_icons.md). + self._dpr = float(dpr) if dpr and dpr > 0 else 1.0 + # Generation this worker was launched for. Emitted alongside + # each thumbnail so the panel can discard images produced for a + # document that has since been replaced (see ThumbnailPanel). + self._epoch = epoch + self._cancelled = False + + def cancel(self) -> None: + self._cancelled = True + + def run(self) -> None: + # ``import fitz`` lives INSIDE the try: a failed import (missing + # binary wheel, broken install) must surface as a warning + a + # render_failed signal, NOT silently kill the QThread with an + # unhandled ImportError that leaves the sidebar stuck on + # placeholders forever. + requested = len(self._pages) + rendered = 0 + doc = None + try: + import fitz # local — keeps import cost off UI startup path + try: + doc = fitz.open(self._doc_path) + except Exception as exc: + _log.warning( + "ThumbnailWorker: failed to open %r: %s", + self._doc_path, exc, + ) + return + if self._password and doc.needs_pass: + with contextlib.suppress(Exception): + doc.authenticate(self._password) + page_count = doc.page_count + for idx in self._pages: + if self._cancelled: + break + if idx < 0 or idx >= page_count: + continue + try: + _log.debug( + "ThumbnailWorker: rendering page %d/%d", + idx + 1, page_count, + ) + page = doc[idx] + # Render at the ACTUAL device-pixel size the thumbnail + # occupies: target box (THUMB_WIDTH×THUMB_HEIGHT) times + # the screen's device-pixel ratio. A fixed 40 DPI used + # to render ~331×468 for A4 and then the delegate + # downscaled to a 120×160 *logical* pixmap with no DPR + # set — so on a 2× HiDPI screen Qt upscaled that 120 px + # image to fill 240 device px: visibly soft/blurry. + # Computing zoom from the page rect keeps the aspect + # ratio (KeepAspectRatio via min) and hits the exact + # pixel budget, so the pixmap is drawn ~1:1 and sharp. + rect = page.rect + tw = THUMB_WIDTH * self._dpr + th = THUMB_HEIGHT * self._dpr + if rect.width > 0 and rect.height > 0: + zoom = min(tw / rect.width, th / rect.height) + else: + zoom = self._dpr + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat, alpha=False, + annots=False) + if pix.n != 3: + pix = fitz.Pixmap(fitz.csRGB, pix) + # ``QImage`` views ``pix.samples`` directly; the + # Pixmap is freed on the next loop iteration, so + # ``.copy()`` an eager copy of the pixels BEFORE + # emitting — otherwise the receiver sees a + # dangling buffer (same fix pattern as the print + # loop and OCR round 3). + # + # Emit QImage (thread-safe) — the main-thread slot + # converts to QPixmap. Constructing QPixmap here in + # the worker thread is invalid per Qt's threading + # model (silent no-op or crash on some platforms). + img = QImage( + pix.samples, pix.width, pix.height, + pix.stride, QImage.Format.Format_RGB888, + ).copy() + # Tag with the device-pixel ratio so the receiving + # QPixmap paints at its device-independent (logical) + # size while carrying full HiDPI detail. Preserved by + # QPixmap.fromImage on the main thread. + img.setDevicePixelRatio(self._dpr) + rendered += 1 + self.thumbnail_ready.emit(idx, img, self._epoch) + except Exception as exc: + # Skip page — bad object / partial doc, keep going — + # but log it: a wall of these is the difference + # between "one bad page" and "the whole doc failed". + _log.warning( + "ThumbnailWorker: failed to render page %d: %s", + idx + 1, exc, + ) + continue + except Exception as exc: + # import fitz / unexpected fatal — don't let the thread die + # mute; the panel needs to know rendering never happened. + _log.error( + "ThumbnailWorker: fatal error, no thumbnails produced: %s", + exc, + ) + finally: + if doc is not None: + with contextlib.suppress(Exception): + doc.close() + # Zero pages rendered when pages were requested and we + # weren't cancelled is a hard failure — make it loud and + # give the panel a chance to reflect it. + if requested and rendered == 0 and not self._cancelled: + _log.warning( + "ThumbnailWorker: rendered 0/%d pages for %r", + requested, self._doc_path, + ) + with contextlib.suppress(RuntimeError): + self.render_failed.emit( + self._epoch, + f"rendered 0/{requested} pages", + ) + + +# ── Model ───────────────────────────────────────────────────────────── + + +class ThumbnailModel(QAbstractListModel): + """List model: one row per page. Decoration = QPixmap thumbnail.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._page_count = 0 + self._cache: dict[int, QPixmap] = {} + self._doc_path = "" + + def rowCount(self, parent=QModelIndex()) -> int: # noqa: B008 + return 0 if parent.isValid() else self._page_count + + def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole): + row = index.row() + if row < 0 or row >= self._page_count: + return None + if role == Qt.ItemDataRole.DecorationRole: + return self._cache.get(row) # None → delegate paints placeholder + if role == Qt.ItemDataRole.DisplayRole: + return str(row + 1) + return None + + def set_document(self, doc_path: str, page_count: int) -> None: + self.beginResetModel() + self._doc_path = doc_path + self._page_count = max(0, int(page_count)) + self._cache.clear() + self.endResetModel() + + def clear(self) -> None: + self.set_document("", 0) + + def cache_pixmap(self, page_idx: int, pix: QPixmap) -> None: + # Refresh insertion order for LRU eviction: pop + re-add. + if page_idx in self._cache: + self._cache.pop(page_idx, None) + self._cache[page_idx] = pix + # Evict oldest entries until we're back under the cap. ``dict`` + # preserves insertion order since Python 3.7, so the first + # iterator element is the oldest. + while len(self._cache) > CACHE_MAX: + oldest = next(iter(self._cache)) + del self._cache[oldest] + idx = self.index(page_idx) + self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DecorationRole]) + + # Expose the cache size for tests / debug. + def cache_size(self) -> int: + return len(self._cache) + + def has_pixmap(self, page_idx: int) -> bool: + """True if ``page_idx`` currently has a cached (non-evicted) + pixmap. Used by the panel's lazy renderer to avoid re-requesting + pages that are already cached, while still allowing a page that + was LRU-evicted to be re-rendered when scrolled back into view.""" + return page_idx in self._cache + + def refresh_decorations(self) -> None: + """Re-emit ``dataChanged`` for every row's decoration. + + The worker fills ``self._cache`` while the panel may be hidden + (sidebar parked on the Contents tab). Qt drops/coalesces the + per-row ``dataChanged`` repaints for a hidden viewport, so those + pixmaps never reach the screen. When the panel becomes visible + again the view must be told to re-query DecorationRole for the + rows it now shows — a bare ``viewport().update()`` repaints but + does NOT force the view to re-read the model, so on some + platforms the cached-but-never-delivered pixmaps stay invisible. + Emitting dataChanged here guarantees a fresh read against the + now-populated cache. + """ + if self._page_count <= 0: + return + top = self.index(0) + bottom = self.index(self._page_count - 1) + self.dataChanged.emit(top, bottom, [Qt.ItemDataRole.DecorationRole]) + + +# ── Delegate ────────────────────────────────────────────────────────── + + +class ThumbnailDelegate(QStyledItemDelegate): + """Paint each row: thumbnail + page number, with current-page + highlight in ACCENT and hover feedback.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._current_page = -1 + self._dark = True + + def set_current_page(self, page_idx: int) -> int: + old = self._current_page + self._current_page = page_idx + return old + + def set_dark(self, dark: bool) -> None: + self._dark = bool(dark) + + def sizeHint(self, option, index): # noqa: D401 + return QSize( + THUMB_WIDTH + 2 * THUMB_PADDING, + THUMB_HEIGHT + 2 * THUMB_PADDING + PAGE_NUM_HEIGHT, + ) + + def paint(self, painter: QPainter, option, index: QModelIndex) -> None: + page_idx = index.row() + rect = option.rect + pix = index.data(Qt.ItemDataRole.DecorationRole) + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Row background — current selection or hover feedback. + if page_idx == self._current_page: + accent = QColor(ACCENT) + accent.setAlpha(60) + painter.setBrush(accent) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(rect.adjusted(4, 4, -4, -4), 6, 6) + elif option.state & QStyle.StateFlag.State_MouseOver: + hover = QColor(255, 255, 255, 20) if self._dark \ + else QColor(0, 0, 0, 20) + painter.setBrush(hover) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(rect.adjusted(4, 4, -4, -4), 6, 6) + + # Thumbnail region. + thumb_x = rect.x() + THUMB_PADDING + thumb_y = rect.y() + THUMB_PADDING + thumb_rect = QRect(thumb_x, thumb_y, THUMB_WIDTH, THUMB_HEIGHT) + + if pix is not None and not pix.isNull(): + # Scale in DEVICE pixels (target × DPR) then tag the result + # with the same ratio, so the pixmap paints at its logical + # THUMB box size while retaining full HiDPI detail — no soft + # upscaling on a 2× screen. Geometry below uses the logical + # (device-independent) size. + dpr = pix.devicePixelRatio() or 1.0 + scaled = pix.scaled( + round(THUMB_WIDTH * dpr), round(THUMB_HEIGHT * dpr), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + scaled.setDevicePixelRatio(dpr) + lw = round(scaled.width() / dpr) + lh = round(scaled.height() / dpr) + cx = thumb_x + (THUMB_WIDTH - lw) // 2 + cy = thumb_y + (THUMB_HEIGHT - lh) // 2 + painter.drawPixmap(cx, cy, scaled) + # 1 px hairline so a white-page thumbnail is still visible + # against a light background. + painter.setPen(QColor(0, 0, 0, 60)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(cx, cy, lw - 1, lh - 1) + else: + # Placeholder while the worker catches up. + painter.setPen(QColor(TEXT_SEC)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(thumb_rect) + painter.drawText(thumb_rect, Qt.AlignmentFlag.AlignCenter, "…") + + # Page number label under the thumbnail. + if page_idx == self._current_page: + painter.setPen(QColor(ACCENT)) + else: + painter.setPen(QColor(TEXT_SEC)) + num_rect = QRect( + rect.x(), thumb_y + THUMB_HEIGHT + 2, + rect.width(), PAGE_NUM_HEIGHT, + ) + painter.drawText(num_rect, Qt.AlignmentFlag.AlignCenter, + str(page_idx + 1)) + + painter.restore() + + +# ── Panel ───────────────────────────────────────────────────────────── + + +class ThumbnailPanel(QWidget): + """Sidebar container hosting the QListView of thumbnails. + + Emits :pyattr:`page_requested` when the user clicks / activates a + thumbnail. Owns the current ``ThumbnailWorker`` and shuts it down + cleanly on doc change / close event. + """ + + page_requested = Signal(int) # 0-based page index + + def __init__(self, parent=None) -> None: + super().__init__(parent) + _install_debug_log() + self.setObjectName("thumbnail_panel") + self._doc_path = "" + self._password = "" + # Multiple short-lived workers may run concurrently: as the user + # scrolls we spin one up per newly-visible batch rather than + # cancelling / blocking on the previous one. Finished workers are + # pruned lazily. The epoch guard drops any results for a + # superseded document. + self._workers: list[ThumbnailWorker] = [] + # Pages that have been handed to a live worker but whose pixmap + # has not yet arrived. Prevents re-requesting an in-flight page + # on every scroll tick. Cleared per-page in _on_image_ready and + # wholesale on set_document / clear. A page that is LRU-evicted + # from the model cache is NOT in this set, so it is re-rendered + # when scrolled back into view — that is what keeps long + # documents from stranding their early pages on placeholders. + self._inflight: set[int] = set() + # Page to centre the pre-render window on while the panel is + # hidden (tracks the current reading page via set_current_page). + self._anchor = 0 + # Monotonic generation counter. Bumped on every set_document / + # clear so late thumbnails from a superseded worker (still in the + # queued-connection event queue) can be identified and dropped + # instead of being cached against the NEW document's indices. + self._epoch = 0 + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._model = ThumbnailModel(self) + self._delegate = ThumbnailDelegate(self) + + self._view = QListView(self) + self._view.setModel(self._model) + self._view.setItemDelegate(self._delegate) + self._view.setViewMode(QListView.ViewMode.ListMode) + self._view.setUniformItemSizes(True) + self._view.setMouseTracking(True) + self._view.setEditTriggers( + QAbstractItemView.EditTrigger.NoEditTriggers) + self._view.setSelectionMode( + QAbstractItemView.SelectionMode.SingleSelection) + self._view.setVerticalScrollMode( + QAbstractItemView.ScrollMode.ScrollPerPixel) + self._view.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._view.clicked.connect(self._on_activated) + self._view.activated.connect(self._on_activated) # keyboard Enter + # Debounce scroll-driven render requests so a flick doesn't spawn + # a worker per pixel. The timer coalesces bursts into one render + # of the settled visible range. + self._scroll_timer = QTimer(self) + self._scroll_timer.setSingleShot(True) + self._scroll_timer.setInterval(120) + self._scroll_timer.timeout.connect(self._render_visible) + sb = self._view.verticalScrollBar() + if sb is not None: + sb.valueChanged.connect(lambda _=0: self._scroll_timer.start()) + layout.addWidget(self._view) + + # ── Public API ──────────────────────────────────────────────── + + def set_document(self, doc_path: str, page_count: int, + password: str = "") -> None: + """Reset model + kick off background rendering of the pages the + user is about to see (NOT the whole document).""" + self._doc_path = doc_path + self._password = password or "" + self._stop_all_workers() + # New generation: any thumbnail still queued from a prior worker + # now carries a stale epoch and will be dropped in _on_image_ready. + self._epoch += 1 + self._inflight.clear() + self._anchor = 0 + self._model.set_document(doc_path, page_count) + self._delegate.set_current_page(-1) + _log.debug( + "set_document: %r page_count=%d epoch=%d visible=%s", + doc_path, page_count, self._epoch, self.isVisible(), + ) + if page_count > 0 and doc_path: + self._render_visible() + + def clear(self) -> None: + """Unload the current document.""" + self._doc_path = "" + self._password = "" + self._stop_all_workers() + # New generation — see set_document. + self._epoch += 1 + self._inflight.clear() + self._anchor = 0 + self._model.clear() + self._delegate.set_current_page(-1) + + def set_current_page(self, page_idx: int) -> None: + """Highlight given page and scroll to it (if visible).""" + if not (0 <= page_idx < self._model.rowCount()): + return + # Remember the reading position so a hidden panel pre-renders the + # right window (the pages the user will land on when they open + # the Pages tab) instead of always page 1. + self._anchor = page_idx + old = self._delegate.set_current_page(page_idx) + idx = self._model.index(page_idx) + self._view.setCurrentIndex(idx) + self._view.scrollTo( + idx, QAbstractItemView.ScrollHint.EnsureVisible) + # Repaint both the previous and the new row so the highlight + # tracks correctly even though the model data didn't change. + vp = self._view.viewport() + if old >= 0: + old_rect = self._view.visualRect(self._model.index(old)) + vp.update(old_rect) + vp.update(self._view.visualRect(idx)) + # If the panel is hidden the scrollbar signal above won't have + # fired a render; make sure the window around the new anchor is + # requested so switching to the tab shows real thumbnails. + self._render_visible() + + def update_theme(self, dark: bool) -> None: + """Repaint so ACCENT / TEXT_SEC track the theme.""" + self._delegate.set_dark(dark) + self._view.viewport().update() + + # ── Internals ───────────────────────────────────────────────── + + def _on_activated(self, index: QModelIndex) -> None: + if index.isValid(): + self.page_requested.emit(index.row()) + + def _row_height(self) -> int: + """Uniform row height from the delegate's size hint.""" + return (self._delegate.sizeHint(None, self._model.index(0)).height() + or 1) + + def _visible_range(self) -> list[int]: + """Compute which page rows should be rendered right now. + + When the panel has real geometry we return the rows in the + viewport padded by VISIBLE_BUFFER. When it is hidden / not yet + laid out (viewport height 0 — exactly the state the Pages tab is + in while parked behind the Contents tab of a TOC PDF) the + viewport can't tell us anything, so we fall back to a window + around the anchor. This is the crux of the fix: we render the + pages the user will actually look at instead of the whole doc. + """ + total = self._model.rowCount() + if total <= 0: + return [] + row_h = self._row_height() + vp = self._view.viewport() + vp_h = vp.height() if vp is not None else 0 + if vp_h <= 0 or not self._view.isVisible(): + first = max(0, self._anchor - VISIBLE_BUFFER) + last = min(total - 1, self._anchor + HIDDEN_WINDOW) + return list(range(first, last + 1)) + sb = self._view.verticalScrollBar() + top = sb.value() if sb is not None else 0 + first = top // row_h + last = (top + vp_h) // row_h + first = max(0, first - VISIBLE_BUFFER) + last = min(total - 1, last + VISIBLE_BUFFER) + return list(range(first, last + 1)) + + def _render_visible(self) -> None: + """Request rendering of the currently-visible (or hidden-window) + pages that aren't already cached or in flight.""" + if not self._doc_path or self._model.rowCount() <= 0: + return + # Prune workers that have finished so the list can't grow without + # bound over a long scrolling session. + self._workers = [w for w in self._workers if w.isRunning()] + want = self._visible_range() + missing = [p for p in want + if p not in self._inflight and not self._model.has_pixmap(p)] + if not missing: + return + self._inflight.update(missing) + _log.debug( + "render_visible: want=[%d..%d] missing=%d inflight=%d " + "cache=%d workers=%d", + want[0] if want else -1, want[-1] if want else -1, + len(missing), len(self._inflight), + self._model.cache_size(), len(self._workers), + ) + self._start_worker(missing) + + def _start_worker(self, page_indices: list[int]) -> None: + # Read the LIVE device-pixel ratio off the view (falls back to the + # panel, then 1.0) so thumbnails render sharp on HiDPI screens. + # Never hardcoded — see memory/feedback_icons.md. + try: + dpr = (self._view.devicePixelRatioF() + or self.devicePixelRatioF() or 1.0) + except Exception: + dpr = 1.0 + worker = ThumbnailWorker( + self._doc_path, page_indices, self._password, self._epoch, + dpr, self) + # Explicit QueuedConnection so the slot runs on this (main) + # thread rather than the worker — required for QPixmap + # construction, and matches the Py3.14 PySide6 routing rules + # documented in memory/project_compress_freeze_py314.md. + worker.thumbnail_ready.connect( + self._on_image_ready, + Qt.ConnectionType.QueuedConnection, + ) + worker.render_failed.connect( + self._on_render_failed, + Qt.ConnectionType.QueuedConnection, + ) + # When the worker's run() returns, reconcile _inflight on the + # main thread: release any requested page it did NOT deliver so a + # later scroll can re-request it (see _on_worker_finished). Queued + # so the slot runs here, not in the worker thread. + worker.finished.connect( + self._on_worker_finished, + Qt.ConnectionType.QueuedConnection, + ) + self._workers.append(worker) + _log.debug("start_worker: %d pages epoch=%d", + len(page_indices), self._epoch) + worker.start() + + @Slot(int, QImage, int) + def _on_image_ready(self, page_idx: int, img: QImage, epoch: int) -> None: + """Runs on the main GUI thread. Convert the worker's QImage + into a QPixmap and hand it to the model. + + QPixmap construction is only valid on the main thread; doing it + here (instead of in the worker) is why the parent signal + carries QImage.""" + # Drop thumbnails from a superseded document: a queued emission + # from the previous worker can still be delivered after + # set_document/clear bumped the epoch, and caching it would paint + # the old document's page into the new document's index. + if epoch != self._epoch: + _log.debug( + "Dropping stale thumbnail page %d (epoch %d != %d)", + page_idx + 1, epoch, self._epoch, + ) + return + # This page is no longer in flight; a future scroll that re-visits + # it will now see has_pixmap() True and skip it (or, if evicted, + # re-request it). + self._inflight.discard(page_idx) + pix = QPixmap.fromImage(img) + # fromImage propagates the image's devicePixelRatio, but re-assert + # it explicitly so the delegate can always read the correct HiDPI + # scale off the pixmap regardless of binding quirks. + pix.setDevicePixelRatio(img.devicePixelRatio()) + self._model.cache_pixmap(page_idx, pix) + _log.debug( + "image_ready: page %d (%dx%d) cache=%d", + page_idx + 1, img.width(), img.height(), self._model.cache_size(), + ) + + @Slot(int, str) + def _on_render_failed(self, epoch: int, reason: str) -> None: + """Runs on the main thread. Surface a worker that produced no + thumbnails instead of leaving the sidebar silently stuck on + placeholders. Stale generations are ignored.""" + # A worker that rendered zero pages left ALL its requested pages + # pinned in _inflight; release them (idempotent with the finished + # handler) so they aren't stranded on placeholders forever. + worker = self.sender() + if isinstance(worker, ThumbnailWorker): + self._release_inflight_for(worker) + if epoch != self._epoch: + return + _log.warning( + "Thumbnail rendering failed for %r: %s", + self._doc_path, reason, + ) + + @Slot() + def _on_worker_finished(self) -> None: + """Runs on the main thread when a worker's run() returns. + + Reconcile bookkeeping so a page can never be stranded in + ``_inflight`` by a worker that failed to deliver it, and drop the + finished QThread so they don't accumulate over a long session. + """ + worker = self.sender() + if not isinstance(worker, ThumbnailWorker): + return + self._release_inflight_for(worker) + with contextlib.suppress(ValueError): + self._workers.remove(worker) + # run() has returned by the time finished fires and we've already + # reconciled its inflight pages, so it's safe to schedule deletion + # (MINOR-1: don't leak finished QThreads parented to the panel). + worker.deleteLater() + + def _release_inflight_for(self, worker: ThumbnailWorker) -> None: + """Release from ``_inflight`` every page this worker was asked to + render but did NOT deliver (i.e. is not in the model cache). + + Called when a worker finishes or fails. Without it, a page the + worker skipped — out of range, a per-page render exception, or a + whole-document open failure — would stay pinned in ``_inflight`` + forever, and ``_render_visible`` (which excludes in-flight pages) + would never request it again: placeholder-forever by failed page. + Delivered pages were already discarded from ``_inflight`` in + ``_on_image_ready`` and remain cached, so they are left alone. + Not re-triggering a render here is deliberate: a genuinely broken + page is retried only when the user scrolls it back into view, + never in a tight busy-loop. + """ + # Stale generation: set_document/clear already cleared _inflight + # for the new document, so this worker's page numbers no longer + # refer to the current in-flight set. Touching it would corrupt + # the new generation's bookkeeping. + if worker._epoch != self._epoch: + return + for page in worker._pages: + if not self._model.has_pixmap(page): + self._inflight.discard(page) + + def _stop_all_workers(self) -> None: + """Cancel + join every live worker. Called on doc change / close + so a stale QThread can't outlive the panel or leak thumbnails + into the next document.""" + workers, self._workers = self._workers, [] + for worker in workers: + worker.cancel() + # Disconnect BEFORE waiting so a thumbnail emitted between the + # cancel flag check and the worker unwinding can't sneak into + # the model for the old document. The epoch guard in + # _on_image_ready is the belt; this disconnect is the braces. + with contextlib.suppress(RuntimeError, TypeError): + worker.thumbnail_ready.disconnect(self._on_image_ready) + with contextlib.suppress(RuntimeError, TypeError): + worker.render_failed.disconnect(self._on_render_failed) + # 2 s is generous — a single page render at 40 DPI is well + # under 100 ms; the cancel flag is polled between pages so + # worst case we wait for the current page to finish. + worker.wait(2000) + + def showEvent(self, event): # noqa: D401 + """Render + repaint the now-visible rows when the panel appears. + + The Pages tab is typically hidden when the document loads (parked + behind the Contents tab for a PDF that has a TOC), so this is + where the real visible range first becomes knowable. We (1) kick + the lazy renderer for the pages now on screen and (2) force the + view to re-read DecorationRole against the cache so any pixmaps + that arrived while hidden are painted instead of staying on ``…`` + placeholders. + """ + super().showEvent(event) + if self._view is None: + return + _log.debug("showEvent: visible=%s cache=%d", + self.isVisible(), self._model.cache_size()) + self._render_visible() + vp = self._view.viewport() + if vp is not None: + self._model.refresh_decorations() + vp.update() + + # Layout can still be incomplete when showEvent fires (the tab + # was just switched — viewport size is still the collapsed + # placeholder value). Re-run after the current event-loop + # iteration against the final geometry so we render/repaint the + # rows that are actually on screen. + def _refresh_again(): + if self._view is None: + return + self._render_visible() + self._model.refresh_decorations() + v = self._view.viewport() + if v is not None: + v.update() + QTimer.singleShot(0, _refresh_again) + + def resizeEvent(self, event): # noqa: D401 + """Render + repaint newly-visible rows on resize so growing the + panel (or the first real layout after a collapsed one) fills in + thumbnails instead of leaving fresh rows on placeholders.""" + super().resizeEvent(event) + if self._view is None: + return + self._render_visible() + vp = self._view.viewport() + if vp is not None: + self._model.refresh_decorations() + vp.update() + + def closeEvent(self, event): # noqa: D401 + self._stop_all_workers() + super().closeEvent(event) diff --git a/app/window.py b/app/window.py index 8d2710c..1db8f43 100644 --- a/app/window.py +++ b/app/window.py @@ -1606,7 +1606,10 @@ def _notify_store_update(self, latest: str, deep_link: str): t("update.store.btn.open"), QMessageBox.ButtonRole.AcceptRole, ) - later_btn = box.addButton( + # "Later" button: its handle isn't needed — clickedButton() is + # compared against open/dismiss only, and anything else (Later or + # the close-box) is a no-op — but the button must still exist. + box.addButton( t("update.store.btn.later"), QMessageBox.ButtonRole.RejectRole, ) @@ -1632,8 +1635,8 @@ def _notify_store_update(self, latest: str, deep_link: str): with contextlib.suppress(Exception): if latest: set_dismissed_store_version(latest) - # "Later" (later_btn) and the close-box: do nothing; dialog - # will reappear on the next startup. + # "Later" and the close-box: do nothing; dialog will reappear on + # the next startup. def _show_update_dialog(self): if self._update_release: diff --git a/docs/changelog.html b/docs/changelog.html index 283937f..cda72ab 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -65,11 +65,26 @@
artifacts-*/ release folders.The viewer sidebar can show a panel of page miniatures. Scroll through it and click any thumbnail to jump straight to that page; the current page stays highlighted as you move around the document. Thumbnails render lazily as they scroll into view, so even long PDFs open instantly, and they stay sharp on HiDPI displays.
+When the PDF has an outline, the panel also offers a Contents tab with the clickable table of contents, so you can switch between browsing by thumbnail and jumping by chapter. For PDFs without bookmarks the panel simply shows the thumbnails.
+Press Ctrl+F to open the search bar. Results are highlighted in real-time as you type. Use the up/down arrows to jump between matches, or press Escape to close.
@@ -383,7 +390,7 @@Free forever, open source. Choose your platform below; installation takes less than a minute.
Clickable side panel showing the PDF outline. Jump to any chapter or section.
+A sidebar panel with miniatures of every page. Scroll and click to jump anywhere; thumbnails render as they come into view and stay crisp on HiDPI screens. A Contents tab appears when the PDF has an outline.
+