Skip to main content

typf_shape_hb/
lib.rs

1//! HarfBuzz shaping backend for Typf.
2//!
3//! Shaping is the step that turns characters into positioned glyphs. That work
4//! is simple for plain Latin text, but it becomes essential for Arabic joins,
5//! Devanagari conjuncts, Thai vowel placement, and any script where one
6//! character does not map cleanly to one painted glyph. This crate delegates
7//! that work to HarfBuzz and translates Typf's neutral API into HarfBuzz calls.
8
9use std::str::FromStr;
10use std::sync::Arc;
11
12use harfbuzz_rs::{Direction as HbDirection, Face, Feature, Font as HbFont, Tag, UnicodeBuffer};
13
14use typf_core::{
15    error::Result,
16    traits::{FontRef, Shaper, Stage},
17    types::{Direction, PositionedGlyph, ShapingResult},
18    ShapingParams,
19};
20
21pub use typf_core::shaping_cache::{CacheStats, ShapingCache, ShapingCacheKey, SharedShapingCache};
22
23/// Text shaper backed by HarfBuzz.
24///
25/// It optionally caches shaping results so repeated requests for the same text,
26/// font, language, and feature set do not pay the shaping cost again.
27pub struct HarfBuzzShaper {
28    cache: Option<SharedShapingCache>,
29}
30
31impl HarfBuzzShaper {
32    /// Create a HarfBuzz shaper without an internal cache.
33    pub fn new() -> Self {
34        Self { cache: None }
35    }
36
37    /// Create a HarfBuzz shaper with its own default cache.
38    pub fn with_cache() -> Self {
39        Self {
40            cache: Some(Arc::new(std::sync::RwLock::new(ShapingCache::new()))),
41        }
42    }
43
44    /// Create a HarfBuzz shaper that reuses an existing shared cache.
45    pub fn with_shared_cache(cache: SharedShapingCache) -> Self {
46        Self { cache: Some(cache) }
47    }
48
49    pub fn cache_stats(&self) -> Option<CacheStats> {
50        self.cache
51            .as_ref()
52            .and_then(|c| c.read().ok())
53            .map(|c| c.stats())
54    }
55
56    pub fn cache_hit_rate(&self) -> Option<f64> {
57        self.cache
58            .as_ref()
59            .and_then(|c| c.read().ok())
60            .map(|c| c.hit_rate())
61    }
62
63    /// Convert Typf's direction enum into HarfBuzz's direction enum.
64    fn to_hb_direction(dir: Direction) -> HbDirection {
65        match dir {
66            Direction::LeftToRight => HbDirection::Ltr,
67            Direction::RightToLeft => HbDirection::Rtl,
68            Direction::TopToBottom => HbDirection::Ttb,
69            Direction::BottomToTop => HbDirection::Btt,
70        }
71    }
72}
73
74impl Default for HarfBuzzShaper {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl Stage for HarfBuzzShaper {
81    fn name(&self) -> &'static str {
82        "HarfBuzz"
83    }
84
85    fn process(
86        &self,
87        ctx: typf_core::context::PipelineContext,
88    ) -> Result<typf_core::context::PipelineContext> {
89        Ok(ctx)
90    }
91}
92
93impl Shaper for HarfBuzzShaper {
94    fn name(&self) -> &'static str {
95        "HarfBuzz"
96    }
97
98    fn shape(
99        &self,
100        text: &str,
101        font: Arc<dyn FontRef>,
102        params: &ShapingParams,
103    ) -> Result<ShapingResult> {
104        if text.is_empty() {
105            return Ok(ShapingResult {
106                glyphs: Vec::new(),
107                advance_width: 0.0,
108                advance_height: params.size,
109                direction: params.direction,
110            });
111        }
112
113        let font_data = font.data();
114
115        let cache_key = if self.cache.is_some() {
116            let key = ShapingCacheKey::new(
117                text,
118                Shaper::name(self),
119                font_data,
120                params.size,
121                params.language.clone(),
122                params.script.clone(),
123                params.features.clone(),
124                params.variations.clone(),
125            );
126            if let Some(ref cache) = self.cache {
127                if let Ok(cache_guard) = cache.read() {
128                    if let Some(result) = cache_guard.get(&key) {
129                        return Ok(result);
130                    }
131                }
132            }
133            Some(key)
134        } else {
135            None
136        };
137        if font_data.is_empty() {
138            let mut glyphs = Vec::new();
139            let mut x_offset = 0.0;
140
141            for ch in text.chars() {
142                if let Some(glyph_id) = font.glyph_id(ch) {
143                    let advance = font.advance_width(glyph_id);
144                    glyphs.push(PositionedGlyph {
145                        id: glyph_id,
146                        x: x_offset,
147                        y: 0.0,
148                        advance,
149                        cluster: 0,
150                    });
151                    x_offset += advance * params.size / font.units_per_em() as f32;
152                }
153            }
154
155            let result = ShapingResult {
156                glyphs,
157                advance_width: x_offset,
158                advance_height: params.size,
159                direction: params.direction,
160            };
161
162            if let Some(key) = cache_key {
163                if let Some(ref cache) = self.cache {
164                    if let Ok(cache_guard) = cache.write() {
165                        cache_guard.insert(key, result.clone());
166                    }
167                }
168            }
169
170            return Ok(result);
171        }
172
173        let face = Face::from_bytes(font_data, 0);
174        let mut hb_font = HbFont::new(face);
175
176        let scale = (params.size * 64.0) as i32;
177        hb_font.set_scale(scale, scale);
178
179        if !params.variations.is_empty() {
180            let variations: Vec<harfbuzz_rs::Variation> = params
181                .variations
182                .iter()
183                .filter_map(|(tag_str, value)| {
184                    if tag_str.len() == 4 {
185                        let bytes = tag_str.as_bytes();
186                        let tag = Tag::new(
187                            bytes[0] as char,
188                            bytes[1] as char,
189                            bytes[2] as char,
190                            bytes[3] as char,
191                        );
192                        Some(harfbuzz_rs::Variation::new(tag, *value))
193                    } else {
194                        None
195                    }
196                })
197                .collect();
198            hb_font.set_variations(&variations);
199        }
200
201        let mut buffer = UnicodeBuffer::new()
202            .add_str(text)
203            .set_direction(Self::to_hb_direction(params.direction));
204
205        if let Some(ref lang) = params.language {
206            if let Ok(language) = harfbuzz_rs::Language::from_str(lang) {
207                buffer = buffer.set_language(language);
208            }
209        }
210
211        if let Some(ref script_str) = params.script {
212            if script_str.len() == 4 {
213                let bytes = script_str.as_bytes();
214                let tag = Tag::new(
215                    bytes[0] as char,
216                    bytes[1] as char,
217                    bytes[2] as char,
218                    bytes[3] as char,
219                );
220                buffer = buffer.set_script(tag);
221            }
222        }
223
224        let hb_features: Vec<Feature> = params
225            .features
226            .iter()
227            .filter_map(|(name, value)| {
228                if name.len() == 4 {
229                    let bytes = name.as_bytes();
230                    let tag = Tag::new(
231                        bytes[0] as char,
232                        bytes[1] as char,
233                        bytes[2] as char,
234                        bytes[3] as char,
235                    );
236                    Some(Feature::new(tag, *value, 0..text.len()))
237                } else {
238                    None
239                }
240            })
241            .collect();
242
243        let output = harfbuzz_rs::shape(&hb_font, buffer, &hb_features);
244
245        let mut glyphs = Vec::new();
246        let mut x_offset = 0.0;
247
248        let positions = output.get_glyph_positions();
249        let infos = output.get_glyph_infos();
250
251        for (info, pos) in infos.iter().zip(positions.iter()) {
252            glyphs.push(PositionedGlyph {
253                id: info.codepoint,
254                x: x_offset + (pos.x_offset as f32 / 64.0),
255                y: pos.y_offset as f32 / 64.0,
256                advance: pos.x_advance as f32 / 64.0,
257                cluster: info.cluster,
258            });
259
260            x_offset += pos.x_advance as f32 / 64.0;
261        }
262
263        let advance_width = x_offset;
264        let advance_height = params.size;
265
266        let result = ShapingResult {
267            glyphs,
268            advance_width,
269            advance_height,
270            direction: params.direction,
271        };
272
273        if let Some(key) = cache_key {
274            if let Some(ref cache) = self.cache {
275                if let Ok(cache_guard) = cache.write() {
276                    cache_guard.insert(key, result.clone());
277                }
278            }
279        }
280
281        Ok(result)
282    }
283
284    fn supports_script(&self, _script: &str) -> bool {
285        true
286    }
287
288    fn clear_cache(&self) {
289        if let Some(ref cache) = self.cache {
290            if let Ok(mut cache_guard) = cache.write() {
291                *cache_guard = ShapingCache::new();
292            }
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    struct TestFont {
302        data: Vec<u8>,
303    }
304
305    impl FontRef for TestFont {
306        fn data(&self) -> &[u8] {
307            &self.data
308        }
309
310        fn units_per_em(&self) -> u16 {
311            1000
312        }
313
314        fn glyph_id(&self, ch: char) -> Option<u32> {
315            Some(ch as u32)
316        }
317
318        fn advance_width(&self, _: u32) -> f32 {
319            500.0
320        }
321    }
322
323    #[test]
324    fn test_empty_text() {
325        let shaper = HarfBuzzShaper::new();
326        let font = Arc::new(TestFont { data: vec![] });
327        let params = ShapingParams::default();
328
329        let result = shaper.shape("", font, &params).unwrap();
330        assert_eq!(result.glyphs.len(), 0);
331        assert_eq!(result.advance_width, 0.0);
332    }
333
334    #[test]
335    fn test_simple_text_no_font_data() {
336        let shaper = HarfBuzzShaper::new();
337        let font = Arc::new(TestFont { data: vec![] });
338        let params = ShapingParams::default();
339
340        let result = shaper.shape("Hi", font, &params).unwrap();
341        assert_eq!(result.glyphs.len(), 2);
342        assert!(result.advance_width > 0.0);
343    }
344
345    #[test]
346    #[cfg(target_os = "macos")]
347    fn test_with_system_font() {
348        use std::fs;
349
350        // Try to load Helvetica system font on macOS
351        let font_path = "/System/Library/Fonts/Helvetica.ttc";
352        if let Ok(font_data) = fs::read(font_path) {
353            let font = Arc::new(TestFont { data: font_data });
354            let shaper = HarfBuzzShaper::new();
355            let params = ShapingParams::default();
356
357            let result = shaper.shape("Hello, World!", font, &params);
358            assert!(result.is_ok());
359
360            let shaped = result.unwrap();
361            // Helvetica should shape "Hello, World!" to multiple glyphs
362            assert!(shaped.glyphs.len() > 10);
363            assert!(shaped.advance_width > 0.0);
364
365            // Check that glyphs have valid IDs
366            for glyph in &shaped.glyphs {
367                assert!(glyph.id > 0);
368                assert!(glyph.advance > 0.0);
369            }
370        }
371    }
372
373    #[test]
374    #[cfg(target_os = "linux")]
375    fn test_with_system_font_linux() {
376        use std::fs;
377
378        // Try common Linux font paths
379        let font_paths = vec![
380            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
381            "/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
382        ];
383
384        for font_path in font_paths {
385            if let Ok(font_data) = fs::read(font_path) {
386                let font = Arc::new(TestFont { data: font_data });
387                let shaper = HarfBuzzShaper::new();
388                let params = ShapingParams::default();
389
390                let result = shaper.shape("Test", font, &params);
391                assert!(result.is_ok());
392
393                let shaped = result.unwrap();
394                assert_eq!(shaped.glyphs.len(), 4); // "Test" = 4 chars
395                assert!(shaped.advance_width > 0.0);
396                return; // Success with first available font
397            }
398        }
399    }
400
401    #[test]
402    fn test_complex_text_shaping() {
403        let shaper = HarfBuzzShaper::new();
404        let font = Arc::new(TestFont { data: vec![] });
405
406        // Test with various text directions
407        let ltr_params = ShapingParams {
408            direction: Direction::LeftToRight,
409            ..Default::default()
410        };
411
412        let rtl_params = ShapingParams {
413            direction: Direction::RightToLeft,
414            ..Default::default()
415        };
416
417        // LTR text
418        let ltr_result = shaper.shape("abc", font.clone(), &ltr_params).unwrap();
419        assert_eq!(ltr_result.direction, Direction::LeftToRight);
420        assert_eq!(ltr_result.glyphs.len(), 3);
421
422        // RTL text (simulated)
423        let rtl_result = shaper.shape("abc", font, &rtl_params).unwrap();
424        assert_eq!(rtl_result.direction, Direction::RightToLeft);
425        assert_eq!(rtl_result.glyphs.len(), 3);
426    }
427
428    #[test]
429    fn test_font_size_variations() {
430        let shaper = HarfBuzzShaper::new();
431        let font = Arc::new(TestFont { data: vec![] });
432
433        let text = "M"; // Use 'M' for consistent width testing
434
435        // Test different font sizes
436        for size in [12.0, 24.0, 48.0] {
437            let params = ShapingParams {
438                size,
439                ..Default::default()
440            };
441
442            let result = shaper.shape(text, font.clone(), &params).unwrap();
443            assert_eq!(result.glyphs.len(), 1);
444            assert_eq!(result.advance_height, size);
445        }
446    }
447
448    #[test]
449    fn test_opentype_features() {
450        let shaper = HarfBuzzShaper::new();
451        let font = Arc::new(TestFont { data: vec![] });
452
453        // Test with ligature feature
454        let params_liga = ShapingParams {
455            features: vec![("liga".to_string(), 1)],
456            ..Default::default()
457        };
458
459        let result = shaper.shape("fi", font.clone(), &params_liga).unwrap();
460        assert_eq!(result.glyphs.len(), 2); // Without real font, won't form ligature
461
462        // Test with kerning feature
463        let params_kern = ShapingParams {
464            features: vec![("kern".to_string(), 1)],
465            ..Default::default()
466        };
467
468        let result = shaper.shape("AV", font.clone(), &params_kern).unwrap();
469        assert_eq!(result.glyphs.len(), 2);
470
471        // Test with multiple features
472        let params_multi = ShapingParams {
473            features: vec![
474                ("liga".to_string(), 1),
475                ("kern".to_string(), 1),
476                ("smcp".to_string(), 1), // Small caps
477            ],
478            ..Default::default()
479        };
480
481        let result = shaper.shape("Test", font, &params_multi).unwrap();
482        assert_eq!(result.glyphs.len(), 4);
483    }
484
485    #[test]
486    fn test_language_and_script() {
487        let shaper = HarfBuzzShaper::new();
488        let font = Arc::new(TestFont { data: vec![] });
489
490        // Test with language set
491        let params_lang = ShapingParams {
492            language: Some("en".to_string()),
493            ..Default::default()
494        };
495
496        let result = shaper.shape("Hello", font.clone(), &params_lang).unwrap();
497        assert_eq!(result.glyphs.len(), 5);
498
499        // Test with script set
500        let params_script = ShapingParams {
501            script: Some("latn".to_string()),
502            ..Default::default()
503        };
504
505        let result = shaper.shape("Test", font.clone(), &params_script).unwrap();
506        assert_eq!(result.glyphs.len(), 4);
507
508        // Test with both language and script
509        let params_both = ShapingParams {
510            language: Some("ar".to_string()),
511            script: Some("arab".to_string()),
512            ..Default::default()
513        };
514
515        let result = shaper.shape("text", font, &params_both).unwrap();
516        assert!(!result.glyphs.is_empty());
517    }
518
519    #[test]
520    #[cfg(target_os = "macos")]
521    fn test_features_with_real_font() {
522        use std::fs;
523
524        let font_path = "/System/Library/Fonts/Helvetica.ttc";
525        if let Ok(font_data) = fs::read(font_path) {
526            let font = Arc::new(TestFont { data: font_data });
527            let shaper = HarfBuzzShaper::new();
528
529            // Test ligature processing with real font
530            let params_no_liga = ShapingParams {
531                features: vec![("liga".to_string(), 0)], // Disable ligatures
532                ..Default::default()
533            };
534
535            let result_no_liga = shaper
536                .shape("fi fl", font.clone(), &params_no_liga)
537                .unwrap();
538
539            let params_liga = ShapingParams {
540                features: vec![("liga".to_string(), 1)], // Enable ligatures
541                ..Default::default()
542            };
543
544            let result_liga = shaper.shape("fi fl", font, &params_liga).unwrap();
545
546            // Both should have glyphs (actual ligature formation depends on font)
547            assert!(!result_no_liga.glyphs.is_empty());
548            assert!(!result_liga.glyphs.is_empty());
549        }
550    }
551
552    #[test]
553    fn test_arabic_shaping() {
554        let shaper = HarfBuzzShaper::new();
555        let font = Arc::new(TestFont { data: vec![] });
556
557        // Test Arabic text with proper script and direction
558        let params = ShapingParams {
559            language: Some("ar".to_string()),
560            script: Some("arab".to_string()),
561            direction: Direction::RightToLeft,
562            ..Default::default()
563        };
564
565        // "Hello" in Arabic (مرحبا)
566        let result = shaper.shape("مرحبا", font, &params).unwrap();
567        assert_eq!(result.direction, Direction::RightToLeft);
568        assert!(!result.glyphs.is_empty());
569        // Arabic has contextual forms, so glyph count may differ from char count
570        assert!(result.advance_width > 0.0);
571    }
572
573    #[test]
574    fn test_devanagari_shaping() {
575        let shaper = HarfBuzzShaper::new();
576        let font = Arc::new(TestFont { data: vec![] });
577
578        // Test Devanagari text with proper script
579        let params = ShapingParams {
580            language: Some("hi".to_string()),
581            script: Some("deva".to_string()),
582            direction: Direction::LeftToRight,
583            ..Default::default()
584        };
585
586        // "Namaste" in Devanagari (नमस्ते)
587        let result = shaper.shape("नमस्ते", font, &params).unwrap();
588        assert_eq!(result.direction, Direction::LeftToRight);
589        assert!(!result.glyphs.is_empty());
590        // Devanagari has complex shaping with conjuncts and vowel marks
591        assert!(result.advance_width > 0.0);
592    }
593
594    #[test]
595    fn test_hebrew_shaping() {
596        let shaper = HarfBuzzShaper::new();
597        let font = Arc::new(TestFont { data: vec![] });
598
599        // Test Hebrew text
600        let params = ShapingParams {
601            language: Some("he".to_string()),
602            script: Some("hebr".to_string()),
603            direction: Direction::RightToLeft,
604            ..Default::default()
605        };
606
607        // "Shalom" in Hebrew (שלום)
608        let result = shaper.shape("שלום", font, &params).unwrap();
609        assert_eq!(result.direction, Direction::RightToLeft);
610        assert_eq!(result.glyphs.len(), 4); // Hebrew doesn't join like Arabic
611        assert!(result.advance_width > 0.0);
612    }
613
614    #[test]
615    fn test_thai_shaping() {
616        let shaper = HarfBuzzShaper::new();
617        let font = Arc::new(TestFont { data: vec![] });
618
619        // Test Thai text
620        let params = ShapingParams {
621            language: Some("th".to_string()),
622            script: Some("thai".to_string()),
623            ..Default::default()
624        };
625
626        // "Hello" in Thai (สวัสดี)
627        let result = shaper.shape("สวัสดี", font, &params).unwrap();
628        assert_eq!(result.direction, Direction::LeftToRight);
629        assert!(!result.glyphs.is_empty());
630        // Thai has complex vowel and tone mark positioning
631        assert!(result.advance_width > 0.0);
632    }
633
634    #[test]
635    fn test_cjk_shaping() {
636        let shaper = HarfBuzzShaper::new();
637        let font = Arc::new(TestFont { data: vec![] });
638
639        // Test Chinese text
640        let params = ShapingParams {
641            language: Some("zh".to_string()),
642            script: Some("hani".to_string()),
643            ..Default::default()
644        };
645
646        // "Hello" in Chinese (你好)
647        let result = shaper.shape("你好", font.clone(), &params).unwrap();
648        assert_eq!(result.direction, Direction::LeftToRight);
649        assert_eq!(result.glyphs.len(), 2); // CJK is mostly 1:1
650        assert!(result.advance_width > 0.0);
651
652        // Test Japanese (same script, different language)
653        let params_ja = ShapingParams {
654            language: Some("ja".to_string()),
655            script: Some("hani".to_string()),
656            ..Default::default()
657        };
658
659        // "Konnichiwa" in hiragana (こんにちは)
660        let result = shaper.shape("こんにちは", font, &params_ja).unwrap();
661        assert_eq!(result.glyphs.len(), 5);
662        assert!(result.advance_width > 0.0);
663    }
664
665    #[test]
666    fn test_mixed_script_text() {
667        let shaper = HarfBuzzShaper::new();
668        let font = Arc::new(TestFont { data: vec![] });
669
670        // Test text with Latin + Arabic
671        let params = ShapingParams {
672            direction: Direction::LeftToRight, // Base direction
673            ..Default::default()
674        };
675
676        let result = shaper.shape("Hello مرحبا World", font, &params).unwrap();
677        assert!(!result.glyphs.is_empty());
678        // HarfBuzz handles bidi internally
679        assert!(result.advance_width > 0.0);
680    }
681
682    // ===================== CACHE TESTS =====================
683
684    #[test]
685    fn test_shaper_with_cache() {
686        let _guard = typf_core::cache_config::scoped_caching_enabled(true);
687
688        let shaper = HarfBuzzShaper::with_cache();
689        let font = Arc::new(TestFont { data: vec![] });
690        let params = ShapingParams::default();
691
692        // First shape - cache miss
693        let result1 = shaper.shape("Hello", font.clone(), &params).unwrap();
694        assert_eq!(result1.glyphs.len(), 5);
695
696        // Second shape - should hit cache
697        let result2 = shaper.shape("Hello", font.clone(), &params).unwrap();
698        assert_eq!(result2.glyphs.len(), 5);
699
700        // Results should be identical
701        assert_eq!(result1.advance_width, result2.advance_width);
702
703        // Check cache hit rate (should be > 0 after second call)
704        let hit_rate = shaper.cache_hit_rate().unwrap();
705        assert!(
706            hit_rate > 0.0,
707            "Cache hit rate should be > 0 after repeat query"
708        );
709    }
710
711    #[test]
712    fn test_shaper_without_cache() {
713        let shaper = HarfBuzzShaper::new();
714
715        // Cache stats should be None when caching is disabled
716        assert!(shaper.cache_stats().is_none());
717        assert!(shaper.cache_hit_rate().is_none());
718    }
719
720    #[test]
721    fn test_cache_stats() {
722        let _guard = typf_core::cache_config::scoped_caching_enabled(true);
723
724        let shaper = HarfBuzzShaper::with_cache();
725        let font = Arc::new(TestFont { data: vec![] });
726        let params = ShapingParams::default();
727
728        // Initial state - no hits or misses
729        let stats = shaper.cache_stats().unwrap();
730        assert_eq!(stats.hits, 0);
731        assert_eq!(stats.misses, 0);
732
733        // First query - miss
734        shaper.shape("Test", font.clone(), &params).unwrap();
735
736        // Second query (same text) - should hit
737        shaper.shape("Test", font.clone(), &params).unwrap();
738
739        let stats = shaper.cache_stats().unwrap();
740        assert!(stats.hits >= 1, "Should have at least one hit");
741    }
742
743    #[test]
744    fn test_shared_cache_across_shapers() {
745        let _guard = typf_core::cache_config::scoped_caching_enabled(true);
746
747        use std::sync::RwLock;
748
749        // Create a shared cache
750        let shared_cache: SharedShapingCache = Arc::new(RwLock::new(ShapingCache::new()));
751
752        // Create two shapers sharing the same cache
753        let shaper1 = HarfBuzzShaper::with_shared_cache(shared_cache.clone());
754        let shaper2 = HarfBuzzShaper::with_shared_cache(shared_cache.clone());
755
756        let font = Arc::new(TestFont { data: vec![] });
757        let params = ShapingParams::default();
758
759        // Shape with shaper1
760        let result1 = shaper1.shape("Shared", font.clone(), &params).unwrap();
761
762        // Shape same text with shaper2 - should hit shared cache
763        let result2 = shaper2.shape("Shared", font.clone(), &params).unwrap();
764
765        // Results should be identical
766        assert_eq!(result1.glyphs.len(), result2.glyphs.len());
767        assert_eq!(result1.advance_width, result2.advance_width);
768
769        // Shared cache should have hits
770        let shared_stats = shared_cache.read().unwrap().stats();
771        assert!(
772            shared_stats.hits >= 1,
773            "Shared cache should have at least one hit"
774        );
775    }
776
777    #[test]
778    fn test_clear_cache() {
779        let _guard = typf_core::cache_config::scoped_caching_enabled(true);
780
781        let shaper = HarfBuzzShaper::with_cache();
782        let font = Arc::new(TestFont { data: vec![] });
783        let params = ShapingParams::default();
784
785        // Shape text to populate cache
786        shaper.shape("ClearTest", font.clone(), &params).unwrap();
787        shaper.shape("ClearTest", font.clone(), &params).unwrap(); // Hit
788
789        // Clear the cache - this always works regardless of caching state
790        shaper.clear_cache();
791
792        // Stats should be reset after clear
793        let stats_after = shaper.cache_stats().unwrap();
794        assert_eq!(stats_after.hits, 0, "Stats should be reset after clear");
795        assert_eq!(stats_after.misses, 0, "Stats should be reset after clear");
796    }
797
798    #[test]
799    fn test_cache_different_params() {
800        let _guard = typf_core::cache_config::scoped_caching_enabled(true);
801
802        let shaper = HarfBuzzShaper::with_cache();
803        let font = Arc::new(TestFont { data: vec![] });
804
805        let params1 = ShapingParams {
806            size: 12.0,
807            ..Default::default()
808        };
809
810        let params2 = ShapingParams {
811            size: 24.0,
812            ..Default::default()
813        };
814
815        // Same text, different sizes should be cached separately
816        let result1 = shaper.shape("Size", font.clone(), &params1).unwrap();
817        let result2 = shaper.shape("Size", font.clone(), &params2).unwrap();
818
819        // With fallback shaping (no font data), advance_height reflects size
820        assert_eq!(result1.advance_height, 12.0);
821        assert_eq!(result2.advance_height, 24.0);
822
823        // Results should differ - this tests that different params produce different results
824        // The cache miss count may vary due to parallel test interference, so we just
825        // verify that the shaping worked correctly (different sizes = different results)
826        assert_ne!(
827            result1.advance_height, result2.advance_height,
828            "Different sizes should produce different results"
829        );
830    }
831}