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_id and 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:

  1. Parses the media_id via Scene._parse_pdf_media_id(), which splits at the last :page: delimiter to extract the file path and page number (using rsplit(":page:", 1) to handle paths that themselves contain :page:).

  2. 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.

  3. 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