Skip to main content

pdf_render/
glyph_cache.rs

1//! Bounded cache of glyph outlines, shared across one document render.
2//!
3//! Building a glyph outline parses font data and converts its curves, so the
4//! renderer memoizes each distinct outline (the path in glyph space) per
5//! (font identity, glyph id) and reuses it for repeated glyphs by
6//! transforming the cached path instead of rebuilding it. Only the outline is
7//! cached — never rasterized coverage, which would change anti-aliasing at
8//! sub-pixel offsets.
9//!
10//! The cache is an ordinary caller-owned value: no globals, no thread-locals.
11//! Rendering stays pure per page — a hit returns the same path the build
12//! would have produced, so output is byte-identical with the cache on, off,
13//! or freshly cleared. Reusing one cache across the pages of a document (see
14//! [`render_with_glyph_cache`](crate::render_with_glyph_cache)) additionally
15//! shares outlines across pages.
16
17use kurbo::BezPath;
18use std::collections::{HashMap, VecDeque};
19
20/// A cached outline plus its recency stamp.
21///
22/// The stamp matches the entry's latest `(key, stamp)` pair in the recency
23/// queue; older pairs for the key are stale. Stamps restart at 1 when a key
24/// is re-inserted after eviction, which is safe: evicting a key consumes
25/// every pair it ever pushed (the victim's live pair is the newest pair it
26/// pushed, and eviction pops everything up to and including it), so no stale
27/// pair of a re-inserted key can linger.
28struct Entry {
29    path: BezPath,
30    touched: u64,
31}
32
33/// Bounded least-recently-used cache of glyph outlines.
34///
35/// Keys are opaque `u128`s combining font identity and glyph id (the
36/// renderer passes `identifier().cache_key()`); values are the outlines in
37/// glyph space. When the cache is full, inserting a new outline evicts the
38/// least-recently-used one. Eviction is always output-neutral — it only
39/// decides which outlines get rebuilt.
40///
41/// A disabled cache ([`set_enabled`](Self::set_enabled)) never hits and
42/// stores nothing; every lookup counts as a miss. Already-stored entries are
43/// kept while disabled, so re-enabling resumes from them.
44pub struct GlyphOutlineCache {
45    entries: HashMap<u128, Entry>,
46    /// Touch history, oldest-first. Each touch pushes one `(key, stamp)`
47    /// pair; a pair is live only if it matches the entry's current stamp.
48    /// Stale pairs are skipped on eviction and dropped by compaction, which
49    /// keeps touches O(1) amortized instead of the O(capacity) scan an exact
50    /// order list would pay per glyph (cf. the image cache in
51    /// `pdf_interpret::cache`, whose exact order is cheap only because its
52    /// capacity is 32).
53    recency: VecDeque<(u128, u64)>,
54    /// Reusable slot for outlines that are returned but not retained: builds
55    /// made while disabled, or while capacity is zero. Lets the lookup return
56    /// `&BezPath` on every path without cloning on hits.
57    scratch: Option<BezPath>,
58    hits: u64,
59    misses: u64,
60    capacity: usize,
61    enabled: bool,
62}
63
64impl std::fmt::Debug for GlyphOutlineCache {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("GlyphOutlineCache")
67            .field("len", &self.entries.len())
68            .field("hits", &self.hits)
69            .field("misses", &self.misses)
70            .field("capacity", &self.capacity)
71            .field("enabled", &self.enabled)
72            .finish_non_exhaustive()
73    }
74}
75
76impl GlyphOutlineCache {
77    /// Default entry bound.
78    ///
79    /// A typical Latin page uses well under 256 distinct glyphs and a whole
80    /// Latin/Cyrillic/Greek document stays in the hundreds, so 4096 serves
81    /// common documents with zero evictions. Long CJK documents can reach a
82    /// few thousand distinct glyphs overall, but the per-page working set is
83    /// in the hundreds, which stays resident. Memory stays modest: a Latin
84    /// outline is typically hundreds of bytes to ~2KB and a complex CJK
85    /// outline up to ~10KB, so a full cache holds single-digit megabytes —
86    /// small next to the page pixmaps (tens of MB).
87    pub const DEFAULT_CAPACITY: usize = 4096;
88
89    /// An empty cache with [`DEFAULT_CAPACITY`](Self::DEFAULT_CAPACITY) entries.
90    pub fn new() -> Self {
91        Self::with_capacity(Self::DEFAULT_CAPACITY)
92    }
93
94    /// An empty cache holding at most `capacity` entries.
95    ///
96    /// A capacity of zero is allowed: lookups always build and nothing is
97    /// ever stored (every call counts as a miss).
98    pub fn with_capacity(capacity: usize) -> Self {
99        Self {
100            entries: HashMap::new(),
101            recency: VecDeque::new(),
102            scratch: None,
103            hits: 0,
104            misses: 0,
105            capacity,
106            enabled: true,
107        }
108    }
109
110    /// Enable or disable the cache. A disabled cache rebuilds every outline
111    /// (each build counts as a miss) and stores nothing; re-enabling resumes
112    /// from the entries stored before.
113    pub fn set_enabled(&mut self, enabled: bool) {
114        self.enabled = enabled;
115    }
116
117    /// Whether lookups can hit.
118    pub fn is_enabled(&self) -> bool {
119        self.enabled
120    }
121
122    /// Outlines served from the cache.
123    pub fn hits(&self) -> u64 {
124        self.hits
125    }
126
127    /// Outlines built because the cache was disabled, empty of the key, or
128    /// had evicted it. Equal to the number of outline builds through this
129    /// cache.
130    pub fn misses(&self) -> u64 {
131        self.misses
132    }
133
134    /// Entries currently stored. Always within [`capacity`](Self::capacity).
135    pub fn len(&self) -> usize {
136        self.entries.len()
137    }
138
139    /// Whether no outline is stored.
140    pub fn is_empty(&self) -> bool {
141        self.entries.is_empty()
142    }
143
144    /// Maximum entries stored.
145    pub fn capacity(&self) -> usize {
146        self.capacity
147    }
148
149    /// Drop all entries and reset the hit/miss counters.
150    pub fn clear(&mut self) {
151        self.entries.clear();
152        self.recency.clear();
153        self.scratch = None;
154        self.hits = 0;
155        self.misses = 0;
156    }
157
158    /// Return the outline for `key`, building and storing it on a miss.
159    ///
160    /// Hits return a reference to the stored path (no clone); builds made
161    /// while disabled or at zero capacity are returned through a reusable
162    /// slot instead. Callers must not call this again while the returned
163    /// reference is alive — the renderer detaches the cache for the duration
164    /// of each fill for exactly this reason.
165    pub(crate) fn get_or_insert_with(
166        &mut self,
167        key: u128,
168        build: impl FnOnce() -> BezPath,
169    ) -> &BezPath {
170        if !self.enabled {
171            self.misses += 1;
172            self.scratch = Some(build());
173            return self.scratch.as_ref().expect("outline just stored");
174        }
175        if self.entries.contains_key(&key) {
176            self.hits += 1;
177            let touched = {
178                let entry = self.entries.get_mut(&key).expect("key just checked");
179                entry.touched += 1;
180                entry.touched
181            };
182            self.recency.push_back((key, touched));
183            self.compact_recency();
184            return &self.entries.get(&key).expect("key just checked").path;
185        }
186        self.misses += 1;
187        let path = build();
188        if self.capacity == 0 {
189            self.scratch = Some(path);
190            return self.scratch.as_ref().expect("outline just stored");
191        }
192        if self.entries.len() >= self.capacity {
193            self.evict_lru();
194        }
195        self.entries.insert(key, Entry { path, touched: 1 });
196        self.recency.push_back((key, 1));
197        self.compact_recency();
198        &self.entries.get(&key).expect("outline just inserted").path
199    }
200
201    /// Remove the least-recently-used entry. Only called on a full (hence
202    /// non-empty) cache, so a live victim always exists; the fallback keeps
203    /// the bound hard even if that invariant ever broke (any victim is still
204    /// output-neutral — it only affects which outline gets rebuilt).
205    fn evict_lru(&mut self) {
206        while let Some((key, touched)) = self.recency.pop_front() {
207            let live = self
208                .entries
209                .get(&key)
210                .is_some_and(|entry| entry.touched == touched);
211            if live {
212                self.entries.remove(&key);
213                return;
214            }
215        }
216        debug_assert!(false, "LRU queue lost its live pair");
217        if let Some(victim) = self.entries.keys().next().copied() {
218            self.entries.remove(&victim);
219        }
220    }
221
222    /// Bound the recency queue: every touch pushes one pair, so a long render
223    /// of few distinct glyphs would otherwise grow the queue without bound.
224    /// The rebuild keeps each entry's single live pair in touch order, so the
225    /// front stays the least-recently-used key.
226    fn compact_recency(&mut self) {
227        let limit = self.capacity.saturating_mul(4).max(16);
228        if self.recency.len() <= limit {
229            return;
230        }
231        let live: Vec<(u128, u64)> = self
232            .recency
233            .drain(..)
234            .filter(|(key, touched)| {
235                self.entries
236                    .get(key)
237                    .is_some_and(|entry| entry.touched == *touched)
238            })
239            .collect();
240        self.recency.extend(live);
241    }
242}
243
244impl Default for GlyphOutlineCache {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::cell::Cell;
254
255    fn path_with_seed(seed: f64) -> BezPath {
256        let mut path = BezPath::new();
257        path.move_to((seed, seed));
258        path.line_to((seed + 1.0, seed + 1.0));
259        path
260    }
261
262    #[test]
263    fn hit_returns_cached_path_and_counts_stats() {
264        let builds = Cell::new(0u32);
265        let mut cache = GlyphOutlineCache::new();
266        let first = cache
267            .get_or_insert_with(7, || {
268                builds.set(builds.get() + 1);
269                path_with_seed(1.0)
270            })
271            .elements()
272            .to_vec();
273        let second = cache
274            .get_or_insert_with(7, || {
275                builds.set(builds.get() + 1);
276                path_with_seed(2.0)
277            })
278            .elements()
279            .to_vec();
280        assert_eq!(first, second, "hit must return the stored path");
281        assert_eq!(builds.get(), 1, "one build for two lookups");
282        assert_eq!((cache.hits(), cache.misses()), (1, 1));
283        assert_eq!(cache.len(), 1);
284    }
285
286    #[test]
287    fn evicts_least_recently_used() {
288        let builds = Cell::new([0u32; 4]);
289        let build = |i: usize| {
290            let mut counts = builds.get();
291            counts[i] += 1;
292            builds.set(counts);
293            path_with_seed(i as f64)
294        };
295        let mut cache = GlyphOutlineCache::with_capacity(2);
296        cache.get_or_insert_with(1, || build(1));
297        cache.get_or_insert_with(2, || build(2));
298        cache.get_or_insert_with(1, || build(1));
299        cache.get_or_insert_with(3, || build(3));
300
301        // Key 2 was least-recently-used, so it rebuilds -- and re-fetching
302        // three keys through two slots thrashes: each re-fetch evicts the
303        // next one asked for, so all three build twice. (FIFO would keep 2
304        // and LIFO would keep 1 here, so this still pins LRU order.)
305        cache.get_or_insert_with(2, || build(2));
306        cache.get_or_insert_with(1, || build(1));
307        cache.get_or_insert_with(3, || build(3));
308        assert_eq!(builds.get(), [0, 2, 2, 2]);
309        assert_eq!(cache.len(), 2);
310    }
311
312    #[test]
313    fn bound_holds_for_many_distinct_keys() {
314        let mut cache = GlyphOutlineCache::with_capacity(8);
315        for key in 0..200u128 {
316            cache.get_or_insert_with(key, || path_with_seed(key as f64));
317        }
318        assert_eq!(cache.len(), 8);
319    }
320
321    #[test]
322    fn disabled_cache_builds_every_time_and_stores_nothing() {
323        let builds = Cell::new(0u32);
324        let mut cache = GlyphOutlineCache::new();
325        cache.set_enabled(false);
326        assert!(!cache.is_enabled());
327        for _ in 0..3 {
328            cache.get_or_insert_with(1, || {
329                builds.set(builds.get() + 1);
330                path_with_seed(builds.get() as f64)
331            });
332        }
333        assert_eq!(builds.get(), 3);
334        assert_eq!((cache.hits(), cache.misses()), (0, 3));
335        assert_eq!(cache.len(), 0);
336        assert!(cache.is_empty());
337
338        cache.set_enabled(true);
339        cache.get_or_insert_with(1, || {
340            builds.set(builds.get() + 1);
341            path_with_seed(9.0)
342        });
343        cache.get_or_insert_with(1, || {
344            builds.set(builds.get() + 1);
345            path_with_seed(10.0)
346        });
347        assert_eq!(builds.get(), 4, "one build after re-enabling, then a hit");
348        assert_eq!((cache.hits(), cache.misses()), (1, 4));
349    }
350
351    #[test]
352    fn zero_capacity_stores_nothing() {
353        let builds = Cell::new(0u32);
354        let mut cache = GlyphOutlineCache::with_capacity(0);
355        for _ in 0..2 {
356            cache.get_or_insert_with(1, || {
357                builds.set(builds.get() + 1);
358                path_with_seed(1.0)
359            });
360        }
361        assert_eq!(builds.get(), 2);
362        assert_eq!(cache.len(), 0);
363        assert_eq!((cache.hits(), cache.misses()), (0, 2));
364    }
365
366    #[test]
367    fn clear_drops_entries_and_resets_stats() {
368        let mut cache = GlyphOutlineCache::new();
369        cache.get_or_insert_with(1, || path_with_seed(1.0));
370        cache.get_or_insert_with(1, || path_with_seed(1.0));
371        cache.clear();
372        assert!(cache.is_empty());
373        assert_eq!((cache.hits(), cache.misses()), (0, 0));
374    }
375
376    #[test]
377    fn recency_queue_stays_bounded_under_repeated_touches() {
378        let mut cache = GlyphOutlineCache::with_capacity(4);
379        for key in 0..4u128 {
380            cache.get_or_insert_with(key, || path_with_seed(key as f64));
381        }
382        for _ in 0..10_000 {
383            cache.get_or_insert_with(0, || path_with_seed(0.0));
384        }
385        assert_eq!(cache.len(), 4);
386        assert!(
387            cache.recency.len() <= 32,
388            "queue must compact, len = {}",
389            cache.recency.len()
390        );
391    }
392
393    #[test]
394    fn reinserted_key_is_most_recently_used() {
395        let mut cache = GlyphOutlineCache::with_capacity(2);
396        cache.get_or_insert_with(1, || path_with_seed(1.0));
397        cache.get_or_insert_with(2, || path_with_seed(2.0));
398        // Evicts 1; re-inserting 1 then evicts 2 (the LRU), not the fresh 1.
399        cache.get_or_insert_with(3, || path_with_seed(3.0));
400        cache.get_or_insert_with(1, || path_with_seed(1.0));
401        cache.get_or_insert_with(4, || path_with_seed(4.0));
402
403        let builds = Cell::new(0u32);
404        cache.get_or_insert_with(1, || {
405            builds.set(builds.get() + 1);
406            path_with_seed(1.0)
407        });
408        assert_eq!(builds.get(), 0, "re-inserted key 1 must still be stored");
409        assert_eq!(cache.len(), 2);
410    }
411}