Skip to main content

edgeparse_core/utils/
font_metrics_cache.rs

1//! Font metrics cache — caches computed string widths and font measurements
2//! to avoid redundant calculations during pipeline stages.
3
4use std::collections::HashMap;
5
6/// A computed text measurement result.
7#[derive(Debug, Clone, PartialEq)]
8pub struct TextMeasurement {
9    /// Width of the text in PDF user space units.
10    pub width: f64,
11    /// Number of characters measured.
12    pub char_count: usize,
13    /// Average character width.
14    pub avg_char_width: f64,
15}
16
17/// Cache key combining font identity and text content.
18#[derive(Debug, Clone, Hash, PartialEq, Eq)]
19struct CacheKey {
20    font_name: String,
21    font_size_millis: i64, // font_size * 1000 as integer for hashing
22    text: String,
23}
24
25/// A font metrics cache that stores computed measurements to avoid
26/// redundant width calculations during pipeline processing.
27#[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    /// Create a new cache with a maximum entry limit.
37    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    /// Create a cache with default capacity (10,000 entries).
47    pub fn default_capacity() -> Self {
48        Self::new(10_000)
49    }
50
51    /// Look up a cached measurement. Returns None on cache miss.
52    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    /// Store a measurement in the cache.
64    /// If the cache is full, it will be cleared (simple eviction strategy).
65    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    /// Get or compute a measurement, using the cache if available.
80    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    /// Estimate the width of a text string given per-character widths from a font.
105    /// This is a convenience method using a simple character-width lookup.
106    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    /// Number of cache hits.
136    pub fn hits(&self) -> u64 {
137        self.hits
138    }
139
140    /// Number of cache misses.
141    pub fn misses(&self) -> u64 {
142        self.misses
143    }
144
145    /// Cache hit rate as a fraction (0.0 to 1.0).
146    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    /// Number of entries currently in the cache.
156    pub fn len(&self) -> usize {
157        self.measurements.len()
158    }
159
160    /// Whether the cache is empty.
161    pub fn is_empty(&self) -> bool {
162        self.measurements.is_empty()
163    }
164
165    /// Clear all cached entries and reset counters.
166    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        // Miss first time
189        assert!(cache.get("Helvetica", 12.0, "hello").is_none());
190        assert_eq!(cache.misses(), 1);
191        assert_eq!(cache.hits(), 0);
192
193        // Store and hit
194        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        // Second call should be cached
221        let result2 = cache.get_or_compute("Arial", 10.0, "test", || TextMeasurement {
222            width: 999.0, // should NOT be used
223            char_count: 0,
224            avg_char_width: 0.0,
225        });
226        assert_eq!(result2.width, 20.0); // Still 20, not 999
227        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        // width = (700 * 12/1000) + (300 * 12/1000) = 8.4 + 3.6 = 12.0
240        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        // 4th entry triggers eviction (clear)
262        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); // only the new entry remains
273    }
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"); // hit
289        let _ = cache.get("F", 10.0, "a"); // hit
290        let _ = cache.get("F", 10.0, "b"); // miss
291        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}