Gunnar

nordisk/facett

.nornir/docview-reasoning.md

# facett-docview — reasoning

> **Class:** `reasoning`. This file holds the *why*, the trade-offs, and **every design we
> rejected**. The descriptive shape we actually run is in
> [`docview-design.md`](docview-design.md), which by rule mentions none of this.

## Why a new component, and why SVG (not PDF)

A help/manual surface is a recurring need across the facett family (nornir's docs book, korp's
operator guide). The obvious approach — "show a PDF in egui" — makes the renderer the hard part:
pdfium is a large C++ native dependency, and pulling it in breaks the pure-Rust,
cross-compile-friendly posture every other facett component holds to.

**Typst** sidesteps the whole problem: `typst compile --format svg` exports one SVG per page
with every glyph **outlined as a vector path**. That single property cascades into three wins
we didn't have to build:

- the exported SVG carries no font references, so it parses with a default-ish `usvg::Options`
  with no work needed to get *Typst* pages right;
- rendering becomes the pure-Rust `resvg`/`usvg`/`tiny_skia` stack — one dependency (`resvg`
  re-exports the other two), no native linking, no cross-compile pain;
- staying vector to the last step means rasterizing **at the current zoom** produces crisp text
  at any scale, never a blurry pre-baked bitmap.

This is the same "source-agnostic, render-only" split the rest of facett follows: the host runs
Typst (or ships pre-exported SVGs) and hands over bytes; `DocView` knows nothing about Typst,
catalogs, or files.

## The fontdb fix — why hand-authored `<text>` was invisible

Typst-exported pages need no font database because every glyph is already a path. But this
crate also has to render **hand-authored** SVGs — the skade-flow diagrams embedded across the
`.nornir/` docs — whose `<text>` elements carry real font references
(`font-family="'DejaVu Sans'"`), not outlines. Under the naive default
(`usvg::Options::default()`, an empty fontdb) usvg can't shape that text against anything, so it
silently **drops** it — the symptom was boxes with no labels, not an error.

We proved the bug before fixing it: `empty_fontdb_default_options_drops_text_proving_the_bug`
(`facett-docview/src/lib.rs:1116`) parses a `<text>`-carrying SVG through the *old*
`usvg::Options::default()` path and asserts the parsed tree has **no** text node and renders
**zero** dark ink. The fix (`shared_fontdb`, `lib.rs:87`) wires a real `fontdb::Database` into
the parse options: `load_system_fonts()` first (so a host's real fonts are preferred when
present), **then** an embedded DejaVu Sans (`include_bytes!`) registered as the default
sans/serif/monospace/cursive/fantasy family. `svg_text_is_retained_and_renders_ink`
(`lib.rs:1091`) is the matching positive proof: the same SVG, parsed through the fixed path,
keeps its text node and paints real ink.

Two choices inside that fix, and why:

- **System fonts first, embedded font as fallback (not the reverse).** A host with a normal
  font stack should see its own fonts; the embedded font exists purely as a floor so nothing
  ever renders as boxes — the "nordisk offline-manual ethos": a barebones/airgapped box with
  zero system fonts still shapes and renders every `<text>` node.
  We rejected "embedded font only, ignore system fonts" because it would make every diagram
  render in DejaVu Sans even on a box with a perfectly good font stack — needlessly homogeneous.
- **Build the database once, share via `Arc`, never rebuild per page.** `load_system_fonts()` is
  the expensive part of this fix; doing it per-page would make a many-page manual pay that cost
  once per page for no benefit, since the database's contents don't change across pages in a
  single process. A `static OnceLock<Arc<fontdb::Database>>` amortizes it to once per process.

## Rejected: rasterize once at a fixed DPI and cache a single bitmap per page

The obvious cache is "one bitmap per page, rendered once." We rejected it because it throws away
the reason we chose SVG in the first place: at a fixed DPI, zooming in blurs (upscaling a
raster) or wastes memory (rendering everything at max DPI up front, whether or not it's ever
zoomed there). The cache key is instead `(page_index, zoom_bucket)` — a page re-rasterizes when
its zoom bucket changes, so text stays crisp at whatever zoom the reader is actually at, and a
manual that's never zoomed past 100% never pays for a 400%-resolution raster.

**Bucketing to ~2% steps**, rather than keying on the raw float zoom, is itself a trade-off: keyed
on the exact float, floating-point jitter (or a fractional `pixels_per_point` from a hi-dpi
display) would thrash the cache every frame, re-rasterizing a page that visually hasn't changed
resolution. Quantizing trades a small amount of visible sharpening granularity for cache
stability — imperceptible in practice, since 2% zoom steps are well under what the eye resolves.

## Rejected: hold the whole manual's textures forever

Given per-page-per-zoom-bucket caching, the next naive move is "never evict — memory is cheap."
We rejected it because a manual's page count is unbounded from the component's point of view
(nothing stops a host from handing it a 2,000-page document), and zoom buckets multiply that
further. `DocView::evict` runs every frame with the current visible band, dropping textures for
pages that scrolled away or whose zoom bucket is stale — memory stays bounded by the viewport,
not by how much of the manual has ever been looked at.

