SVG diagrams in notes

New JSONHTL block type, svg — see JSONHTL_SPEC for the schema. This note covers implementation across the three renderers.

Why SVG, and why stored as text

The whole point is small, diffable notes. SVG is XML to begin with, so storing the raw markup in body (like codeblock.body) costs nothing extra — no base64 inflation in storage, and it stays readable in git diff / notes history. Base64 only happens transiently at render time, for the two renderers that need it (see below).

Web renderer (notes_web.py)

Renders via <img src="data:image/svg+xml;base64,..."> — deliberately never inlines raw <svg> markup. This page is public (critchley.biz/notes/...), and inline SVG can carry <script>, on*= handlers, or <foreignObject> embedding arbitrary HTML. Rather than hand-roll an SVG sanitizer (easy to get subtly wrong), img-loaded SVG is treated by browsers as a static, non-scriptable image regardless of markup content — sidesteps the sanitization problem entirely. Wrapped in <figure>/<figcaption> when a caption is given, alt set for accessibility.

Function: _render_svg() in notes_web.py.

wx desktop browsers (notes_browser.py, notes_browser_runnable.py)

wx.html.HtmlWindow cannot render SVG at all. Two library options were tried:

  1. wx.svg.SVGimage (wxPython's bundled NanoSVG-based renderer) — ruled out. Confirmed empirically that it silently drops <text> elements entirely: a rect+text SVG parsed to a single shape (the rect), with no error or warning. Since almost any useful diagram needs labels, this made it unusable for the actual use case. Also hit a separate GDK pixbuf bug in ConvertToBitmap() in this environment (RuntimeError: Failed to gain raw access to bitmap data) — worked around once, then abandoned anyway once the missing-text issue was found.
  2. cairosvg (pure-Python, pip install --user --break-system-packages cairosvg) — used. Produces a real PNG with correct text rendering, fed into wx.Image the same proven way as the show(fig) matplotlib fix.

New dependency: cairosvg (pulls in cairocffi, cssselect2). No requirements.txt exists in this project — installed ad hoc, same as psycopg2-binary for the location-db tunnel. If setting up a fresh environment: pip3 install --user --break-system-packages cairosvg.

Rendering flow (svg_render.py, shared by both browser variants — they don't share a base renderer class, see the existing sheet-RPC-divergence note in notes-browser/todo): rasterize via cairosvg.svg2png(), load into wx.Image, register in wx.MemoryFSHandler under a filename keyed by a hash of the SVG source (not by note/block position) — so re-viewing the same diagram, including re-navigating to the same note, does not re-register or leak memory; only genuinely new SVG content adds an entry. Entries are never removed, but growth is bounded by distinct diagrams seen in a session, not view count. Emits <img src="memory:svg_<hash>.png">, wired into each renderer's _render_jsonhtl_blocks block-type dispatch — the same one-HTML-string-per-block-run architecture already used for tables/lists/etc, so no ProsePanel restructuring was needed.

Caption rendering uses <p>, not <div> — tried <div> first and the caption rendered beside the image instead of below it (wx's HTML engine isn't a full CSS box model, and <p> is what every other block type in this renderer already relies on for block-level flow).

Desktop app shows a rasterized (fixed-resolution) copy; the web page shows genuine scalable vector. Display-time difference only — stored note content is identical either way.

Tests

Live example

Working example with a real two-box diagram (with arrowhead + text label) at test/svg-block — created for verification, safe to delete or repurpose.

created 2026-07-17