Skip to main content

typf_shape_ct/
lib.rs

1//! macOS-native text shaping with CoreText precision
2//!
3//! CoreText is Apple's professional text shaping engine, built right into macOS.
4//! It understands every script Apple supports, handles variable fonts flawlessly,
5//! and integrates perfectly with the system font rendering pipeline. This is
6//! the shaper you want on macOS for that native Apple typography feel.
7
8#![cfg(target_os = "macos")]
9
10// this_file: backends/typf-shape-ct/src/lib.rs
11
12use std::{
13    cell::RefCell,
14    ffi::c_void,
15    ptr::{self, NonNull},
16    sync::Arc,
17};
18use typf_core::{
19    error::{Result, ShapingError, TypfError},
20    traits::{FontRef, Shaper},
21    types::{PositionedGlyph, ShapingResult},
22    ShapingParams,
23};
24
25use objc2_core_foundation::{
26    CFDictionary, CFMutableAttributedString, CFNumber, CFRange, CFRetained, CFString, CFType,
27    CGFloat, CGPoint, CGSize,
28};
29use objc2_core_graphics::{CGDataProvider, CGFont};
30use objc2_core_text::{
31    kCTFontAttributeName, kCTFontVariationAttribute, kCTKernAttributeName,
32    kCTLigatureAttributeName, CTFont, CTFontDescriptor, CTLine, CTRun,
33};
34
35use lru::LruCache;
36use parking_lot::RwLock;
37
38// Thread-local font cache to avoid cross-thread CTFont destruction.
39// CoreText fonts have thread affinity - destroying a CTFont on a different
40// thread than it was created causes memory corruption in OTL::Lookup.
41//
42// Note: We use Arc here even though CFRetained<CTFont> is not Send+Sync
43// because the cache is thread-local and Arcs never cross thread boundaries.
44// This allows cloning the Arc within the same thread's cache operations.
45thread_local! {
46    static FONT_CACHE: RefCell<LruCache<FontCacheKey, Arc<CFRetained<CTFont>>>> =
47        RefCell::new(LruCache::new(std::num::NonZeroUsize::new(50).unwrap()));
48}
49
50/// How we identify fonts in our cache
51type FontCacheKey = String;
52
53/// How we identify shaping results in our cache
54type ShapeCacheKey = String;
55
56/// Callback to release font data when CGDataProvider is done with it
57unsafe extern "C-unwind" fn release_data_callback(
58    info: *mut c_void,
59    _data: NonNull<c_void>,
60    _size: usize,
61) {
62    if !info.is_null() {
63        // Reconstruct the Box to drop it properly
64        let _ = unsafe { Box::from_raw(info as *mut Arc<[u8]>) };
65    }
66}
67
68/// Professional text shaping powered by macOS CoreText
69pub struct CoreTextShaper {
70    /// Cache shaping results to avoid redundant work
71    /// Note: font_cache is thread-local (FONT_CACHE) to ensure CTFont objects
72    /// are always destroyed on the same thread they were created, avoiding
73    /// memory corruption in CoreText's OTL lookup tables.
74    shape_cache: Option<RwLock<LruCache<ShapeCacheKey, Arc<ShapingResult>>>>,
75}
76
77impl CoreTextShaper {
78    /// Creates a new shaper ready to work with CoreText
79    pub fn new() -> Self {
80        Self::with_cache(true)
81    }
82
83    /// Creates a new shaper with optional shape caching
84    pub fn with_cache(enabled: bool) -> Self {
85        let cache = if enabled {
86            Some(RwLock::new(LruCache::new(
87                std::num::NonZeroUsize::new(1000).unwrap(),
88            )))
89        } else {
90            None
91        };
92
93        Self { shape_cache: cache }
94    }
95
96    /// Makes a unique key for caching fonts with their settings
97    fn font_cache_key(font: &Arc<dyn FontRef>, params: &ShapingParams) -> String {
98        // Create a robust hash using font length + samples from start, middle, and end.
99        // Just using first 32 bytes was broken: many fonts have identical headers,
100        // causing cache collisions that returned wrong glyph IDs for different fonts.
101        let data = font.data();
102        let len = data.len();
103
104        // Hash: length XOR samples from beginning, middle, and end
105        let mut font_hash = len as u64;
106
107        // Sample first 64 bytes
108        for (i, &b) in data.iter().take(64).enumerate() {
109            font_hash = font_hash
110                .wrapping_mul(31)
111                .wrapping_add(b as u64)
112                .wrapping_add(i as u64);
113        }
114
115        // Sample 64 bytes from middle
116        if len > 128 {
117            let mid = len / 2;
118            for (i, &b) in data[mid..].iter().take(64).enumerate() {
119                font_hash = font_hash
120                    .wrapping_mul(37)
121                    .wrapping_add(b as u64)
122                    .wrapping_add(i as u64);
123            }
124        }
125
126        // Sample last 64 bytes
127        if len > 64 {
128            for (i, &b) in data[len.saturating_sub(64)..].iter().enumerate() {
129                font_hash = font_hash
130                    .wrapping_mul(41)
131                    .wrapping_add(b as u64)
132                    .wrapping_add(i as u64);
133            }
134        }
135
136        // Include variations in cache key - critical for variable fonts!
137        let var_key = if params.variations.is_empty() {
138            String::new()
139        } else {
140            let mut sorted_vars: Vec<_> = params.variations.iter().collect();
141            sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
142            sorted_vars
143                .iter()
144                .map(|(tag, val)| format!("{}={:.1}", tag, val))
145                .collect::<Vec<_>>()
146                .join(",")
147        };
148
149        if var_key.is_empty() {
150            format!("{}:{}", font_hash, params.size as u32)
151        } else {
152            format!("{}:{}:{}", font_hash, params.size as u32, var_key)
153        }
154    }
155
156    /// Makes a unique key for caching shaping results
157    fn shape_cache_key(text: &str, font: &Arc<dyn FontRef>, params: &ShapingParams) -> String {
158        format!("{}::{}", text, Self::font_cache_key(font, params))
159    }
160
161    /// Gets or creates a CoreText font from our font data
162    fn build_ct_font(
163        &self,
164        font: &Arc<dyn FontRef>,
165        params: &ShapingParams,
166    ) -> Result<Arc<CFRetained<CTFont>>> {
167        // Create cache key to see if we already have this font
168        let cache_key = Self::font_cache_key(font, params);
169
170        // Use thread-local cache to ensure CTFont destruction happens on
171        // the same thread as creation (CoreText thread-safety requirement)
172        FONT_CACHE.with(|cache| {
173            let mut cache = cache.borrow_mut();
174
175            // Check cache first
176            if let Some(cached) = cache.get(&cache_key) {
177                log::debug!("CoreTextShaper: Font cache hit (thread-local)");
178                return Ok(Arc::clone(cached));
179            }
180
181            log::debug!("CoreTextShaper: Building new CTFont (thread-local)");
182
183            // Create the font from our data
184            let ct_font = Self::create_ct_font_from_data(font.data(), params)?;
185            // Arc is used even though CTFont isn't Send+Sync because this cache is
186            // thread-local - the Arc never crosses thread boundaries, it just enables
187            // cheap cloning within the same thread's cache operations.
188            #[allow(clippy::arc_with_non_send_sync)]
189            let arc_font = Arc::new(ct_font);
190
191            // Save it for next time (on this thread only)
192            cache.put(cache_key, Arc::clone(&arc_font));
193
194            Ok(arc_font)
195        })
196    }
197
198    /// Convert a 4-character axis tag to its numeric identifier.
199    /// Example: "wght" -> 2003265652 (0x77676874)
200    fn tag_to_axis_id(tag: &str) -> Option<i64> {
201        if tag.len() != 4 {
202            return None;
203        }
204        let bytes = tag.as_bytes();
205        Some(
206            ((bytes[0] as i64) << 24)
207                | ((bytes[1] as i64) << 16)
208                | ((bytes[2] as i64) << 8)
209                | (bytes[3] as i64),
210        )
211    }
212
213    /// Turns raw font bytes into a CoreText CTFont
214    fn create_ct_font_from_data(data: &[u8], params: &ShapingParams) -> Result<CFRetained<CTFont>> {
215        // Create Arc from font data to keep it alive
216        let font_data: Arc<[u8]> = Arc::from(data);
217        let data_ptr = font_data.as_ptr();
218        let data_len = font_data.len();
219
220        // Box the Arc so we can pass ownership to the callback
221        let boxed = Box::new(font_data);
222        let info_ptr = Box::into_raw(boxed) as *mut c_void;
223
224        // Create CGDataProvider using the raw callback API
225        let provider = unsafe {
226            CGDataProvider::with_data(
227                info_ptr,
228                data_ptr as *const c_void,
229                data_len,
230                Some(release_data_callback),
231            )
232        }
233        .ok_or_else(|| {
234            TypfError::ShapingFailed(ShapingError::BackendError(
235                "Failed to create CGDataProvider".to_string(),
236            ))
237        })?;
238
239        // Create CGFont from data provider
240        let cg_font = CGFont::with_data_provider(&provider).ok_or_else(|| {
241            TypfError::ShapingFailed(ShapingError::BackendError(
242                "Failed to create CGFont from data".to_string(),
243            ))
244        })?;
245
246        // Apply variable font coordinates if specified
247        if !params.variations.is_empty() {
248            log::debug!(
249                "CoreTextShaper: Applying {} variation coordinates",
250                params.variations.len()
251            );
252
253            // Convert variations to CFDictionary<CFNumber(axis_id), CFNumber(value)>
254            let var_pairs: Vec<(CFRetained<CFNumber>, CFRetained<CFNumber>)> = params
255                .variations
256                .iter()
257                .filter_map(|(tag, value)| {
258                    Self::tag_to_axis_id(tag).map(|axis_id| {
259                        (CFNumber::new_i64(axis_id), CFNumber::new_f64(*value as f64))
260                    })
261                })
262                .collect();
263
264            if !var_pairs.is_empty() {
265                // Build dictionary with axis ID -> value pairs
266                let keys: Vec<&CFNumber> = var_pairs.iter().map(|(k, _)| k.as_ref()).collect();
267                let values: Vec<&CFNumber> = var_pairs.iter().map(|(_, v)| v.as_ref()).collect();
268                let var_dict = CFDictionary::from_slices(&keys, &values);
269
270                // Create descriptor attributes dictionary with variation attribute
271                let var_key: &CFString = unsafe { kCTFontVariationAttribute };
272
273                // We need to cast the dictionary to CFType for the attributes
274                let var_dict_type = unsafe { CFRetained::cast_unchecked::<CFType>(var_dict) };
275
276                let attr_keys: [&CFString; 1] = [var_key];
277                let attr_values: [&CFType; 1] = [&var_dict_type];
278                let attrs_dict = CFDictionary::from_slices(&attr_keys, &attr_values);
279
280                // Create font descriptor with variation attributes
281                let attrs_untyped =
282                    unsafe { CFRetained::cast_unchecked::<CFDictionary>(attrs_dict) };
283                let desc = unsafe { CTFontDescriptor::with_attributes(&attrs_untyped) };
284
285                // Create CTFont with the descriptor
286                return Ok(unsafe {
287                    CTFont::with_graphics_font(
288                        &cg_font,
289                        params.size as CGFloat,
290                        ptr::null(),
291                        Some(&desc),
292                    )
293                });
294            }
295        }
296
297        // No variations or no valid axis tags - use base CGFont
298        Ok(unsafe {
299            CTFont::with_graphics_font(&cg_font, params.size as CGFloat, ptr::null(), None)
300        })
301    }
302
303    /// Create attributed string with font and features
304    fn create_attributed_string(
305        &self,
306        text: &str,
307        ct_font: &CTFont,
308        params: &ShapingParams,
309    ) -> CFRetained<CFMutableAttributedString> {
310        let cf_string = CFString::from_str(text);
311
312        // Create empty mutable attributed string
313        let attributed_string = CFMutableAttributedString::new(None, 0)
314            .expect("Failed to create CFMutableAttributedString");
315
316        // Replace content (append to empty string)
317        let len = cf_string.length();
318        unsafe {
319            CFMutableAttributedString::replace_string(
320                Some(&attributed_string),
321                CFRange::new(0, 0),
322                Some(&cf_string),
323            );
324        }
325
326        let range = CFRange::new(0, len);
327
328        // Set font attribute
329        let font_key: &CFString = unsafe { kCTFontAttributeName };
330        // CTFont needs to be cast to CFType
331        let ct_font_type: &CFType = unsafe { &*(ct_font as *const CTFont as *const CFType) };
332        unsafe {
333            CFMutableAttributedString::set_attribute(
334                Some(&attributed_string),
335                range,
336                Some(font_key),
337                Some(ct_font_type),
338            );
339        }
340
341        // Apply OpenType features
342        Self::apply_features(&attributed_string, range, params);
343
344        attributed_string
345    }
346
347    /// Apply OpenType features to attributed string
348    fn apply_features(
349        attr_string: &CFMutableAttributedString,
350        range: CFRange,
351        params: &ShapingParams,
352    ) {
353        // Apply ligature setting
354        if let Some((_, value)) = params.features.iter().find(|(tag, _)| tag == "liga") {
355            let ligature_value = CFNumber::new_i32(if *value > 0 { 1 } else { 0 });
356            let lig_key: &CFString = unsafe { kCTLigatureAttributeName };
357            let lig_type: &CFType =
358                unsafe { &*(&*ligature_value as *const CFNumber as *const CFType) };
359            unsafe {
360                CFMutableAttributedString::set_attribute(
361                    Some(attr_string),
362                    range,
363                    Some(lig_key),
364                    Some(lig_type),
365                );
366            }
367        }
368
369        // Apply kerning setting
370        if let Some((_, value)) = params.features.iter().find(|(tag, _)| tag == "kern") {
371            if *value == 0 {
372                let zero = CFNumber::new_f64(0.0);
373                let kern_key: &CFString = unsafe { kCTKernAttributeName };
374                let kern_type: &CFType = unsafe { &*(&*zero as *const CFNumber as *const CFType) };
375                unsafe {
376                    CFMutableAttributedString::set_attribute(
377                        Some(attr_string),
378                        range,
379                        Some(kern_key),
380                        Some(kern_type),
381                    );
382                }
383            }
384        }
385    }
386
387    /// Extract glyphs from CTLine
388    fn extract_glyphs_from_line(
389        &self,
390        line: &CTLine,
391        font: &Arc<dyn FontRef>,
392    ) -> Result<Vec<PositionedGlyph>> {
393        let runs = unsafe { line.glyph_runs() };
394        let mut glyphs = Vec::new();
395
396        // Get the font's glyph count for validation
397        let max_glyph_id = font.glyph_count().unwrap_or(u32::MAX);
398
399        // Iterate over CFArray of CTRun
400        let run_count = runs.len();
401        for i in 0..run_count {
402            // Get run from array - it's a CFType that we cast to CTRun
403            let run_ptr = unsafe { runs.value_at_index(i as isize) };
404            if run_ptr.is_null() {
405                continue;
406            }
407            let run: &CTRun = unsafe { &*(run_ptr as *const CTRun) };
408            Self::collect_run_glyphs(run, &mut glyphs, max_glyph_id);
409        }
410
411        Ok(glyphs)
412    }
413
414    /// Collect glyphs from a single CTRun
415    fn collect_run_glyphs(
416        run: &CTRun,
417        glyphs: &mut Vec<PositionedGlyph>,
418        max_glyph_id: u32,
419    ) -> f32 {
420        let glyph_count = unsafe { run.glyph_count() };
421        if glyph_count <= 0 {
422            return 0.0;
423        }
424        let count = glyph_count as usize;
425
426        // Get direct pointers to run data
427        let glyphs_ptr = unsafe { run.glyphs_ptr() };
428        let positions_ptr = unsafe { run.positions_ptr() };
429        let indices_ptr = unsafe { run.string_indices_ptr() };
430
431        // Get advances - need to allocate buffer and call advances method
432        let mut advances = vec![
433            CGSize {
434                width: 0.0,
435                height: 0.0
436            };
437            count
438        ];
439        if let Some(advances_nonnull) = NonNull::new(advances.as_mut_ptr()) {
440            unsafe {
441                run.advances(CFRange::new(0, 0), advances_nonnull);
442            }
443        }
444
445        let mut advance_sum = 0.0f32;
446
447        for idx in 0..count {
448            // Get glyph ID
449            let raw_glyph_id = if !glyphs_ptr.is_null() {
450                (unsafe { *glyphs_ptr.add(idx) }) as u32
451            } else {
452                0
453            };
454
455            // Get position
456            let position = if !positions_ptr.is_null() {
457                unsafe { *positions_ptr.add(idx) }
458            } else {
459                CGPoint { x: 0.0, y: 0.0 }
460            };
461
462            // Get cluster (string index)
463            let cluster = if !indices_ptr.is_null() {
464                unsafe { *indices_ptr.add(idx) }
465            } else {
466                0
467            };
468
469            // Get advance
470            let advance = advances.get(idx).map(|s| s.width as f32).unwrap_or(0.0);
471
472            // Validate glyph ID and use notdef (0) for invalid glyphs
473            let glyph_id = if raw_glyph_id < max_glyph_id {
474                raw_glyph_id
475            } else {
476                log::debug!(
477                    "CoreTextShaper: Invalid glyph ID {} (max {}), using notdef",
478                    raw_glyph_id,
479                    max_glyph_id
480                );
481                0 // Use notdef glyph for invalid IDs
482            };
483
484            glyphs.push(PositionedGlyph {
485                id: glyph_id,
486                x: position.x as f32,
487                y: position.y as f32,
488                advance,
489                cluster: cluster.max(0) as u32,
490            });
491
492            advance_sum += advance;
493        }
494
495        advance_sum
496    }
497}
498
499impl Default for CoreTextShaper {
500    fn default() -> Self {
501        Self::new()
502    }
503}
504
505impl Shaper for CoreTextShaper {
506    fn name(&self) -> &'static str {
507        "coretext"
508    }
509
510    fn shape(
511        &self,
512        text: &str,
513        font: Arc<dyn FontRef>,
514        params: &ShapingParams,
515    ) -> Result<ShapingResult> {
516        log::debug!("CoreTextShaper: Shaping {} chars", text.chars().count());
517
518        // Create cache key
519        let cache_key = Self::shape_cache_key(text, &font, params);
520
521        // Check shape cache
522        if let Some(cache_lock) = &self.shape_cache {
523            let cache = cache_lock.read();
524            if let Some(cached) = cache.peek(&cache_key) {
525                log::debug!("CoreTextShaper: Shape cache hit");
526                return Ok((**cached).clone());
527            }
528        }
529
530        // Build CTFont
531        let ct_font = self.build_ct_font(&font, params)?;
532
533        // Create attributed string
534        let attr_string = self.create_attributed_string(text, &ct_font, params);
535
536        // Create CTLine - need to cast CFMutableAttributedString to CFAttributedString
537        let attr_str_ref = unsafe {
538            &*(&*attr_string as *const CFMutableAttributedString
539                as *const objc2_core_foundation::CFAttributedString)
540        };
541        let line = unsafe { CTLine::with_attributed_string(attr_str_ref) };
542
543        // Extract glyphs
544        let glyphs = self.extract_glyphs_from_line(&line, &font)?;
545
546        // Calculate metrics
547        let advance_width = if let Some(last) = glyphs.last() {
548            last.x + last.advance
549        } else {
550            0.0
551        };
552
553        let result = ShapingResult {
554            glyphs,
555            advance_width,
556            advance_height: params.size,
557            direction: params.direction,
558        };
559
560        // Cache the result
561        if let Some(cache_lock) = &self.shape_cache {
562            let mut cache = cache_lock.write();
563            cache.put(cache_key, Arc::new(result.clone()));
564        }
565
566        Ok(result)
567    }
568
569    fn supports_script(&self, _script: &str) -> bool {
570        // CoreText supports all scripts
571        true
572    }
573
574    fn clear_cache(&self) {
575        log::debug!("CoreTextShaper: Clearing caches");
576        // Clear thread-local font cache
577        FONT_CACHE.with(|cache| cache.borrow_mut().clear());
578        // Clear shared shape cache
579        if let Some(cache) = &self.shape_cache {
580            cache.write().clear();
581        }
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    // Mock font for testing
590    #[allow(dead_code)]
591    struct MockFont {
592        data: Vec<u8>,
593    }
594
595    impl FontRef for MockFont {
596        fn data(&self) -> &[u8] {
597            &self.data
598        }
599
600        fn units_per_em(&self) -> u16 {
601            1000
602        }
603
604        fn glyph_id(&self, ch: char) -> Option<u32> {
605            if ch.is_ascii() {
606                Some(ch as u32)
607            } else {
608                None
609            }
610        }
611
612        fn advance_width(&self, _glyph_id: u32) -> f32 {
613            500.0
614        }
615    }
616
617    #[test]
618    fn test_shaper_creation() {
619        let shaper = CoreTextShaper::new();
620        assert_eq!(shaper.name(), "coretext");
621    }
622
623    #[test]
624    fn test_supports_all_scripts() {
625        let shaper = CoreTextShaper::new();
626        assert!(shaper.supports_script("Latn"));
627        assert!(shaper.supports_script("Arab"));
628        assert!(shaper.supports_script("Deva"));
629        assert!(shaper.supports_script("Hans"));
630    }
631
632    #[test]
633    fn test_variations_preserve_font_identity() {
634        use std::fs;
635        use std::path::Path;
636
637        // Locate Archivo variable font used across the test suite
638        let font_path = Path::new(env!("CARGO_MANIFEST_DIR"))
639            .join("../../../../runs/assets/candidates/Archivo[wdth,wght].ttf");
640
641        if !font_path.exists() {
642            eprintln!(
643                "skipped: Archivo variable font not found at {:?}",
644                font_path
645            );
646            return;
647        }
648
649        let data = match fs::read(&font_path) {
650            Ok(data) => data,
651            Err(e) => unreachable!("failed to read Archivo variable font: {e}"),
652        };
653
654        let base_params = ShapingParams {
655            size: 32.0,
656            ..ShapingParams::default()
657        };
658
659        // Base font without variations
660        let base = match CoreTextShaper::create_ct_font_from_data(&data, &base_params) {
661            Ok(base) => base,
662            Err(e) => unreachable!("failed to create base CTFont: {e}"),
663        };
664
665        // Apply variations that previously triggered descriptor-based lookup
666        let mut var_params = base_params.clone();
667        var_params.variations = vec![("wght".to_string(), 900.0), ("wdth".to_string(), 100.0)];
668
669        let with_vars = match CoreTextShaper::create_ct_font_from_data(&data, &var_params) {
670            Ok(with_vars) => with_vars,
671            Err(e) => unreachable!("failed to create CTFont with variations: {e}"),
672        };
673
674        // The font identity must stay the same; losing it would swap in a system font
675        let base_name = unsafe { base.post_script_name() };
676        let vars_name = unsafe { with_vars.post_script_name() };
677        assert_eq!(
678            base_name.to_string(),
679            vars_name.to_string(),
680            "Applying variations must not change the underlying font",
681        );
682    }
683
684    #[test]
685    fn test_cache_clearing() {
686        let shaper = CoreTextShaper::new();
687        shaper.clear_cache();
688        // Just verify it doesn't panic
689    }
690}