## Rejected: parse every page eagerly, always

`DocView::new`/`from_svgs` (eager) were the original — and still the default — shape: fine for a
small-to-medium manual, since parsing is a one-time, parallelized (rayon) cost at construction.
It does not scale to a very large manual (hundreds of MB of SVG): parsing all of it up front
means paying that cost, and holding every `usvg::Tree` in memory, even for pages the reader
never scrolls to.

We added `PageSource` / `Pages::Lazy` as a second, opt-in shape rather than replacing eager
mode, because eager mode's simplicity (no trait to implement, no per-call fetch cost) is the
right default for the common case, and lazy mode's added complexity (an on-demand fetch seam,
a `size_hint` for pre-fetch layout, per-page eviction) only pays for itself once the page count
gets large enough that eager parsing would be a visible stall. `PageSource` is deliberately the
**only** seam for lazy loading — the viewer is told nothing about where bytes come from (file,
archive, network), which keeps the render engine ignorant of storage the same way it's ignorant
of Typst.

**Velocity-biased prefetch** (leading the fetch/rasterize band in the scroll direction,
`PREFETCH_LEAD`) exists because a naive "only fetch what's on screen right now" band pops in
pages visibly during a fast scroll/fling — the fetch+parse+rasterize latency becomes visible
lag. Leading the band by a multiple of the current scroll velocity (clamped to ~2 screens, so a
sudden huge velocity spike can't try to prefetch the whole document) gets pages ready before
they're needed without unboundedly widening the band at rest.

## Rejected: `update` performs I/O directly

Consistent with the rest of facett's Elm convention (see
[`elm-reasoning.md`](elm-reasoning.md)), `DocView::update` returns `Vec<Effect>` rather than
doing side work itself. In docview's case `Effect` ends up uninhabited (`enum Effect {}`,
`lib.rs:333`) because there genuinely is no host I/O to request — the "side work" (rasterizing,
fetching a lazy page, evicting a texture) is render-engine machinery that lives in `view`'s
interior-mutable cells, not something a host needs to be told to do. Keeping the `Effect`
plumbing anyway (rather than dropping it as dead weight) keeps `DocView` a normal, harness-driven
`Elm` component instead of a special case.

## Rejected: put the render-engine state inside `Model`

The parsed/lazy `Pages`, the texture cache, the smooth-scroll `Cell`s, and the jump-box text
buffer are all, in one sense, "state." We keep them **outside** `DocState` — the same FC-7
discipline as the rest of facett's Elm components — because `DocState` is `PartialEq` and gets
snapshotted/diffed by tests and golden files. A `usvg::Tree` isn't serializable at all, a
`TextureHandle` is a GPU resource, and even if they could be serialized, a scroll-animation
`Cell` changing every frame would make no two snapshots equal. `DocState` holds the *decisions*
(zoom, current page, sidebar shown, TOC, pending jump); the render engine's derived, ephemeral
data is folded back into `state_json()`'s *extra* keys (`page_count`, `cache_size`,
`page_sizes_pt`) for observability without polluting the diffable Model.

## Rejected: index-based visible-page tracking that skips `Msg::SyncViewport`

`view` could update `current_page`/`visible_pages` directly since they're derived purely from
scroll geometry it already computes — no `Msg` seemed necessary. We rejected that shortcut for
the same FC-9 reason the rest of the Elm contract exists: if `view` (a `&self` method) wrote
`DocState` directly, a headless driver replaying a `Msg` script could never reproduce that
derived transition, because it only happens inside a live paint. Routing it through
`Msg::SyncViewport` — computed in `view`, applied in `update` — means a test can feed the exact
same message a real frame would have produced and get the exact same resulting Model.

## The scrollbar clip bug (Rickard 2026-06-22)

`fit_width` used to compute `zoom = panel_width / page_width_pt` against the **full** panel
width. egui's default scrollbar floats **over** the content rather than reserving its own lane,
so a page fitted to the full panel width had its rightmost sliver sit directly under the
floating bar — the symptom read as "the rightmost character is half-width" / "page clipped too
early." The fix has two parts: switch to `egui::style::ScrollStyle::solid()` (`lib.rs:720`), which
gives the scrollbar its own reserved lane instead of floating over content, and compute
`fit_width`'s target width as the panel width **minus** that reserved lane (`lib.rs:721-727`),
replacing an old magic `-16.0` constant with the actual measured bar width. The regression test
(`fit_width_leaves_scrollbar_room_no_right_clip`, `lib.rs:1264`) asserts a fitted page's right
edge stays left of the bar rather than just checking a magic-number width, so it stays correct
if egui's default bar metrics change.

## How we got here

facett-docview was built directly to the Elm shape (no pre-Elm hand-rolled `impl Facet` to
migrate) — see [`elm-reasoning.md`](elm-reasoning.md) for why that shape exists across facett.
The fontdb fix predates the `PageSource`/lazy-loading and smooth-scroll/prefetch work; both
were added once the component was proven on small bundled manuals (facett-demo's
`sample_manual`) and the next question became "does this hold up on a manual too big to parse
eagerly."