Skip to main content

typf_core/
shaping_cache.rs

1//! Backend-agnostic shaping cache
2//!
3//! Shared cache implementation for text shaping results. Used by HarfBuzz-based
4//! shapers to avoid expensive reshaping operations.
5//!
6//! This module was extracted from duplicated code in typf-shape-hb and
7//! typf-shape-icu-hb to provide a single source of truth.
8
9// this_file: crates/typf-core/src/shaping_cache.rs
10
11use std::collections::hash_map::DefaultHasher;
12use std::hash::{Hash, Hasher};
13use std::sync::{Arc, RwLock};
14
15use crate::cache::MultiLevelCache;
16use crate::cache_config;
17use crate::types::ShapingResult;
18
19/// Key for caching shaping results
20///
21/// Uniquely identifies a shaping operation by its inputs:
22/// text content, font identity, size, locale settings, OpenType features,
23/// and variable font axis coordinates.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct ShapingCacheKey {
26    /// Text content
27    pub text: String,
28    /// Name of the shaper/backend
29    pub backend: String,
30    /// Font identifier (hash of font data)
31    pub font_id: u64,
32    /// Font size in points (stored as u32: size * 100 for hash stability)
33    pub size: u32,
34    /// Language code (e.g., "en", "ar", "zh")
35    pub language: Option<String>,
36    /// Script tag (e.g., "latn", "arab", "hans")
37    pub script: Option<String>,
38    /// Enabled OpenType features with their values
39    pub features: Vec<(String, u32)>,
40    /// Variable font axis coordinates (stored as i32: value * 100 for hash stability)
41    pub variations: Vec<(String, i32)>,
42}
43
44impl ShapingCacheKey {
45    /// Create a new cache key from shaping inputs
46    ///
47    /// The font data is hashed to create a stable identifier that doesn't
48    /// require keeping the full font data in memory for cache lookups.
49    ///
50    /// Variable font coordinates are included in the key to ensure different
51    /// axis settings (e.g., wght=400 vs wght=700) produce different cache entries.
52    #[allow(clippy::too_many_arguments)]
53    pub fn new(
54        text: impl Into<String>,
55        backend: impl Into<String>,
56        font_data: &[u8],
57        size: f32,
58        language: Option<String>,
59        script: Option<String>,
60        features: Vec<(String, u32)>,
61        variations: Vec<(String, f32)>,
62    ) -> Self {
63        // Hash the font data for the font_id
64        let mut hasher = DefaultHasher::new();
65        font_data.hash(&mut hasher);
66        let font_id = hasher.finish();
67
68        // Convert variations to integer representation for hash stability
69        let variations_int: Vec<(String, i32)> = variations
70            .into_iter()
71            .map(|(tag, val)| (tag, (val * 100.0) as i32))
72            .collect();
73
74        Self {
75            text: text.into(),
76            backend: backend.into(),
77            font_id,
78            size: (size * 100.0) as u32, // Store as integer for stability
79            language,
80            script,
81            features,
82            variations: variations_int,
83        }
84    }
85}
86
87/// Cache for shaping results
88///
89/// Uses a two-level cache (L1 hot cache + L2 LRU cache) for optimal
90/// performance across different access patterns.
91pub struct ShapingCache {
92    cache: MultiLevelCache<ShapingCacheKey, ShapingResult>,
93}
94
95impl std::fmt::Debug for ShapingCache {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("ShapingCache")
98            .field("cache", &self.cache)
99            .finish()
100    }
101}
102
103impl ShapingCache {
104    /// Create a new shaping cache with default capacities
105    ///
106    /// L1 (hot cache): 100 entries for frequently accessed results
107    /// L2 (LRU cache): 500 entries for less frequent access
108    pub fn new() -> Self {
109        Self {
110            cache: MultiLevelCache::new(100, 500),
111        }
112    }
113
114    /// Create a shaping cache with custom capacities
115    pub fn with_capacity(l1_size: usize, l2_size: usize) -> Self {
116        Self {
117            cache: MultiLevelCache::new(l1_size, l2_size),
118        }
119    }
120
121    /// Get a cached shaping result
122    ///
123    /// Returns `Some(result)` if the key exists in either cache level,
124    /// `None` if not found or if caching is globally disabled.
125    pub fn get(&self, key: &ShapingCacheKey) -> Option<ShapingResult> {
126        if !cache_config::is_caching_enabled() {
127            return None;
128        }
129        self.cache.get(key)
130    }
131
132    /// Insert a shaping result into the cache
133    ///
134    /// The result is stored in both L1 and L2 caches for maximum availability.
135    /// Does nothing if caching is globally disabled.
136    pub fn insert(&self, key: ShapingCacheKey, result: ShapingResult) {
137        if !cache_config::is_caching_enabled() {
138            return;
139        }
140        self.cache.insert(key, result);
141    }
142
143    /// Get the current cache hit rate (0.0 to 1.0)
144    pub fn hit_rate(&self) -> f64 {
145        self.cache.hit_rate()
146    }
147
148    /// Get cache statistics
149    pub fn stats(&self) -> CacheStats {
150        let metrics = self.cache.metrics();
151        let total = metrics.l1_hits + metrics.l2_hits + metrics.misses;
152        CacheStats {
153            hits: (metrics.l1_hits + metrics.l2_hits) as usize,
154            misses: metrics.misses as usize,
155            evictions: 0, // Not tracked in current implementation
156            hit_rate: if total > 0 {
157                (metrics.l1_hits + metrics.l2_hits) as f64 / total as f64
158            } else {
159                0.0
160            },
161        }
162    }
163}
164
165impl Default for ShapingCache {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171/// Cache statistics
172#[derive(Debug, Clone)]
173pub struct CacheStats {
174    pub hits: usize,
175    pub misses: usize,
176    pub evictions: usize,
177    pub hit_rate: f64,
178}
179
180/// Thread-safe shaping cache wrapper
181pub type SharedShapingCache = Arc<RwLock<ShapingCache>>;
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::types::{Direction, PositionedGlyph};
187
188    #[test]
189    fn test_cache_key_creation() {
190        let key = ShapingCacheKey::new(
191            "Hello",
192            "hb",
193            b"font_data",
194            16.0,
195            Some("en".to_string()),
196            Some("latn".to_string()),
197            vec![("liga".to_string(), 1)],
198            vec![("wght".to_string(), 700.0)],
199        );
200
201        assert_eq!(key.text, "Hello");
202        assert_eq!(key.size, 1600); // 16.0 * 100
203        assert_eq!(key.language, Some("en".to_string()));
204        assert_eq!(key.variations, vec![("wght".to_string(), 70000)]); // 700.0 * 100
205    }
206
207    #[test]
208    fn test_cache_insert_and_get() {
209        let _guard = crate::cache_config::scoped_caching_enabled(true);
210
211        let cache = ShapingCache::new();
212
213        let key = ShapingCacheKey::new("Test", "hb", b"font", 12.0, None, None, vec![], vec![]);
214
215        let result = ShapingResult {
216            glyphs: vec![PositionedGlyph {
217                id: 1,
218                x: 0.0,
219                y: 0.0,
220                advance: 10.0,
221                cluster: 0,
222            }],
223            advance_width: 10.0,
224            advance_height: 12.0,
225            direction: Direction::LeftToRight,
226        };
227
228        cache.insert(key.clone(), result.clone());
229        let cached = match cache.get(&key) {
230            Some(cached) => cached,
231            None => unreachable!("cache should return inserted value"),
232        };
233        assert_eq!(cached.glyphs.len(), 1);
234    }
235
236    #[test]
237    fn test_cache_miss() {
238        let cache = ShapingCache::new();
239
240        let key = ShapingCacheKey::new("Missing", "hb", b"font", 16.0, None, None, vec![], vec![]);
241        assert!(cache.get(&key).is_none());
242    }
243
244    #[test]
245    fn test_cache_stats() {
246        let _guard = crate::cache_config::scoped_caching_enabled(true);
247
248        let cache = ShapingCache::new();
249
250        let key = ShapingCacheKey::new("Text", "hb", b"font", 16.0, None, None, vec![], vec![]);
251        let result = ShapingResult {
252            glyphs: vec![],
253            advance_width: 0.0,
254            advance_height: 16.0,
255            direction: Direction::LeftToRight,
256        };
257
258        // Miss
259        cache.get(&key);
260
261        // Insert
262        cache.insert(key.clone(), result);
263
264        // Hit
265        cache.get(&key);
266        cache.get(&key);
267
268        let stats = cache.stats();
269
270        // Stats track hits and misses
271        assert!(stats.hit_rate >= 0.0);
272    }
273
274    #[test]
275    fn test_different_keys() {
276        let key1 = ShapingCacheKey::new("Hello", "hb", b"font1", 16.0, None, None, vec![], vec![]);
277        let key2 = ShapingCacheKey::new("Hello", "hb", b"font2", 16.0, None, None, vec![], vec![]);
278        let key3 = ShapingCacheKey::new("World", "hb", b"font1", 16.0, None, None, vec![], vec![]);
279
280        // Different font data should produce different keys
281        assert_ne!(key1, key2);
282
283        // Different text should produce different keys
284        assert_ne!(key1, key3);
285    }
286
287    #[test]
288    fn test_different_variations_produce_different_keys() {
289        // Same font, same text, but different wght values
290        let key_400 = ShapingCacheKey::new(
291            "Test",
292            "hb",
293            b"font",
294            16.0,
295            None,
296            None,
297            vec![],
298            vec![("wght".to_string(), 400.0)],
299        );
300        let key_700 = ShapingCacheKey::new(
301            "Test",
302            "hb",
303            b"font",
304            16.0,
305            None,
306            None,
307            vec![],
308            vec![("wght".to_string(), 700.0)],
309        );
310        let key_no_var =
311            ShapingCacheKey::new("Test", "hb", b"font", 16.0, None, None, vec![], vec![]);
312
313        // Different variations should produce different keys
314        assert_ne!(
315            key_400, key_700,
316            "wght=400 and wght=700 should have different cache keys"
317        );
318        assert_ne!(
319            key_400, key_no_var,
320            "wght=400 and no variations should have different keys"
321        );
322    }
323}