# facett-docview — design
> **Class:** `design` · **Function axis:** `f0` (the render pipeline) + `f1` page/font parse ·
> `f2` the lazy `PageSource` seam · `f3` texture cache + viewport scroll · `f4` the Elm/Facet contract.
> Strict: this file states *only the shape we run*. Every *why*, every alternative, lives in
> [`docview-reasoning.md`](docview-reasoning.md). Workflow lives in [`docview-guide.md`](docview-guide.md).

## f0 — the render pipeline (the whole)
`DocView` (`facett-docview/src/lib.rs:372`) shows a manual as a stack of vector SVG pages: a
TOC sidebar, continuous vertical scroll, stepped zoom + fit-width, and a page-jump box —
**pure Rust**, no pdfium, no native libraries. The pipeline is one dependency, `resvg`, which
re-exports `usvg` and `tiny_skia` (`lib.rs:51-52`):
1. **Parse once per page** — `DocPage::from_svg_bytes` (`lib.rs:125`) →
`usvg::Tree::from_data(bytes, &opts)`; the intrinsic page size in pt is read from
`tree.size()` and kept on the `DocPage` (`width_pt`/`height_pt`, `lib.rs:110-115`).
2. **Rasterize on demand, at a zoom bucket** — `DocPage::rasterize` (`lib.rs:149`) allocates a
`tiny_skia::Pixmap` sized to `page_pt × scale`, applies a uniform `Transform::from_scale`,
calls `resvg::render`, then demultiplies the premultiplied RGBA into an `egui::ColorImage`
(`lib.rs:162-169`).
3. **Cache by `(page_index, zoom_bucket)`** — `DocView::cache` (`lib.rs:382`), a
`RefCell<HashMap<(usize, u32), egui::TextureHandle>>`; `DocView::zoom_bucket`
(`lib.rs:579`) quantizes `zoom × pixels_per_point` to ~2% steps so a page re-rasterizes
only when its bucket actually changes.
4. **Viewport-bounded** — `DocView::view` (`lib.rs:638`) computes the visible band from
`ScrollArea::both().show_viewport` (`lib.rs:760-855`); only pages within
`VIEWPORT_MARGIN_FACTOR` (`lib.rs:67`) of the band rasterize, and `DocView::evict`
(`lib.rs:593`) drops cache entries and (in lazy mode) parsed pages outside that band.
5. **Clamped** — zoom ∈ `[MIN_ZOOM, MAX_ZOOM]` = `[0.5, 4.0]` (`lib.rs:55-57`), a page's
pixmap never exceeds `MAX_TEX_DIM` = 4096px in either dimension (`lib.rs:62`, enforced in
`DocPage::rasterize` at `lib.rs:154-157`).
**Reuse map (what docview `uses`):** `resvg`/`usvg`/`tiny_skia` (the render stack, re-exported
at `lib.rs:51-52`), `rayon` (parallel page parse + rasterize), and facett-core's `Elm` trait,
`impl_facet_via_elm!` bridge, `harness` module, `scroll_engine::{SmoothScroll,
smooth_scroll_area}`, and `clip::{ClipKind, ClipPayload, CopySource}`. It is used *by*
facett-demo's `docview` tab (`facett-demo/src/lib.rs:633`) and by any host embedding a Typst-
or skade-exported manual.
## f1 — page parse + the shared fontdb
`DocPage::from_svg_bytes` (`lib.rs:125`) parses with a `usvg::Options` whose `fontdb` field is
`shared_fontdb()` (`lib.rs:87`) and whose `font_family` is `EMBEDDED_SANS_FAMILY` (`lib.rs:128`,
`:82`). `shared_fontdb` builds the database **once**, behind a `static OnceLock<Arc<...>>`
(`lib.rs:88`), and every subsequent call clones the `Arc` — the expensive
`db.load_system_fonts()` (`lib.rs:92`) runs exactly once per process. After system fonts, it
loads an embedded font, `EMBEDDED_SANS` — `include_bytes!("../assets/DejaVuSans.ttf")`
(`lib.rs:79`) — and sets it as the sans/serif/monospace/cursive/fantasy default family
(`lib.rs:97-101`), so any `<text>` with no usable `font-family` still shapes.
This lets `DocPage::from_svg_bytes` parse two different kinds of SVG through the same path:
| SVG kind | text representation | fontdb involvement |
|---|---|---|
| Typst-exported manual page | every glyph outlined as a vector path | none — outlines render regardless of `fontdb` |
| hand-authored diagram (e.g. skade) | real `<text>` elements with `font-family` | resolved against `shared_fontdb`, falling back to the embedded DejaVu Sans |
## f2 — the lazy `PageSource` seam
`Pages` (`lib.rs:199`) is the page backing, one of two variants:
- `Pages::Eager(Vec<DocPage>)` — every page parsed up front, built by `DocView::new`
(`lib.rs:403`) / `DocView::from_svgs` (`lib.rs:448`).
- `Pages::Lazy { source, count, cache, size_hint }` — pages fetched + parsed only as they near
the viewport, built by `DocView::from_source` (`lib.rs:413`).
`PageSource` (`lib.rs:182`) is the only seam for lazy loading: `page_count`, `page_svg(i) ->
Option<Vec<u8>>`, and `page_size_hint()` (default `(595.0, 842.0)`, A4 portrait, `lib.rs:191`).
It is `Send + Sync` so `Pages::ensure_parsed` (`lib.rs:247`) fetches + parses the needed indices
in parallel via `rayon`'s `par_iter` (`lib.rs:255-261`), one page per core. A parsed page's real
size refines `size_hint` (`lib.rs:263-266`) so pre-fetch layout converges to the true page box.
`Pages::evict_parsed` (`lib.rs:289`) drops cached `DocPage`s outside the current viewport band;
`Pages::rasterize_many` (`lib.rs:274`) rasterizes a batch of pages in parallel, again via
`rayon`.
## f3 — texture cache + viewport scroll
`DocView::view` (`lib.rs:638`) drives the scroll/cache machinery each frame:
1. **Geometry pass** — for every page, its logical box (`page_logical_size`, `lib.rs:585`,
`= page_pt × zoom`) and stacked top offset are precomputed (`lib.rs:735-743`); the visible
band is `[viewport.min.y − margin, viewport.max.y + margin]` where `margin = viewport.height
× VIEWPORT_MARGIN_FACTOR` (`lib.rs:767`).
2. **Velocity-biased prefetch** — the band leads in the scroll direction by `scroll_delta ×
PREFETCH_LEAD` (`lib.rs:72`, `:771-776`), clamped to two screens, so an accelerating fling has
its upcoming pages parsed + rasterized before they enter view.
3. **Rasterize pass** — pages in the band not already in `DocView::cache` at the current
`zoom_bucket` are parsed (if lazy) then rasterized in parallel (`lib.rs:813-832`), and each
result is uploaded via `ui.ctx().load_texture` with `egui::TextureOptions::LINEAR`
(`lib.rs:825-830`).
4. **Draw pass** — each visible page is painted from the cache with `ui.painter().image`
(`lib.rs:834-848`).
5. **Smooth scroll** — `DocView::page_scroll_x` / `page_scroll_y` (`lib.rs:394-395`, both
`Cell<SmoothScroll>`) drive the page stack through `SmoothScroll::follow`
(`facett-core/src/scroll_engine.rs:46`-area); a pending `Msg::JumpTo` snaps the eased offset
to the target page's top (`lib.rs:754-758`) so a bookmark lands immediately, while ordinary
wheel scrolling is eased. The TOC sidebar scrolls through `smooth_scroll_area`
(`facett-core/src/scroll_engine.rs:163`), called from `draw_sidebar` (`lib.rs:610-625`).
6. **Solid, non-floating scrollbar** — `ui.spacing_mut().scroll = egui::style::ScrollStyle::solid()`
(`lib.rs:720`) reserves the scrollbar its own lane; `fit_width`'s target column width is the
panel width minus that reserved lane (`lib.rs:721-727`), so a fitted page's right edge never
sits under the bar.
7. **Eviction** — `DocView::evict` (`lib.rs:593`) runs every frame with the current visible set
and zoom bucket, dropping stale cache entries and (lazy mode) far-offscreen parsed pages.
## f4 — the Elm / Facet contract
`DocView` implements `facett_core::Elm` (`lib.rs:927`) with `Model = DocState`, `Msg = Msg`,
`Effect = Effect`:
- **`DocState`** (`lib.rs:343`, `Serialize + Deserialize + PartialEq`) — `zoom`, `show_sidebar`,
`current_page`, `visible_pages`, `toc`, `scroll_to`. This is the complete observable Model
(FC-1/FC-3); the parsed/lazy `Pages` engine and the GPU texture cache are **not** in it —
they live in `RefCell`/`Cell` fields on `DocView` (`lib.rs:376-396`).
- **`Msg`** (`lib.rs:307`) — `ToggleSidebar`, `ZoomIn`, `ZoomOut`, `SetZoom(f32)`,
`FitWidth(f32)`, `JumpTo(usize)`, `SyncViewport { current_page, visible, consumed_scroll_to }`.
Every toolbar button, TOC click, jump-box Enter, and the per-frame scroll-derived sync route
through this one enum (FC-2).
- **`Effect`** (`lib.rs:333`) — an uninhabited enum: `DocView::update` (`lib.rs:543`) never
asks the host to do I/O (FC-8).
- **`update`** (`lib.rs:543`) is the sole `&mut self` mutation of `DocState`; **`view`**
(`lib.rs:638`) takes `&self` and returns the frame's `Msg`s (FC-9) — it advances the
render-engine `RefCell`/`Cell` machinery (cache, lazy parse/evict, smooth-scroll) through
interior mutability, but never writes `DocState` directly.
- **The bridge** — `facett_core::impl_facet_via_elm!(DocView, custom_state_json, { … })`
(`lib.rs:956`) writes `impl Facet for DocView`, using form 3 to supply a richer `state_json`
that folds in non-Model engine signals (`page_count`, `cache_size`, `page_sizes_pt`) alongside
the Model fields (`lib.rs:961-972`).
- **Caps** — `caps()` returns `FacetCaps::NONE.scalable().themeable().resizable().copyable()`
(`lib.rs:977`); `scale()`/`set_scale()` (`lib.rs:982-987`) make the deck's uniform zoom
control drive `DocState::zoom` directly, so `fit_width`/`zoom_in` and the deck's Ctrl-+/-/0
operate on the same value.
- **Typed copy** — `DocView` implements `CopySource` (`facett-core/src/clip.rs:131`):
`copy_kinds()` returns `&[ClipKind::Text]` (`lib.rs:908`), and `copy_payload()`
(`lib.rs:911-923`) serializes the TOC as `"title\tpage"` lines (1-based page numbers) — the
page rasters carry no extractable text, but the outline does.
`state_json()` shape (`lib.rs:961-972`):
```json
{
"page_count": 4, "current_page": 0, "zoom": 1.0, "sidebar": true,
"toc": [{"title": "Welcome", "page": 0}, ...],
"visible_pages": [0, 1],
"cache_size": 2,
"page_sizes_pt": [[420.0, 594.0], ...]
}
```