1use std::collections::HashMap;
5
6#[derive(Debug, Clone, PartialEq)]
8pub struct TextMeasurement {
9 pub width: f64,
11 pub char_count: usize,
13 pub avg_char_width: f64,
15}
16
17#[derive(Debug, Clone, Hash, PartialEq, Eq)]
19struct CacheKey {
20 font_name: String,
21 font_size_millis: i64, text: String,
23}
24
25#[derive(Debug)]
28pub struct FontMetricsCache {
29 measurements: HashMap<CacheKey, TextMeasurement>,
30 hits: u64,
31 misses: u64,
32 max_entries: usize,
33}
34
35impl FontMetricsCache {
36 pub fn new(max_entries: usize) -> Self {
38 Self {
39 measurements: HashMap::with_capacity(max_entries.min(4096)),
40 hits: 0,
41 misses: 0,
42 max_entries,
43 }
44 }
45
46 pub fn default_capacity() -> Self {
48 Self::new(10_000)
49 }
50
51 pub fn get(&mut self, font_name: &str, font_size: f64, text: &str) -> Option<&TextMeasurement> {
53 let key = Self::make_key(font_name, font_size, text);
54 if self.measurements.contains_key(&key) {
55 self.hits += 1;
56 self.measurements.get(&key)
57 } else {
58 self.misses += 1;
59 None
60 }
61 }
62
63 pub fn put(
66 &mut self,
67 font_name: &str,
68 font_size: f64,
69 text: &str,
70 measurement: TextMeasurement,
71 ) {
72 if self.measurements.len() >= self.max_entries {
73 self.measurements.clear();
74 }
75 let key = Self::make_key(font_name, font_size, text);
76 self.measurements.insert(key, measurement);
77 }
78
79 pub fn get_or_compute<F>(
81 &mut self,
82 font_name: &str,
83 font_size: f64,
84 text: &str,
85 compute: F,
86 ) -> TextMeasurement
87 where
88 F: FnOnce() -> TextMeasurement,
89 {
90 let key = Self::make_key(font_name, font_size, text);
91 if let Some(cached) = self.measurements.get(&key) {
92 self.hits += 1;
93 return cached.clone();
94 }
95 self.misses += 1;
96 let result = compute();
97 if self.measurements.len() >= self.max_entries {
98 self.measurements.clear();
99 }
100 self.measurements.insert(key, result.clone());
101 result
102 }
103
104 pub fn estimate_width(
107 &mut self,
108 font_name: &str,
109 font_size: f64,
110 text: &str,
111 char_widths: &HashMap<char, f64>,
112 default_width: f64,
113 ) -> TextMeasurement {
114 self.get_or_compute(font_name, font_size, text, || {
115 let scale = font_size / 1000.0;
116 let mut total_width = 0.0;
117 let char_count = text.chars().count();
118 for ch in text.chars() {
119 let glyph_width = char_widths.get(&ch).copied().unwrap_or(default_width);
120 total_width += glyph_width * scale;
121 }
122 let avg = if char_count > 0 {
123 total_width / char_count as f64
124 } else {
125 0.0
126 };
127 TextMeasurement {
128 width: total_width,
129 char_count,
130 avg_char_width: avg,
131 }
132 })
133 }
134
135 pub fn hits(&self) -> u64 {
137 self.hits
138 }
139
140 pub fn misses(&self) -> u64 {
142 self.misses
143 }
144
145 pub fn hit_rate(&self) -> f64 {
147 let total = self.hits + self.misses;
148 if total == 0 {
149 0.0
150 } else {
151 self.hits as f64 / total as f64
152 }
153 }
154
155 pub fn len(&self) -> usize {
157 self.measurements.len()
158 }
159
160 pub fn is_empty(&self) -> bool {
162 self.measurements.is_empty()
163 }
164
165 pub fn clear(&mut self) {
167 self.measurements.clear();
168 self.hits = 0;
169 self.misses = 0;
170 }
171
172 fn make_key(font_name: &str, font_size: f64, text: &str) -> CacheKey {
173 CacheKey {
174 font_name: font_name.to_string(),
175 font_size_millis: (font_size * 1000.0) as i64,
176 text: text.to_string(),
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn test_cache_hit_and_miss() {
187 let mut cache = FontMetricsCache::new(100);
188 assert!(cache.get("Helvetica", 12.0, "hello").is_none());
190 assert_eq!(cache.misses(), 1);
191 assert_eq!(cache.hits(), 0);
192
193 cache.put(
195 "Helvetica",
196 12.0,
197 "hello",
198 TextMeasurement {
199 width: 25.0,
200 char_count: 5,
201 avg_char_width: 5.0,
202 },
203 );
204 let m = cache.get("Helvetica", 12.0, "hello").unwrap();
205 assert_eq!(m.width, 25.0);
206 assert_eq!(cache.hits(), 1);
207 }
208
209 #[test]
210 fn test_get_or_compute() {
211 let mut cache = FontMetricsCache::new(100);
212 let result = cache.get_or_compute("Arial", 10.0, "test", || TextMeasurement {
213 width: 20.0,
214 char_count: 4,
215 avg_char_width: 5.0,
216 });
217 assert_eq!(result.width, 20.0);
218 assert_eq!(cache.misses(), 1);
219
220 let result2 = cache.get_or_compute("Arial", 10.0, "test", || TextMeasurement {
222 width: 999.0, char_count: 0,
224 avg_char_width: 0.0,
225 });
226 assert_eq!(result2.width, 20.0); assert_eq!(cache.hits(), 1);
228 }
229
230 #[test]
231 fn test_estimate_width() {
232 let mut cache = FontMetricsCache::default_capacity();
233 let mut widths = HashMap::new();
234 widths.insert('H', 700.0);
235 widths.insert('i', 300.0);
236
237 let m = cache.estimate_width("Helvetica", 12.0, "Hi", &widths, 500.0);
238 assert_eq!(m.char_count, 2);
239 assert!((m.width - 12.0).abs() < 0.001);
241 assert!((m.avg_char_width - 6.0).abs() < 0.001);
242 }
243
244 #[test]
245 fn test_eviction_on_full() {
246 let mut cache = FontMetricsCache::new(3);
247 for i in 0..3 {
248 cache.put(
249 "F",
250 10.0,
251 &format!("text{}", i),
252 TextMeasurement {
253 width: i as f64,
254 char_count: 1,
255 avg_char_width: i as f64,
256 },
257 );
258 }
259 assert_eq!(cache.len(), 3);
260
261 cache.put(
263 "F",
264 10.0,
265 "text3",
266 TextMeasurement {
267 width: 3.0,
268 char_count: 1,
269 avg_char_width: 3.0,
270 },
271 );
272 assert_eq!(cache.len(), 1); }
274
275 #[test]
276 fn test_hit_rate() {
277 let mut cache = FontMetricsCache::new(100);
278 cache.put(
279 "F",
280 10.0,
281 "a",
282 TextMeasurement {
283 width: 1.0,
284 char_count: 1,
285 avg_char_width: 1.0,
286 },
287 );
288 let _ = cache.get("F", 10.0, "a"); let _ = cache.get("F", 10.0, "a"); let _ = cache.get("F", 10.0, "b"); assert_eq!(cache.hits(), 2);
292 assert_eq!(cache.misses(), 1);
293 assert!((cache.hit_rate() - 2.0 / 3.0).abs() < 0.001);
294 }
295}