diff --git a/crates/op-pen-loader/src/adapter.rs b/crates/op-pen-loader/src/adapter.rs index e03aa7528..c4dbfebf2 100644 --- a/crates/op-pen-loader/src/adapter.rs +++ b/crates/op-pen-loader/src/adapter.rs @@ -50,9 +50,15 @@ thread_local! { } /// Real skia paragraph shaper — native + web-skia builds (`skia-measure`, default). +/// Wrapped in a memoizing cache: paragraph shaping is the dominant layout cost, +/// and repeat reconversions (drag / resize / colour edits) re-measure identical +/// text, so the cache turns those into hash lookups. (The estimate backend below +/// is already cheap, so it is left unwrapped.) #[cfg(feature = "skia-measure")] fn make_measure_backend() -> Rc { - Rc::new(jian_skia::SkiaMeasure::new()) + Rc::new(crate::measure_cache::CachingMeasureBackend::new(Rc::new( + jian_skia::SkiaMeasure::new(), + ))) } /// Skia-free estimate backend — the CanvasKit web build links no jian-skia / diff --git a/crates/op-pen-loader/src/lib.rs b/crates/op-pen-loader/src/lib.rs index 03e983919..7e518a588 100644 --- a/crates/op-pen-loader/src/lib.rs +++ b/crates/op-pen-loader/src/lib.rs @@ -24,6 +24,11 @@ mod authored_geometry; mod effects; mod layout_repair; mod layout_scene; +// Only the real-shaper (`skia-measure`) build benefits from caching; the +// estimate backend is already cheap, so the module is gated to avoid dead code +// under the CanvasKit (no-skia-measure) web build. +#[cfg(feature = "skia-measure")] +mod measure_cache; mod path_bounds; mod style_payload; mod text_style; diff --git a/crates/op-pen-loader/src/measure_cache.rs b/crates/op-pen-loader/src/measure_cache.rs new file mode 100644 index 000000000..c3e48e14c --- /dev/null +++ b/crates/op-pen-loader/src/measure_cache.rs @@ -0,0 +1,201 @@ +//! Memoizing wrapper around a [`MeasureBackend`]. +//! +//! `editor_state_to_layout_scene` rebuilds the layout scene whenever the +//! document is dirty, re-running the taffy flex solve — which calls +//! `MeasureBackend::measure` once per text leaf. Under the real skia shaper +//! (`SkiaMeasure`) each call builds and lays out a fresh `Paragraph`, the single +//! most expensive op in the layout pass. Text rarely changes between +//! reconversions (a drag / resize / colour edit leaves every string identical), +//! so memoizing on the full shaping input turns repeat measurements into hash +//! lookups. The backend lives in a thread-local that outlives individual passes, +//! so the cache spans reconversions. +//! +//! Measurement is a pure function of its inputs, so cached entries never go +//! stale; the cache only grows with the set of distinct (text, font, width) +//! tuples in play and is bounded by [`MAX_ENTRIES`]. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use jian_core::layout::measure::{ + FontStyleKind, MeasureBackend, MeasureRequest, MeasureResult, +}; + +/// Cap on distinct cached measurements. A large multi-page design has at most a +/// few thousand distinct text leaves; past this, drop the whole cache rather +/// than track LRU order — a full refill is one layout pass, far cheaper than +/// per-measure bookkeeping. +const MAX_ENTRIES: usize = 8192; + +/// Owned, hashable mirror of a `StyledRun`. `f32` fields are keyed by their bit +/// pattern (`to_bits`) since `f32` is not `Hash`/`Eq`; font sizes / spacings are +/// never `NaN` in practice, so distinct bit patterns map to distinct keys. +#[derive(PartialEq, Eq, Hash)] +struct RunKey { + text: String, + font_family: Option, + font_size_bits: u32, + font_weight: u16, + italic: bool, + letter_spacing_bits: u32, +} + +#[derive(PartialEq, Eq, Hash)] +struct MeasureKey { + runs: Vec, + line_height_bits: u32, + max_width_bits: Option, +} + +impl MeasureKey { + fn from_request(req: &MeasureRequest<'_>) -> Self { + let runs = req + .runs + .iter() + .map(|run| RunKey { + text: run.text.to_string(), + font_family: run.font_family.map(str::to_string), + font_size_bits: run.font_size.to_bits(), + font_weight: run.font_weight, + italic: matches!(run.font_style, FontStyleKind::Italic), + letter_spacing_bits: run.letter_spacing.to_bits(), + }) + .collect(); + MeasureKey { + runs, + line_height_bits: req.line_height.to_bits(), + max_width_bits: req.max_width.map(f32::to_bits), + } + } +} + +/// Wraps a `MeasureBackend`, memoizing results keyed on the full shaping input. +/// `&self` measurement (Taffy hands an immutable context) uses interior +/// mutability via `RefCell`, the single-threaded pattern the trait documents. +pub struct CachingMeasureBackend { + inner: Rc, + cache: RefCell>, +} + +impl CachingMeasureBackend { + pub fn new(inner: Rc) -> Self { + Self { + inner, + cache: RefCell::new(HashMap::new()), + } + } +} + +impl MeasureBackend for CachingMeasureBackend { + fn measure(&self, req: &MeasureRequest<'_>) -> MeasureResult { + let key = MeasureKey::from_request(req); + if let Some(hit) = self.cache.borrow().get(&key) { + return *hit; + } + let result = self.inner.measure(req); + let mut cache = self.cache.borrow_mut(); + // Cheap bound: a runaway distinct-key set just resets (a full refill is + // one layout pass) rather than paying LRU bookkeeping on every measure. + if cache.len() >= MAX_ENTRIES { + cache.clear(); + } + cache.insert(key, result); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + use jian_core::layout::measure::StyledRun; + + /// Records how many times the wrapped backend was actually invoked, and + /// returns an input-derived result so pass-through is verifiable. + struct CountingBackend { + calls: Cell, + } + + impl MeasureBackend for CountingBackend { + fn measure(&self, req: &MeasureRequest<'_>) -> MeasureResult { + self.calls.set(self.calls.get() + 1); + let width: f32 = req.runs.iter().map(|r| r.text.len() as f32).sum(); + MeasureResult { + width, + height: 4.0, + line_count: 1, + baseline: 3.2, + } + } + } + + fn run(text: &str, size: f32, weight: u16) -> StyledRun<'_> { + StyledRun { + text, + font_family: None, + font_size: size, + font_weight: weight, + font_style: FontStyleKind::Normal, + letter_spacing: 0.0, + } + } + + #[test] + fn identical_requests_hit_the_cache() { + let counter = Rc::new(CountingBackend { + calls: Cell::new(0), + }); + let backend = CachingMeasureBackend::new(counter.clone()); + + let runs = [run("hello", 16.0, 400)]; + let req = MeasureRequest { + runs: &runs, + line_height: 0.0, + max_width: None, + }; + + let first = backend.measure(&req); + let second = backend.measure(&req); + + assert_eq!(counter.calls.get(), 1, "second identical measure must hit the cache"); + assert_eq!(first.width, second.width); + assert_eq!(first.width, 5.0, "result must pass through from the inner backend"); + } + + #[test] + fn distinct_inputs_each_measure_once() { + let counter = Rc::new(CountingBackend { + calls: Cell::new(0), + }); + let backend = CachingMeasureBackend::new(counter.clone()); + + // Each axis that feeds shaping must produce a distinct cache key. + let cases: &[(&str, f32, u16, f32, Option)] = &[ + ("hello", 16.0, 400, 0.0, None), + ("world", 16.0, 400, 0.0, None), // text differs + ("hello", 18.0, 400, 0.0, None), // size differs + ("hello", 16.0, 700, 0.0, None), // weight differs + ("hello", 16.0, 400, 0.0, Some(80.0)), // max_width differs + ("hello", 16.0, 400, 2.0, None), // line_height differs + ]; + for (text, size, weight, line_height, max_width) in cases { + let runs = [run(text, *size, *weight)]; + let req = MeasureRequest { + runs: &runs, + line_height: *line_height, + max_width: *max_width, + }; + backend.measure(&req); + // Re-measuring the same case must not add a call. + backend.measure(&req); + } + + assert_eq!( + counter.calls.get(), + cases.len(), + "each distinct shaping input measures exactly once" + ); + } +}