Skip to main content

justpdf_render/
glyph_cache.rs

1use std::collections::HashMap;
2use tiny_skia::Path;
3
4/// Cache key for a glyph path.
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6struct GlyphKey {
7    /// Hash of the font data (to distinguish fonts).
8    font_hash: u64,
9    /// Glyph ID within the font.
10    glyph_id: u16,
11}
12
13/// A cache for pre-built glyph paths to avoid re-parsing font data
14/// and re-building paths for each character occurrence.
15pub struct GlyphCache {
16    paths: HashMap<GlyphKey, Option<Path>>,
17    /// Maps font data pointer (as `usize`) to a fast hash, so we only
18    /// hash each font's bytes once per unique allocation.
19    font_hashes: HashMap<usize, u64>,
20    capacity: usize,
21    hits: u64,
22    misses: u64,
23}
24
25const DEFAULT_CAPACITY: usize = 4096;
26
27impl GlyphCache {
28    pub fn new(capacity: usize) -> Self {
29        Self {
30            paths: HashMap::with_capacity(capacity),
31            font_hashes: HashMap::new(),
32            capacity,
33            hits: 0,
34            misses: 0,
35        }
36    }
37
38    pub fn with_default_capacity() -> Self {
39        Self::new(DEFAULT_CAPACITY)
40    }
41
42    /// Get or insert a glyph path.
43    ///
44    /// `font_data` is the raw font file bytes.
45    /// `glyph_id` is the glyph index.
46    /// `build_fn` is called on cache miss to build the path.
47    pub fn get_or_insert(
48        &mut self,
49        font_data: &[u8],
50        glyph_id: u16,
51        build_fn: impl FnOnce() -> Option<Path>,
52    ) -> Option<&Path> {
53        let font_hash = self.font_hash(font_data);
54        let key = GlyphKey {
55            font_hash,
56            glyph_id,
57        };
58
59        if self.paths.contains_key(&key) {
60            self.hits += 1;
61            return self.paths.get(&key).and_then(|opt| opt.as_ref());
62        }
63
64        self.misses += 1;
65
66        // Evict all entries if we hit capacity (simple strategy).
67        if self.paths.len() >= self.capacity {
68            self.paths.clear();
69            self.font_hashes.clear();
70        }
71
72        let path = build_fn();
73        self.paths.insert(key.clone(), path);
74        self.paths.get(&key).and_then(|opt| opt.as_ref())
75    }
76
77    /// Cache hit rate for debugging/profiling.
78    pub fn hit_rate(&self) -> f64 {
79        let total = self.hits + self.misses;
80        if total == 0 {
81            return 0.0;
82        }
83        self.hits as f64 / total as f64
84    }
85
86    /// Number of cached entries.
87    pub fn len(&self) -> usize {
88        self.paths.len()
89    }
90
91    /// Whether the cache is empty.
92    pub fn is_empty(&self) -> bool {
93        self.paths.is_empty()
94    }
95
96    /// Clear the cache and reset statistics.
97    pub fn clear(&mut self) {
98        self.paths.clear();
99        self.font_hashes.clear();
100        self.hits = 0;
101        self.misses = 0;
102    }
103
104    /// Compute (or retrieve cached) hash for font data.
105    ///
106    /// Uses a fast hash of the first 256 bytes + the total length,
107    /// which is sufficient to distinguish fonts without hashing
108    /// megabytes of data. The hash is cached per font data pointer
109    /// so repeated calls for the same allocation are free.
110    fn font_hash(&mut self, font_data: &[u8]) -> u64 {
111        let ptr = font_data.as_ptr() as usize;
112        if let Some(&h) = self.font_hashes.get(&ptr) {
113            return h;
114        }
115        let h = compute_font_hash(font_data);
116        self.font_hashes.insert(ptr, h);
117        h
118    }
119}
120
121/// Fast, non-cryptographic hash of font data for cache keying.
122/// Hashes the first 256 bytes + the total length.
123fn compute_font_hash(data: &[u8]) -> u64 {
124    // FNV-1a 64-bit
125    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
126    const FNV_PRIME: u64 = 0x00000100000001B3;
127
128    let mut hash = FNV_OFFSET;
129
130    // Mix in the length first
131    let len_bytes = (data.len() as u64).to_le_bytes();
132    for &b in &len_bytes {
133        hash ^= b as u64;
134        hash = hash.wrapping_mul(FNV_PRIME);
135    }
136
137    // Hash up to 256 bytes of content
138    let prefix_len = data.len().min(256);
139    for &b in &data[..prefix_len] {
140        hash ^= b as u64;
141        hash = hash.wrapping_mul(FNV_PRIME);
142    }
143
144    hash
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use tiny_skia::PathBuilder;
151
152    fn make_test_path(x: f32) -> Option<Path> {
153        let mut pb = PathBuilder::new();
154        pb.move_to(x, 0.0);
155        pb.line_to(x + 10.0, 0.0);
156        pb.line_to(x + 10.0, 10.0);
157        pb.close();
158        pb.finish()
159    }
160
161    fn fake_font_data(tag: u8) -> Vec<u8> {
162        // Generate 300 bytes so we exercise the 256-byte prefix window
163        let mut data = vec![tag; 300];
164        // Vary the "font header" area
165        data[0] = tag;
166        data[4] = tag.wrapping_add(1);
167        data
168    }
169
170    #[test]
171    fn cache_hit_on_second_access() {
172        let mut cache = GlyphCache::new(128);
173        let font = fake_font_data(0xAA);
174
175        // First access — miss
176        let p = cache.get_or_insert(&font, 42, || make_test_path(0.0));
177        assert!(p.is_some());
178        assert_eq!(cache.misses, 1);
179        assert_eq!(cache.hits, 0);
180
181        // Second access — hit
182        let p = cache.get_or_insert(&font, 42, || {
183            panic!("build_fn should not be called on cache hit")
184        });
185        assert!(p.is_some());
186        assert_eq!(cache.misses, 1);
187        assert_eq!(cache.hits, 1);
188    }
189
190    #[test]
191    fn different_fonts_produce_different_entries() {
192        let mut cache = GlyphCache::new(128);
193        let font_a = fake_font_data(0xAA);
194        let font_b = fake_font_data(0xBB);
195
196        cache.get_or_insert(&font_a, 1, || make_test_path(0.0));
197        cache.get_or_insert(&font_b, 1, || make_test_path(5.0));
198
199        // Both should be misses (different fonts, same glyph_id)
200        assert_eq!(cache.misses, 2);
201        assert_eq!(cache.hits, 0);
202        assert_eq!(cache.len(), 2);
203    }
204
205    #[test]
206    fn cache_miss_returns_built_path() {
207        let mut cache = GlyphCache::new(128);
208        let font = fake_font_data(0xCC);
209
210        let p = cache.get_or_insert(&font, 10, || make_test_path(3.0));
211        assert!(p.is_some());
212        assert_eq!(cache.len(), 1);
213
214        // None path should also be cached
215        let p = cache.get_or_insert(&font, 99, || None);
216        assert!(p.is_none());
217        assert_eq!(cache.len(), 2);
218
219        // Second access to the None entry should still be a hit
220        let p = cache.get_or_insert(&font, 99, || {
221            panic!("should not rebuild None entry")
222        });
223        assert!(p.is_none());
224        assert_eq!(cache.hits, 1);
225    }
226
227    #[test]
228    fn hit_rate_tracking() {
229        let mut cache = GlyphCache::new(128);
230        let font = fake_font_data(0xDD);
231
232        assert_eq!(cache.hit_rate(), 0.0);
233
234        cache.get_or_insert(&font, 1, || make_test_path(0.0)); // miss
235        cache.get_or_insert(&font, 1, || make_test_path(0.0)); // hit
236        cache.get_or_insert(&font, 1, || make_test_path(0.0)); // hit
237        cache.get_or_insert(&font, 1, || make_test_path(0.0)); // hit
238
239        // 3 hits / 4 total = 0.75
240        assert!((cache.hit_rate() - 0.75).abs() < 1e-9);
241    }
242
243    #[test]
244    fn clear_empties_cache() {
245        let mut cache = GlyphCache::new(128);
246        let font = fake_font_data(0xEE);
247
248        cache.get_or_insert(&font, 1, || make_test_path(0.0));
249        cache.get_or_insert(&font, 2, || make_test_path(0.0));
250        assert_eq!(cache.len(), 2);
251        assert!(!cache.is_empty());
252
253        cache.clear();
254        assert_eq!(cache.len(), 0);
255        assert!(cache.is_empty());
256        assert_eq!(cache.hit_rate(), 0.0);
257    }
258
259    #[test]
260    fn eviction_on_capacity() {
261        let mut cache = GlyphCache::new(2);
262        let font = fake_font_data(0xFF);
263
264        cache.get_or_insert(&font, 1, || make_test_path(0.0));
265        cache.get_or_insert(&font, 2, || make_test_path(0.0));
266        assert_eq!(cache.len(), 2);
267
268        // This should trigger eviction (capacity = 2)
269        cache.get_or_insert(&font, 3, || make_test_path(0.0));
270        // After eviction + insert, we should have 1 entry
271        assert_eq!(cache.len(), 1);
272    }
273}