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 indocview-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::Optionswith no work needed to get Typst pages right; - rendering becomes the pure-Rust
resvg/usvg/tiny_skiastack — one dependency (resvgre-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. Astatic 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), 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 Cells, 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 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."