1#![cfg(target_os = "macos")]
9
10use 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
38thread_local! {
46 static FONT_CACHE: RefCell<LruCache<FontCacheKey, Arc<CFRetained<CTFont>>>> =
47 RefCell::new(LruCache::new(std::num::NonZeroUsize::new(50).unwrap()));
48}
49
50type FontCacheKey = String;
52
53type ShapeCacheKey = String;
55
56unsafe 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 let _ = unsafe { Box::from_raw(info as *mut Arc<[u8]>) };
65 }
66}
67
68pub struct CoreTextShaper {
70 shape_cache: Option<RwLock<LruCache<ShapeCacheKey, Arc<ShapingResult>>>>,
75}
76
77impl CoreTextShaper {
78 pub fn new() -> Self {
80 Self::with_cache(true)
81 }
82
83 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 fn font_cache_key(font: &Arc<dyn FontRef>, params: &ShapingParams) -> String {
98 let data = font.data();
102 let len = data.len();
103
104 let mut font_hash = len as u64;
106
107 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 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 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 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 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 fn build_ct_font(
163 &self,
164 font: &Arc<dyn FontRef>,
165 params: &ShapingParams,
166 ) -> Result<Arc<CFRetained<CTFont>>> {
167 let cache_key = Self::font_cache_key(font, params);
169
170 FONT_CACHE.with(|cache| {
173 let mut cache = cache.borrow_mut();
174
175 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 let ct_font = Self::create_ct_font_from_data(font.data(), params)?;
185 #[allow(clippy::arc_with_non_send_sync)]
189 let arc_font = Arc::new(ct_font);
190
191 cache.put(cache_key, Arc::clone(&arc_font));
193
194 Ok(arc_font)
195 })
196 }
197
198 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 fn create_ct_font_from_data(data: &[u8], params: &ShapingParams) -> Result<CFRetained<CTFont>> {
215 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 let boxed = Box::new(font_data);
222 let info_ptr = Box::into_raw(boxed) as *mut c_void;
223
224 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 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 if !params.variations.is_empty() {
248 log::debug!(
249 "CoreTextShaper: Applying {} variation coordinates",
250 params.variations.len()
251 );
252
253 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 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 let var_key: &CFString = unsafe { kCTFontVariationAttribute };
272
273 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 let attrs_untyped =
282 unsafe { CFRetained::cast_unchecked::<CFDictionary>(attrs_dict) };
283 let desc = unsafe { CTFontDescriptor::with_attributes(&attrs_untyped) };
284
285 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 Ok(unsafe {
299 CTFont::with_graphics_font(&cg_font, params.size as CGFloat, ptr::null(), None)
300 })
301 }
302
303 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 let attributed_string = CFMutableAttributedString::new(None, 0)
314 .expect("Failed to create CFMutableAttributedString");
315
316 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 let font_key: &CFString = unsafe { kCTFontAttributeName };
330 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 Self::apply_features(&attributed_string, range, params);
343
344 attributed_string
345 }
346
347 fn apply_features(
349 attr_string: &CFMutableAttributedString,
350 range: CFRange,
351 params: &ShapingParams,
352 ) {
353 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 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 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 let max_glyph_id = font.glyph_count().unwrap_or(u32::MAX);
398
399 let run_count = runs.len();
401 for i in 0..run_count {
402 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 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 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 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 let raw_glyph_id = if !glyphs_ptr.is_null() {
450 (unsafe { *glyphs_ptr.add(idx) }) as u32
451 } else {
452 0
453 };
454
455 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 let cluster = if !indices_ptr.is_null() {
464 unsafe { *indices_ptr.add(idx) }
465 } else {
466 0
467 };
468
469 let advance = advances.get(idx).map(|s| s.width as f32).unwrap_or(0.0);
471
472 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 };
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 let cache_key = Self::shape_cache_key(text, &font, params);
520
521 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 let ct_font = self.build_ct_font(&font, params)?;
532
533 let attr_string = self.create_attributed_string(text, &ct_font, params);
535
536 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 let glyphs = self.extract_glyphs_from_line(&line, &font)?;
545
546 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 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 true
572 }
573
574 fn clear_cache(&self) {
575 log::debug!("CoreTextShaper: Clearing caches");
576 FONT_CACHE.with(|cache| cache.borrow_mut().clear());
578 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 #[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 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 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 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 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 }
690}