Skip to content

zettelkasten.equation_snapshot

zettelkasten.equation_snapshot

Equation snapshots: crop the source-PDF region for an equation note.

PDF text extraction destroys math (symbols become mojibake or vanish, structure linearizes), so the LaTeX an equation note carries in its body is a transcription — not verbatim. The visual ground-truth is the equation as it actually appears in the source, so this module renders that region to a committed PNG sidecar.

Given a source graph, it resolves the original PDF from the source's committed pointer (source_path or zotero_key in _meta.yaml), renders the relevant page with pypdfium2, optionally crops to a rectangle (typically a Zotero image/area annotation the reader drew around the equation), and writes the crop to <graph>/_equations/<note_id>.png. The pointer it returns ({path, content_hash, page, rect?}) makes the crop reproducible from the original PDF, mirroring how the full-text cache re-derives from its pointer.

Everything here is best-effort: a missing PDF, an out-of-range page, or an uninstalled renderer returns an {"error": ...} dict rather than raising, so a snapshot failure never blocks authoring the equation note itself.

capture_snapshot

capture_snapshot(graph: str, note_id: str, *, page_index: int, page_label: str = '', rect: list[float] | tuple[float, float, float, float] | None = None, scale: float = _DEFAULT_SCALE, subdir: str = SNAPSHOT_SUBDIR) -> dict[str, Any]

Render (and optionally crop) a source PDF region into a committed sidecar.

Parameters:

Name Type Description Default
graph str

The source graph whose PDF the equation was transcribed from.

required
note_id str

The equation note id; names the sidecar file.

required
page_index int

Zero-based page index to render.

required
page_label str

Human page label to store in the pointer (defaults to page_index + 1 when empty).

''
rect list[float] | tuple[float, float, float, float] | None

Optional [x1, y1, x2, y2] in PDF points (bottom-left origin, i.e. Zotero annotation space). None snapshots the whole page.

None
scale float

Render scale (≈ DPI / 72).

_DEFAULT_SCALE
subdir str

Sidecar subdirectory under <graph>/_equations (default) for equation crops, _figures for figure crops — so the two kinds never share a directory.

SNAPSHOT_SUBDIR

Returns the pointer dict {path, content_hash, page, rect?} on success — path is POSIX-relative to the graphs dir — or {"error": ...} on any failure (missing PDF, bad page, renderer not installed).

Source code in zettelkasten/equation_snapshot.py
def capture_snapshot(
    graph: str,
    note_id: str,
    *,
    page_index: int,
    page_label: str = "",
    rect: list[float] | tuple[float, float, float, float] | None = None,
    scale: float = _DEFAULT_SCALE,
    subdir: str = SNAPSHOT_SUBDIR,
) -> dict[str, Any]:
    """Render (and optionally crop) a source PDF region into a committed sidecar.

    Args:
        graph: The source graph whose PDF the equation was transcribed from.
        note_id: The equation note id; names the sidecar file.
        page_index: Zero-based page index to render.
        page_label: Human page label to store in the pointer (defaults to
            ``page_index + 1`` when empty).
        rect: Optional ``[x1, y1, x2, y2]`` in PDF points (bottom-left origin,
            i.e. Zotero annotation space). ``None`` snapshots the whole page.
        scale: Render scale (≈ DPI / 72).
        subdir: Sidecar subdirectory under ``<graph>/`` — ``_equations`` (default)
            for equation crops, ``_figures`` for figure crops — so the two kinds
            never share a directory.

    Returns the pointer dict ``{path, content_hash, page, rect?}`` on success —
    ``path`` is POSIX-relative to the graphs dir — or ``{"error": ...}`` on any
    failure (missing PDF, bad page, renderer not installed).
    """
    try:
        import pypdfium2 as pdfium  # noqa: PLC0415 — optional heavy dep, import lazily
    except ImportError:
        return {
            "error": (
                "pypdfium2 is not installed; equation snapshots require it "
                "(pip install pypdfium2 pillow)."
            ),
            "type": "DependencyMissing",
        }

    pdf_path = _resolve_pdf_path(graph)
    if pdf_path is None:
        return {
            "error": (
                f"No source PDF found for graph '{graph}' — cannot snapshot. The "
                "source needs a committed source_path or zotero_key pointing at a "
                "PDF on this machine."
            ),
            "type": "NotFound",
        }

    pdf = None
    try:
        pdf = pdfium.PdfDocument(str(pdf_path))
        n_pages = len(pdf)
        if not (0 <= page_index < n_pages):
            return {
                "error": (
                    f"page_index {page_index} out of range (source has {n_pages} "
                    "pages)."
                ),
                "type": "BadRequest",
            }
        page = pdf[page_index]
        width_pt, height_pt = page.get_size()
        bitmap = page.render(scale=scale)
        image = bitmap.to_pil()

        if rect and len(rect) == 4:
            x1, y1, x2, y2 = (float(v) for v in rect)
            # Pad, clamp to the page, and convert PDF (bottom-left origin) points
            # to raster (top-left origin) pixels.
            lo_x, hi_x = min(x1, x2) - _CROP_PAD_PT, max(x1, x2) + _CROP_PAD_PT
            lo_y, hi_y = min(y1, y2) - _CROP_PAD_PT, max(y1, y2) + _CROP_PAD_PT
            lo_x, hi_x = max(0.0, lo_x), min(width_pt, hi_x)
            lo_y, hi_y = max(0.0, lo_y), min(height_pt, hi_y)
            left = int(lo_x * scale)
            right = int(hi_x * scale)
            top = int((height_pt - hi_y) * scale)
            bottom = int((height_pt - lo_y) * scale)
            if right > left and bottom > top:
                image = image.crop((left, top, right, bottom))

        out_rel, out_abs = _sidecar_path(graph, note_id, subdir)
        out_abs.parent.mkdir(parents=True, exist_ok=True)
        image.save(str(out_abs), format="PNG")

        pointer: dict[str, Any] = {
            "path": out_rel,
            "content_hash": _content_hash(graph, pdf_path),
            "page": str(page_label or (page_index + 1)),
            "source_graph": graph,
        }
        if rect and len(rect) == 4:
            pointer["rect"] = [float(v) for v in rect]
        return pointer
    except Exception as exc:  # noqa: BLE001 — snapshot must never crash the write
        return {"error": f"Failed to render equation snapshot: {exc}", "type": "RenderError"}
    finally:
        if pdf is not None:
            try:
                pdf.close()
            except Exception:  # noqa: BLE001
                pass

resolve_snapshot_path

resolve_snapshot_path(rel_path: str) -> Path | None

Resolve a stored snapshot path (relative to the graphs dir) to a file.

Path-jailed via :func:safe_join; returns None when the file is absent or the path escapes the graphs dir. Used by the dashboard image endpoint.

Source code in zettelkasten/equation_snapshot.py
def resolve_snapshot_path(rel_path: str) -> Path | None:
    """Resolve a stored snapshot ``path`` (relative to the graphs dir) to a file.

    Path-jailed via :func:`safe_join`; returns ``None`` when the file is absent
    or the path escapes the graphs dir. Used by the dashboard image endpoint.
    """
    from zettelkasten.graph_io import _graphs_dir, safe_join

    if not rel_path:
        return None
    try:
        base = _graphs_dir()
        target = safe_join(base, rel_path)
    except Exception:  # noqa: BLE001 — traversal / bad path
        return None
    return target if target.is_file() else None