use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub struct TextMeasurement {
pub width: f64,
pub char_count: usize,
pub avg_char_width: f64,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
struct CacheKey {
font_name: String,
font_size_millis: i64, text: String,
}
#[derive(Debug)]
pub struct FontMetricsCache {
measurements: HashMap<CacheKey, TextMeasurement>,
hits: u64,
misses: u64,
max_entries: usize,
}
impl FontMetricsCache {
pub fn new(max_entries: usize) -> Self {
Self {
measurements: HashMap::with_capacity(max_entries.min(4096)),
hits: 0,
misses: 0,
max_entries,
}
}
pub fn default_capacity() -> Self {
Self::new(10_000)
}
pub fn get(&mut self, font_name: &str, font_size: f64, text: &str) -> Option<&TextMeasurement> {
let key = Self::make_key(font_name, font_size, text);
if self.measurements.contains_key(&key) {
self.hits += 1;
self.measurements.get(&key)
} else {
self.misses += 1;
None
}
}
pub fn put(
&mut self,
font_name: &str,
font_size: f64,
text: &str,
measurement: TextMeasurement,
) {
if self.measurements.len() >= self.max_entries {
self.measurements.clear();
}
let key = Self::make_key(font_name, font_size, text);
self.measurements.insert(key, measurement);
}
pub fn get_or_compute<F>(
&mut self,
font_name: &str,
font_size: f64,
text: &str,
compute: F,
) -> TextMeasurement
where
F: FnOnce() -> TextMeasurement,
{
let key = Self::make_key(font_name, font_size, text);
if let Some(cached) = self.measurements.get(&key) {
self.hits += 1;
return cached.clone();
}
self.misses += 1;
let result = compute();
if self.measurements.len() >= self.max_entries {
self.measurements.clear();
}
self.measurements.insert(key, result.clone());
result
}
pub fn estimate_width(
&mut self,
font_name: &str,
font_size: f64,
text: &str,
char_widths: &HashMap<char, f64>,
default_width: f64,
) -> TextMeasurement {
self.get_or_compute(font_name, font_size, text, || {
let scale = font_size / 1000.0;
let mut total_width = 0.0;
let char_count = text.chars().count();
for ch in text.chars() {
let glyph_width = char_widths.get(&ch).copied().unwrap_or(default_width);
total_width += glyph_width * scale;
}
let avg = if char_count > 0 {
total_width / char_count as f64
} else {
0.0
};
TextMeasurement {
width: total_width,
char_count,
avg_char_width: avg,
}
})
}
pub fn hits(&self) -> u64 {
self.hits
}
pub fn misses(&self) -> u64 {
self.misses
}
pub fn hit_rate(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
self.hits as f64 / total as f64
}
}
pub fn len(&self) -> usize {
self.measurements.len()
}
pub fn is_empty(&self) -> bool {
self.measurements.is_empty()
}
pub fn clear(&mut self) {
self.measurements.clear();
self.hits = 0;
self.misses = 0;
}
fn make_key(font_name: &str, font_size: f64, text: &str) -> CacheKey {
CacheKey {
font_name: font_name.to_string(),
font_size_millis: (font_size * 1000.0) as i64,
text: text.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_hit_and_miss() {
let mut cache = FontMetricsCache::new(100);
assert!(cache.get("Helvetica", 12.0, "hello").is_none());
assert_eq!(cache.misses(), 1);
assert_eq!(cache.hits(), 0);
cache.put(
"Helvetica",
12.0,
"hello",
TextMeasurement {
width: 25.0,
char_count: 5,
avg_char_width: 5.0,
},
);
let m = cache.get("Helvetica", 12.0, "hello").unwrap();
assert_eq!(m.width, 25.0);
assert_eq!(cache.hits(), 1);
}
#[test]
fn test_get_or_compute() {
let mut cache = FontMetricsCache::new(100);
let result = cache.get_or_compute("Arial", 10.0, "test", || TextMeasurement {
width: 20.0,
char_count: 4,
avg_char_width: 5.0,
});
assert_eq!(result.width, 20.0);
assert_eq!(cache.misses(), 1);
let result2 = cache.get_or_compute("Arial", 10.0, "test", || TextMeasurement {
width: 999.0, char_count: 0,
avg_char_width: 0.0,
});
assert_eq!(result2.width, 20.0); assert_eq!(cache.hits(), 1);
}
#[test]
fn test_estimate_width() {
let mut cache = FontMetricsCache::default_capacity();
let mut widths = HashMap::new();
widths.insert('H', 700.0);
widths.insert('i', 300.0);
let m = cache.estimate_width("Helvetica", 12.0, "Hi", &widths, 500.0);
assert_eq!(m.char_count, 2);
assert!((m.width - 12.0).abs() < 0.001);
assert!((m.avg_char_width - 6.0).abs() < 0.001);
}
#[test]
fn test_eviction_on_full() {
let mut cache = FontMetricsCache::new(3);
for i in 0..3 {
cache.put(
"F",
10.0,
&format!("text{}", i),
TextMeasurement {
width: i as f64,
char_count: 1,
avg_char_width: i as f64,
},
);
}
assert_eq!(cache.len(), 3);
cache.put(
"F",
10.0,
"text3",
TextMeasurement {
width: 3.0,
char_count: 1,
avg_char_width: 3.0,
},
);
assert_eq!(cache.len(), 1); }
#[test]
fn test_hit_rate() {
let mut cache = FontMetricsCache::new(100);
cache.put(
"F",
10.0,
"a",
TextMeasurement {
width: 1.0,
char_count: 1,
avg_char_width: 1.0,
},
);
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);
assert_eq!(cache.misses(), 1);
assert!((cache.hit_rate() - 2.0 / 3.0).abs() < 0.001);
}
}