Source code for zooui.objects.mediaobjects.pdfmediaobject
## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.
"""Multi-page PDF rendered as individually tiled pages with page navigation."""
import atexit
import glob as _glob
import os
import shutil
import tempfile
from typing import Any
from PySide6 import QtCore, QtWidgets
from zooui.converters import converterrunner
from zooui.logger import get_logger
from zooui.tilesystem import tilemanager as TileManager
from zooui.tilesystem.tiler.tilerrunner import TilingHandle, submit_tiling
from .tiledmediaobject import TiledMediaObject
[docs]
class _BlockingConverter:
"""Dummy converter whose progress never reaches 1.0.
Used to prevent TiledMediaObject.render() from calling __run_tiler()
while PdfMediaObject manages page-specific tiling externally.
"""
progress: float = 0.0
[docs]
class PdfMediaObject(TiledMediaObject):
"""
Constructor :
PdfMediaObject(pdf_path, scene, autofit, start_page)
Parameters :
pdf_path : str
scene : Scene
autofit : bool (default=True)
start_page : int (default=0)
PdfMediaObject(pdf_path, scene, autofit, start_page) --> None
A multi-page PDF rendered as individually tiled pages in the ZUI.
Each page is rasterized as a separate PPM and tiled independently.
Page navigation is controlled via Ctrl+Up/Down and Ctrl+Alt+G (go to page).
A lazy 2-page tiling buffer tiles the current page and the next page,
providing instant display on forward navigation.
"""
def __init__(self, pdf_path: str, scene: Any, autofit: bool = True, start_page: int = 0) -> None:
"""
Constructor :
PdfMediaObject(pdf_path, scene, autofit, start_page)
Parameters :
pdf_path : str
scene : Scene
autofit : bool (default=True)
start_page : int (default=0)
PdfMediaObject(pdf_path, scene, autofit, start_page) --> None
Initialize a new PdfMediaObject from the PDF at pdf_path.
Sets up deferred initialization — the PDF is converted in a background
process, then pages are tiled lazily with a 2-page buffer.
"""
TiledMediaObject.__init__(self, pdf_path, scene, autofit, deferred=True)
self._pdf_path: str = pdf_path
self._start_page: int = max(0, start_page)
self._page_count: int = 0
self._current_page: int = 0
self._page_ppm_paths: list[str] = []
self._page_tilers: dict[int, TilingHandle] = {}
self._page_tiling: set[int] = set()
self._page_tiled: set[int] = set()
self._pending_nav_direction: int | None = None
self._logger = get_logger(f"PdfMediaObject.{pdf_path}")
self._outdir: str = tempfile.mkdtemp(prefix="zooui_pdf_")
atexit.register(lambda d=self._outdir: shutil.rmtree(d, ignore_errors=True))
future = converterrunner.submit_pdf_conversion(pdf_path, self._outdir)
self.__pdf_converter = converterrunner.ConversionHandle(future, pdf_path, self._outdir)
self._dummy_converter = _BlockingConverter()
self._media_id = f"{pdf_path}:page:{self._current_page}"
@property
def current_page(self) -> int:
"""Zero-based index of the current page."""
return self._current_page
@property
def page_count(self) -> int:
"""Total number of pages in the PDF (0 while loading)."""
return self._page_count
@property
def current_page_label(self) -> str:
"""Human-readable page label, e.g. 'Page 3/42'."""
if self._page_count:
return f"Page {self._current_page + 1}/{self._page_count}"
return "Loading..."
[docs]
def change_page(self, delta: int) -> None:
"""Navigate by delta pages (-1 = previous, +1 = next)."""
if self._page_count <= 1:
return
new_page = max(0, min(self._page_count - 1, self._current_page + delta))
if new_page == self._current_page:
return
self._pending_nav_direction = 1 if delta > 0 else -1
self._current_page = new_page
new_media_id = f"{self._pdf_path}:page:{new_page}"
self._reset_for_page(new_media_id)
self._ensure_page_tiling(new_page)
self._maintain_buffer()
[docs]
def _try_load(self) -> None:
"""Override to apply smart positioning when a page finishes loading."""
was_loaded = self._loaded
super()._try_load()
if not was_loaded and self._loaded and self._pending_nav_direction is not None:
direction = self._pending_nav_direction
self._pending_nav_direction = None
_viewport_w, viewport_h = self._scene.viewport_size
if direction > 0:
current_tl = self.topleft
# place "-current_tl[0]" in the first argument to get topleft behaviour
self.move(0, -current_tl[1])
else:
current_br = self.bottomright
# place "viewport_w - current_br[0]" in the first argument to get bottomright behaviour
self.move(0, viewport_h - current_br[1])
[docs]
def go_to_page(self, page_number: int) -> None:
"""Jump directly to a page (1-indexed)."""
if not 1 <= page_number <= self._page_count:
return
page_idx = page_number - 1
delta = page_idx - self._current_page
self.change_page(delta)
[docs]
def handle_key(self, key: int, modifiers: QtCore.Qt.KeyboardModifier) -> bool:
"""Handle page navigation keys. Returns True if consumed."""
if self._page_count <= 0:
return False
if modifiers & QtCore.Qt.ControlModifier:
if key == QtCore.Qt.Key_Up:
self.change_page(-1)
return True
elif key == QtCore.Qt.Key_Down:
self.change_page(1)
return True
elif key == QtCore.Qt.Key_G and (modifiers & QtCore.Qt.AltModifier):
self._show_go_to_page_dialog()
return True
return False
[docs]
def render(self, painter: Any, mode: int) -> None:
"""Override render to manage page lifecycle and prevent premature tiling."""
self._check_conversion_complete()
if not self._page_count:
self._TiledMediaObject__converter = self._dummy_converter
super().render(painter, mode)
return
self._check_page_tiling()
page_media_id = f"{self._pdf_path}:page:{self._current_page}"
if not TileManager.tiled(page_media_id) and self._current_page not in self._page_tiled:
self._ensure_page_tiling(self._current_page)
self._maintain_buffer()
self._TiledMediaObject__converter = self._dummy_converter
elif self._TiledMediaObject__converter is self._dummy_converter:
self._TiledMediaObject__converter = None # type: ignore[assignment]
super().render(painter, mode)
[docs]
def _on_conversion_complete(self) -> None:
"""Handle PDF conversion completion: discover pages and start tiling."""
ppm_files = sorted(_glob.glob(os.path.join(self._outdir, "page_*.ppm")))
if not ppm_files:
self._logger.error("PDF conversion produced no pages")
self._TiledMediaObject__converter = None # type: ignore[assignment]
return
self._page_count = len(ppm_files)
self._page_ppm_paths = ppm_files
self._logger.info("PDF converted: %d pages", self._page_count)
if self._start_page >= self._page_count:
self._start_page = 0
self._current_page = self._start_page
new_media_id = f"{self._pdf_path}:page:{self._current_page}"
self._reset_for_page(new_media_id)
self._ensure_page_tiling(self._current_page)
self._maintain_buffer()
self._TiledMediaObject__converter = None # type: ignore[assignment]
[docs]
def _check_conversion_complete(self) -> None:
"""Check if the PDF conversion process has finished."""
if self._page_count > 0:
return
if self.__pdf_converter and self.__pdf_converter.progress == 1.0:
if self.__pdf_converter.error:
self._logger.error("PDF conversion failed: %s", self.__pdf_converter.error)
self._TiledMediaObject__converter = None # type: ignore[assignment]
return
self._on_conversion_complete()
[docs]
def _check_page_tiling(self) -> None:
"""Check if the current page's tiling process has finished."""
page = self._current_page
tiler = self._page_tilers.get(page)
if tiler is not None and tiler.progress == 1.0:
page_media_id = f"{self._pdf_path}:page:{page}"
if TileManager.tiled(page_media_id):
self._page_tiled.add(page)
self._page_tiling.discard(page)
del self._page_tilers[page]
[docs]
def _ensure_page_tiling(self, page_idx: int) -> None:
"""Ensure that a page has been submitted for tiling, unless already done."""
if page_idx < 0 or page_idx >= self._page_count:
return
page_media_id = f"{self._pdf_path}:page:{page_idx}"
if TileManager.tiled(page_media_id):
self._page_tiled.add(page_idx)
return
if page_idx in self._page_tiling or page_idx in self._page_tiled:
return
self._start_tiling_page(page_idx)
[docs]
def _maintain_buffer(self) -> None:
"""Maintain 2-page lazy buffer: current page + next page."""
for p in (self._current_page, self._current_page + 1):
if 0 <= p < self._page_count:
self._ensure_page_tiling(p)
[docs]
def _start_tiling_page(self, page_idx: int) -> None:
"""Submit a tiling job for a specific page's PPM."""
ppm_path = self._page_ppm_paths[page_idx]
page_media_id = f"{self._pdf_path}:page:{page_idx}"
try:
future = submit_tiling(ppm_path, page_media_id, "png")
self._page_tilers[page_idx] = TilingHandle(future, ppm_path, page_media_id)
self._page_tiling.add(page_idx)
except Exception as e:
self._logger.error("Failed to start tiling for page %d: %s", page_idx, e)
[docs]
def _show_go_to_page_dialog(self) -> None:
"""Show a dialog to jump to a specific page number."""
page_num, ok = QtWidgets.QInputDialog.getInt(
None,
"Go to Page",
f"Enter page number (1\u2013{self._page_count}):",
self._current_page + 1,
1,
self._page_count,
)
if ok:
self.go_to_page(page_num)