justpdf_render/
glyph_cache.rs1use std::collections::HashMap;
2use tiny_skia::Path;
3
4#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6struct GlyphKey {
7 font_hash: u64,
9 glyph_id: u16,
11}
12
13pub struct GlyphCache {
16 paths: HashMap<GlyphKey, Option<Path>>,
17 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 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 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 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 pub fn len(&self) -> usize {
88 self.paths.len()
89 }
90
91 pub fn is_empty(&self) -> bool {
93 self.paths.is_empty()
94 }
95
96 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 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
121fn compute_font_hash(data: &[u8]) -> u64 {
124 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
126 const FNV_PRIME: u64 = 0x00000100000001B3;
127
128 let mut hash = FNV_OFFSET;
129
130 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 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 let mut data = vec![tag; 300];
164 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 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 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 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 let p = cache.get_or_insert(&font, 99, || None);
216 assert!(p.is_none());
217 assert_eq!(cache.len(), 2);
218
219 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)); cache.get_or_insert(&font, 1, || make_test_path(0.0)); cache.get_or_insert(&font, 1, || make_test_path(0.0)); cache.get_or_insert(&font, 1, || make_test_path(0.0)); 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 cache.get_or_insert(&font, 3, || make_test_path(0.0));
270 assert_eq!(cache.len(), 1);
272 }
273}