Skip to main content

typf_shape_hr/
lib.rs

1//! Pure Rust text shaping backend using harfrust
2//!
3//! Harfrust is a pure Rust port of HarfBuzz, providing text shaping without
4//! any C dependencies. This makes it ideal for environments where compiling
5//! HarfBuzz is difficult or when a fully auditable Rust dependency tree is
6//! required.
7//!
8//! Performance is within 25% of the C HarfBuzz implementation for most fonts.
9
10use std::str::FromStr;
11use std::sync::Arc;
12
13use harfrust::{
14    Direction as HrDirection, Feature, FontRef as HrFontRef, GlyphBuffer, Language, Script,
15    ShaperData, ShaperInstance, Tag, UnicodeBuffer, Variation,
16};
17
18use typf_core::{
19    error::Result,
20    traits::{FontRef, Shaper, Stage},
21    types::{Direction, PositionedGlyph, ShapingResult},
22    ShapingParams,
23};
24
25// Re-export shared shaping cache from typf-core
26pub use typf_core::shaping_cache::{CacheStats, ShapingCache, ShapingCacheKey, SharedShapingCache};
27
28/// Pure Rust text shaping powered by harfrust
29///
30/// Optionally caches shaping results to avoid expensive re-shaping of identical text.
31pub struct HarfrustShaper {
32    /// Optional shaping cache for performance
33    cache: Option<SharedShapingCache>,
34}
35
36impl HarfrustShaper {
37    /// Creates a new harfrust shaper ready to handle any script
38    pub fn new() -> Self {
39        Self { cache: None }
40    }
41
42    /// Creates a new harfrust shaper with caching enabled
43    ///
44    /// Uses default cache capacities (L1: 100, L2: 500 entries)
45    pub fn with_cache() -> Self {
46        Self {
47            cache: Some(Arc::new(std::sync::RwLock::new(ShapingCache::new()))),
48        }
49    }
50
51    /// Creates a new harfrust shaper with a custom cache
52    ///
53    /// Useful for sharing a cache across multiple shapers
54    pub fn with_shared_cache(cache: SharedShapingCache) -> Self {
55        Self { cache: Some(cache) }
56    }
57
58    /// Returns cache statistics if caching is enabled
59    pub fn cache_stats(&self) -> Option<CacheStats> {
60        self.cache
61            .as_ref()
62            .and_then(|c| c.read().ok())
63            .map(|c| c.stats())
64    }
65
66    /// Returns the cache hit rate (0.0 to 1.0) if caching is enabled
67    pub fn cache_hit_rate(&self) -> Option<f64> {
68        self.cache
69            .as_ref()
70            .and_then(|c| c.read().ok())
71            .map(|c| c.hit_rate())
72    }
73
74    /// Translates our direction enum to harfrust's format
75    fn to_hr_direction(dir: Direction) -> HrDirection {
76        match dir {
77            Direction::LeftToRight => HrDirection::LeftToRight,
78            Direction::RightToLeft => HrDirection::RightToLeft,
79            Direction::TopToBottom => HrDirection::TopToBottom,
80            Direction::BottomToTop => HrDirection::BottomToTop,
81        }
82    }
83
84    /// Parse a 4-character tag string into a harfrust Tag
85    fn parse_tag(tag_str: &str) -> Option<Tag> {
86        if tag_str.len() == 4 {
87            let bytes = tag_str.as_bytes();
88            Some(Tag::new(&[bytes[0], bytes[1], bytes[2], bytes[3]]))
89        } else {
90            None
91        }
92    }
93
94    /// Perform basic fallback shaping when font data is unavailable
95    fn fallback_shape(
96        &self,
97        text: &str,
98        font: Arc<dyn FontRef>,
99        params: &ShapingParams,
100    ) -> ShapingResult {
101        let mut glyphs = Vec::new();
102        let mut x_offset = 0.0;
103
104        for ch in text.chars() {
105            if let Some(glyph_id) = font.glyph_id(ch) {
106                let advance = font.advance_width(glyph_id);
107                glyphs.push(PositionedGlyph {
108                    id: glyph_id,
109                    x: x_offset,
110                    y: 0.0,
111                    advance,
112                    cluster: 0,
113                });
114                x_offset += advance * params.size / font.units_per_em() as f32;
115            }
116        }
117
118        ShapingResult {
119            glyphs,
120            advance_width: x_offset,
121            advance_height: params.size,
122            direction: params.direction,
123        }
124    }
125
126    /// Extract positioned glyphs from harfrust's GlyphBuffer
127    fn extract_glyphs(buffer: &GlyphBuffer, ppem: f32, upem: u16) -> (Vec<PositionedGlyph>, f32) {
128        let mut glyphs = Vec::new();
129        let mut x_offset = 0.0;
130        let scale = ppem / upem as f32;
131
132        let positions = buffer.glyph_positions();
133        let infos = buffer.glyph_infos();
134
135        for (info, pos) in infos.iter().zip(positions.iter()) {
136            glyphs.push(PositionedGlyph {
137                id: info.glyph_id,
138                x: x_offset + (pos.x_offset as f32 * scale),
139                y: pos.y_offset as f32 * scale,
140                advance: pos.x_advance as f32 * scale,
141                cluster: info.cluster,
142            });
143
144            x_offset += pos.x_advance as f32 * scale;
145        }
146
147        (glyphs, x_offset)
148    }
149}
150
151impl Default for HarfrustShaper {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl Stage for HarfrustShaper {
158    fn name(&self) -> &'static str {
159        "Harfrust"
160    }
161
162    fn process(
163        &self,
164        ctx: typf_core::context::PipelineContext,
165    ) -> Result<typf_core::context::PipelineContext> {
166        // Harfrust doesn't process pipeline context directly
167        Ok(ctx)
168    }
169}
170
171impl Shaper for HarfrustShaper {
172    fn name(&self) -> &'static str {
173        "Harfrust"
174    }
175
176    fn shape(
177        &self,
178        text: &str,
179        font: Arc<dyn FontRef>,
180        params: &ShapingParams,
181    ) -> Result<ShapingResult> {
182        if text.is_empty() {
183            return Ok(ShapingResult {
184                glyphs: Vec::new(),
185                advance_width: 0.0,
186                advance_height: params.size,
187                direction: params.direction,
188            });
189        }
190
191        // Try to get the actual font data
192        let font_data = font.data();
193
194        // Check cache if enabled
195        let cache_key = if self.cache.is_some() {
196            let key = ShapingCacheKey::new(
197                text,
198                Shaper::name(self),
199                font_data,
200                params.size,
201                params.language.clone(),
202                params.script.clone(),
203                params.features.clone(),
204                params.variations.clone(),
205            );
206            // Try to get from cache
207            if let Some(ref cache) = self.cache {
208                if let Ok(cache_guard) = cache.read() {
209                    if let Some(result) = cache_guard.get(&key) {
210                        return Ok(result);
211                    }
212                }
213            }
214            Some(key)
215        } else {
216            None
217        };
218
219        if font_data.is_empty() {
220            // No font data? Fall back to basic shaping
221            let result = self.fallback_shape(text, font, params);
222
223            // Store fallback result in cache if enabled
224            if let Some(key) = cache_key {
225                if let Some(ref cache) = self.cache {
226                    if let Ok(cache_guard) = cache.write() {
227                        cache_guard.insert(key, result.clone());
228                    }
229                }
230            }
231
232            return Ok(result);
233        }
234
235        // Create harfrust FontRef from font data
236        let hr_font = match HrFontRef::new(font_data) {
237            Ok(f) => f,
238            Err(_) => {
239                // Font data couldn't be parsed, fall back to basic shaping
240                let result = self.fallback_shape(text, font.clone(), params);
241                if let Some(key) = cache_key {
242                    if let Some(ref cache) = self.cache {
243                        if let Ok(cache_guard) = cache.write() {
244                            cache_guard.insert(key, result.clone());
245                        }
246                    }
247                }
248                return Ok(result);
249            },
250        };
251
252        // Create ShaperData - this caches font tables and is expensive
253        let shaper_data = ShaperData::new(&hr_font);
254
255        // Build variation instance if we have variations
256        let instance = if !params.variations.is_empty() {
257            let variations: Vec<Variation> = params
258                .variations
259                .iter()
260                .filter_map(|(tag_str, value)| {
261                    Self::parse_tag(tag_str).map(|tag| Variation { tag, value: *value })
262                })
263                .collect();
264            Some(ShaperInstance::from_variations(&hr_font, variations))
265        } else {
266            None
267        };
268
269        // Build the shaper
270        let mut builder = shaper_data.shaper(&hr_font);
271        if let Some(ref inst) = instance {
272            builder = builder.instance(Some(inst));
273        }
274        builder = builder.point_size(Some(params.size));
275        let shaper = builder.build();
276
277        // Create the text buffer
278        let mut buffer = UnicodeBuffer::new();
279        buffer.push_str(text);
280        buffer.set_direction(Self::to_hr_direction(params.direction));
281
282        // Set language if specified
283        if let Some(ref lang) = params.language {
284            if let Ok(language) = Language::from_str(lang) {
285                buffer.set_language(language);
286            }
287        }
288
289        // Set script if specified
290        if let Some(ref script_str) = params.script {
291            if let Some(tag) = Self::parse_tag(script_str) {
292                if let Some(script) = Script::from_iso15924_tag(tag) {
293                    buffer.set_script(script);
294                }
295            }
296        }
297
298        // Convert OpenType features (liga, kern, etc.) to harfrust format
299        let features: Vec<Feature> = params
300            .features
301            .iter()
302            .filter_map(|(name, value)| {
303                Self::parse_tag(name).map(|tag| Feature {
304                    tag,
305                    value: *value,
306                    start: 0,
307                    end: u32::MAX,
308                })
309            })
310            .collect();
311
312        // Let harfrust work its magic
313        let output = shaper.shape(buffer, &features);
314
315        // Extract the positioned glyphs
316        let upem = font.units_per_em();
317        let (glyphs, advance_width) = Self::extract_glyphs(&output, params.size, upem);
318
319        let result = ShapingResult {
320            glyphs,
321            advance_width,
322            advance_height: params.size,
323            direction: params.direction,
324        };
325
326        // Store in cache if enabled
327        if let Some(key) = cache_key {
328            if let Some(ref cache) = self.cache {
329                if let Ok(cache_guard) = cache.write() {
330                    cache_guard.insert(key, result.clone());
331                }
332            }
333        }
334
335        Ok(result)
336    }
337
338    fn supports_script(&self, _script: &str) -> bool {
339        // Harfrust knows how to shape every script that HarfBuzz supports
340        true
341    }
342
343    fn clear_cache(&self) {
344        if let Some(ref cache) = self.cache {
345            if let Ok(mut cache_guard) = cache.write() {
346                *cache_guard = ShapingCache::new();
347            }
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    struct TestFont {
357        data: Vec<u8>,
358    }
359
360    impl FontRef for TestFont {
361        fn data(&self) -> &[u8] {
362            &self.data
363        }
364
365        fn units_per_em(&self) -> u16 {
366            1000
367        }
368
369        fn glyph_id(&self, ch: char) -> Option<u32> {
370            Some(ch as u32)
371        }
372
373        fn advance_width(&self, _: u32) -> f32 {
374            500.0
375        }
376    }
377
378    #[test]
379    fn test_empty_text() {
380        let shaper = HarfrustShaper::new();
381        let font = Arc::new(TestFont { data: vec![] });
382        let params = ShapingParams::default();
383
384        let result = shaper.shape("", font, &params).unwrap();
385        assert_eq!(result.glyphs.len(), 0);
386        assert_eq!(result.advance_width, 0.0);
387    }
388
389    #[test]
390    fn test_simple_text_no_font_data() {
391        let shaper = HarfrustShaper::new();
392        let font = Arc::new(TestFont { data: vec![] });
393        let params = ShapingParams::default();
394
395        let result = shaper.shape("Hi", font, &params).unwrap();
396        assert_eq!(result.glyphs.len(), 2);
397        assert!(result.advance_width > 0.0);
398    }
399
400    #[test]
401    #[cfg(target_os = "macos")]
402    fn test_with_system_font() {
403        use std::fs;
404
405        // Try to load Helvetica system font on macOS
406        let font_path = "/System/Library/Fonts/Helvetica.ttc";
407        if let Ok(font_data) = fs::read(font_path) {
408            let font = Arc::new(TestFont { data: font_data });
409            let shaper = HarfrustShaper::new();
410            let params = ShapingParams::default();
411
412            let result = shaper.shape("Hello, World!", font, &params);
413            assert!(result.is_ok());
414
415            let shaped = result.unwrap();
416            // Helvetica should shape "Hello, World!" to multiple glyphs
417            assert!(shaped.glyphs.len() > 10);
418            assert!(shaped.advance_width > 0.0);
419
420            // Check that glyphs have valid IDs
421            for glyph in &shaped.glyphs {
422                assert!(glyph.id > 0);
423                assert!(glyph.advance > 0.0);
424            }
425        }
426    }
427
428    #[test]
429    fn test_complex_text_shaping() {
430        let shaper = HarfrustShaper::new();
431        let font = Arc::new(TestFont { data: vec![] });
432
433        // Test with various text directions
434        let ltr_params = ShapingParams {
435            direction: Direction::LeftToRight,
436            ..Default::default()
437        };
438
439        let rtl_params = ShapingParams {
440            direction: Direction::RightToLeft,
441            ..Default::default()
442        };
443
444        // LTR text
445        let ltr_result = shaper.shape("abc", font.clone(), &ltr_params).unwrap();
446        assert_eq!(ltr_result.direction, Direction::LeftToRight);
447        assert_eq!(ltr_result.glyphs.len(), 3);
448
449        // RTL text (simulated)
450        let rtl_result = shaper.shape("abc", font, &rtl_params).unwrap();
451        assert_eq!(rtl_result.direction, Direction::RightToLeft);
452        assert_eq!(rtl_result.glyphs.len(), 3);
453    }
454
455    #[test]
456    fn test_font_size_variations() {
457        let shaper = HarfrustShaper::new();
458        let font = Arc::new(TestFont { data: vec![] });
459
460        let text = "M"; // Use 'M' for consistent width testing
461
462        // Test different font sizes
463        for size in [12.0, 24.0, 48.0] {
464            let params = ShapingParams {
465                size,
466                ..Default::default()
467            };
468
469            let result = shaper.shape(text, font.clone(), &params).unwrap();
470            assert_eq!(result.glyphs.len(), 1);
471            assert_eq!(result.advance_height, size);
472        }
473    }
474
475    #[test]
476    fn test_opentype_features() {
477        let shaper = HarfrustShaper::new();
478        let font = Arc::new(TestFont { data: vec![] });
479
480        // Test with ligature feature
481        let params_liga = ShapingParams {
482            features: vec![("liga".to_string(), 1)],
483            ..Default::default()
484        };
485
486        let result = shaper.shape("fi", font.clone(), &params_liga).unwrap();
487        assert_eq!(result.glyphs.len(), 2); // Without real font, won't form ligature
488
489        // Test with kerning feature
490        let params_kern = ShapingParams {
491            features: vec![("kern".to_string(), 1)],
492            ..Default::default()
493        };
494
495        let result = shaper.shape("AV", font.clone(), &params_kern).unwrap();
496        assert_eq!(result.glyphs.len(), 2);
497
498        // Test with multiple features
499        let params_multi = ShapingParams {
500            features: vec![
501                ("liga".to_string(), 1),
502                ("kern".to_string(), 1),
503                ("smcp".to_string(), 1), // Small caps
504            ],
505            ..Default::default()
506        };
507
508        let result = shaper.shape("Test", font, &params_multi).unwrap();
509        assert_eq!(result.glyphs.len(), 4);
510    }
511
512    #[test]
513    fn test_language_and_script() {
514        let shaper = HarfrustShaper::new();
515        let font = Arc::new(TestFont { data: vec![] });
516
517        // Test with language set
518        let params_lang = ShapingParams {
519            language: Some("en".to_string()),
520            ..Default::default()
521        };
522
523        let result = shaper.shape("Hello", font.clone(), &params_lang).unwrap();
524        assert_eq!(result.glyphs.len(), 5);
525
526        // Test with script set
527        let params_script = ShapingParams {
528            script: Some("latn".to_string()),
529            ..Default::default()
530        };
531
532        let result = shaper.shape("Test", font.clone(), &params_script).unwrap();
533        assert_eq!(result.glyphs.len(), 4);
534
535        // Test with both language and script
536        let params_both = ShapingParams {
537            language: Some("ar".to_string()),
538            script: Some("arab".to_string()),
539            ..Default::default()
540        };
541
542        let result = shaper.shape("text", font, &params_both).unwrap();
543        assert!(!result.glyphs.is_empty());
544    }
545
546    // ===================== CACHE TESTS =====================
547
548    #[test]
549    fn test_shaper_with_cache() {
550        let _guard = typf_core::cache_config::scoped_caching_enabled(true);
551
552        let shaper = HarfrustShaper::with_cache();
553        let font = Arc::new(TestFont { data: vec![] });
554        let params = ShapingParams::default();
555
556        // First shape - cache miss
557        let result1 = shaper.shape("Hello", font.clone(), &params).unwrap();
558        assert_eq!(result1.glyphs.len(), 5);
559
560        // Second shape - should hit cache
561        let result2 = shaper.shape("Hello", font.clone(), &params).unwrap();
562        assert_eq!(result2.glyphs.len(), 5);
563
564        // Results should be identical
565        assert_eq!(result1.advance_width, result2.advance_width);
566    }
567
568    #[test]
569    fn test_shaper_without_cache() {
570        let shaper = HarfrustShaper::new();
571
572        // Cache stats should be None when caching is disabled
573        assert!(shaper.cache_stats().is_none());
574        assert!(shaper.cache_hit_rate().is_none());
575    }
576
577    #[test]
578    fn test_clear_cache() {
579        let _guard = typf_core::cache_config::scoped_caching_enabled(true);
580
581        let shaper = HarfrustShaper::with_cache();
582        let font = Arc::new(TestFont { data: vec![] });
583        let params = ShapingParams::default();
584
585        // Shape text to populate cache
586        shaper.shape("ClearTest", font.clone(), &params).unwrap();
587        shaper.shape("ClearTest", font.clone(), &params).unwrap(); // Hit
588
589        // Clear the cache
590        shaper.clear_cache();
591
592        // Stats should be reset
593        let stats_after = shaper.cache_stats().unwrap();
594        assert_eq!(stats_after.hits, 0);
595        assert_eq!(stats_after.misses, 0);
596    }
597}