PDF media object¶
PdfMediaObject extends TiledMediaObject to render multi-page
PDF documents as individually tiled pages in the ZUI. Each page is rasterized
as a separate PPM by PDFConverter,
tiled independently, and stored in the tilestore under
per-page media_id values ("doc.pdf:page:0", "doc.pdf:page:1", …).
Application Flow¶
Here is the complete lifecycle of a PdfMediaObject, from file opening to
rendering:
1. USER ACTION: Open a PDF file
MainWindow.__open_media() detects .pdf extension
└─→ PdfMediaObject(pdf_path, scene, autofit, start_page)
2. PdfMediaObject.__init__()
├─ Calls TiledMediaObject.__init__(deferred=True) for clean setup
├─ Creates temp output directory (tempfile.mkdtemp)
├─ Submits PDF→per-page-PPM conversion to process pool
│ future = converterrunner.submit_pdf_conversion(pdf_path, outdir)
└─ Sets _media_id = "{pdf_path}:page:0"
3. CONVERSION PHASE (process-based)
PDFConverter runs in a separate process:
├─ pdftoppm rasterizes all pages at 300 DPI into tmpdir
├─ Per-page PPMs are copied to outdir:
│ outdir/page_0000.ppm, outdir/page_0001.ppm, ...
└─ page_count and page_paths are populated
4. PAGE DISCOVERY (PdfMediaObject._on_conversion_complete)
├─ glob(glob) discovers page_*.ppm files
├─ Sets _page_count and _page_ppm_paths
├─ Clamps _start_page if beyond range
├─ Calls _reset_for_page(media_id_for_page_0)
└─ Starts tiling with 2-page buffer (_maintain_buffer)
5. LAZY TILING BUFFER
_maintain_buffer() ensures the current page and the next page
are submitted for tiling:
├─ _ensure_page_tiling(curr) → submit_tiling(ppm, media_id, "png")
└─ _ensure_page_tiling(curr+1) → submit_tiling(ppm, media_id, "png")
6. RENDER LOOP (PdfMediaObject.render())
├─ _check_conversion_complete() — discover pages when conversion ends
├─ _check_page_tiling() — mark pages as tiled when tilers complete
├─ If current page not tiled: set _BlockingConverter dummy
│ to prevent parent's __run_tiler() from firing on missing PPM
└─ super().render() — delegates to TiledMediaObject's tileblock
rendering pipeline
7. PAGE SWITCHING (Ctrl+↑/↓ or go_to_page)
├─ change_page(delta) sets _pending_nav_direction
├─ _reset_for_page(new_media_id) resets tileblock caches,
│ keeps dimensions from previous page for correct autofit bbox
└─ _try_load() override applies smart alignment after autofit:
• Forward (delta>0): move(0, -page_topleft.y) — top of page aligns to top of viewport
• Backward (delta<0): move(0, viewport_h - page_bottomright.y) — bottom of page aligns to bottom of viewport
Lazy Tiling Buffer¶
Rather than tiling all pages at once (which would be slow for large PDFs), only the current page and the next page are submitted for tiling. This provides instant display on forward navigation because the next page’s tiles are already on disk.
def _maintain_buffer(self):
for p in (self._current_page, self._current_page + 1):
if 0 <= p < self._page_count:
self._ensure_page_tiling(p)
Page state is tracked in three sets:
- _page_tiling: pages with an active tiling job submitted
- _page_tiled: pages whose tiles are confirmed on disk
- _page_tilers: mapping of page index → active TilingHandle
Previously visited pages that are still in the tilestore are detected via
TileManager.tiled() and marked as tiled immediately.
_BlockingConverter Pattern¶
TiledMediaObject.render falls through to TiledMediaObject.__run_tiler()
when both __converter and __tiler are None. Since PdfMediaObject
manages page-specific tiling externally (via tilerrunner.submit_tiling()),
it must prevent this fallthrough for pages that haven’t been tiled yet.
The solution is _BlockingConverter, a dummy sentinel whose
progress attribute is always 0.0:
class _BlockingConverter:
progress: float = 0.0
When a page is not yet tiled, PdfMediaObject.render() assigns the dummy
to _TiledMediaObject__converter before calling super().render().
The parent sees __converter.progress == 0.0 and stays in the “loading”
state, showing the placeholder. Once the page’s tiling completes, the dummy
is replaced with None and the parent loads the root tile normally.
Large PDF Page Selection¶
PDFs larger than 2 MB trigger a pre‑open dialog before conversion begins.
The page count is quickly obtained by calling the pdfinfo command‑line
tool (part of Poppler, installed alongside pdftoppm):
@staticmethod
def _get_pdf_page_count(pdf_path: str) -> int | None:
result = subprocess.run(["pdfinfo", pdf_path], ...)
for line in result.stdout.splitlines():
if line.startswith("Pages:"):
return int(line.split(":", 1)[1].strip())
return None
A QInputDialog then asks the
user which page to open first. The selected page becomes the
start_page parameter passed to PdfMediaObject.
_reset_for_page and deferred Init¶
Two additions to TiledMediaObject enable subclass‑based page
switching:
deferred=True(constructor parameter)Skips the normal conversion/tiling setup in
__init__, allowing the subclass to manage its own pipeline._reset_for_page(new_media_id)Switches the underlying
media_idand resets all tileblock caches (_loaded,__tileblock,__converter,__tiler) without resetting the dimension data (__width,__height,__aspect_ratio,__maxtilelevel). Keeping the old dimensions ensures the autofit bounding box in_try_load()uses the correct on‑screen size from the previous page, avoiding visual drift.
Scene Persistence¶
PdfMediaObject participates in the full save/load round‑trip through
.pzs scene files. On save, it writes its class name and per‑page
media_id:
PdfMediaObject /path/to/doc.pdf:page:3 2.5 150.0 200.0
The media_id carries both the original PDF path and the current
page number (last seen before the save), using the format
"{pdf_path}:page:{current_page}".
On scene load, Scene._create_mediaobject_from_line() recognises the
PdfMediaObject class name and:
Parses the
media_idviaScene._parse_pdf_media_id(), which splits at the last:page:delimiter to extract the file path and page number (usingrsplit(":page:", 1)to handle paths that themselves contain:page:).Checks that the original PDF file still exists on disk. If the file is gone, the object is silently dropped with a warning log and the rest of the scene still loads.
Creates a new
PdfMediaObject(pdf_path, scene, autofit=False, start_page=page_number), preserving the saved zoomlevel, position, and page.
Note
autofit=False is critical during load. It prevents the zoomlevel
from being recalculated when the actual image dimensions load,
preserving the saved value exactly — the same pattern used by
TiledMediaObject scene persistence.
The per‑page tiles themselves are stored in the tilestore under
their media_id hashes (derived from sha1("pdf_path:page:N")) and
are detected by TileManager.tiled() on reload, avoiding
re‑conversion. The PDF conversion pipeline re‑runs only if the tiles are
not yet cached.
PDF Page Numbering¶
When the PDFConverter rasterises a PDF, it can optionally draw a
"N / total" page‑number label onto each PPM image in the
bottom‑right corner. This is controlled by the page_numbering flag
(default True):
converter = PDFConverter(pdf_path, outdir, page_numbering=True)
The label is rendered using PIL with a semi‑transparent background so it never obscures content but remains readable against any PDF page colour.
See Also¶
Tiled media object — Parent class
Converter System — PDF conversion pipeline
Object System — Object system hierarchy
zooui.converters.pdfconverter module — PDFConverter API reference
../zooui/tilestore — Persistent tile storage
User Interface — User interface keyboard shortcuts