Skip to main content

rustmotion_core/engine/renderer/
fonts.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::sync::{Mutex, OnceLock};
4
5use skia_safe::{FontMgr, FontStyle, Typeface};
6
7use crate::error::{Result, RustmotionError};
8use crate::schema::FontEntry;
9
10use super::google_fonts::{font_cache_dir, resolve_google_font};
11
12thread_local! {
13    static THREAD_FONT_MGR: FontMgr = FontMgr::default();
14    static CUSTOM_TYPEFACES: RefCell<GenerationCache<(String, i32, bool), Typeface>> =
15        RefCell::new(GenerationCache::new());
16    static FALLBACK_CACHE: RefCell<GenerationCache<(String, i32, bool, u32), Option<Typeface>>> =
17        RefCell::new(GenerationCache::new());
18}
19
20static FONT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
21
22pub fn invalidate_font_caches() {
23    FONT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
24}
25
26struct GenerationCache<K, V> {
27    generation: u64,
28    map: HashMap<K, V>,
29}
30
31impl<K: std::hash::Hash + Eq, V> GenerationCache<K, V> {
32    fn new() -> Self {
33        Self {
34            generation: FONT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
35            map: HashMap::new(),
36        }
37    }
38
39    fn current(&mut self) -> &mut HashMap<K, V> {
40        let generation = FONT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
41        if self.generation != generation {
42            self.map.clear();
43            self.generation = generation;
44        }
45        &mut self.map
46    }
47}
48
49pub fn font_mgr() -> FontMgr {
50    THREAD_FONT_MGR.with(|mgr| mgr.clone())
51}
52
53#[derive(Clone)]
54struct CustomFontVariant {
55    data: Vec<u8>,
56    weight: i32,
57    italic: bool,
58    generation: u64,
59}
60
61fn custom_font_registry() -> &'static Mutex<HashMap<String, Vec<CustomFontVariant>>> {
62    static REG: OnceLock<Mutex<HashMap<String, Vec<CustomFontVariant>>>> = OnceLock::new();
63    REG.get_or_init(|| Mutex::new(HashMap::new()))
64}
65
66pub fn register_custom_font_variant(family: &str, data: Vec<u8>, weight: i32, italic: bool) {
67    let mut reg = custom_font_registry()
68        .lock()
69        .unwrap_or_else(|e| e.into_inner());
70    let generation = FONT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
71    let variants = reg.entry(family.to_string()).or_default();
72    let replacement = CustomFontVariant {
73        data,
74        weight,
75        italic,
76        generation,
77    };
78    match variants
79        .iter_mut()
80        .find(|v| v.weight == weight && v.italic == italic)
81    {
82        Some(existing) if existing.generation == generation => {}
83        Some(existing) => *existing = replacement,
84        None => variants.push(replacement),
85    }
86}
87
88#[cfg(test)]
89fn custom_font_bytes(family: &str, weight: i32, italic: bool) -> Option<Vec<u8>> {
90    let reg = custom_font_registry()
91        .lock()
92        .unwrap_or_else(|e| e.into_inner());
93    let variants = reg.get(family)?;
94    closest_variant(variants, weight, italic).map(|v| v.data.clone())
95}
96
97fn closest_variant(
98    variants: &[CustomFontVariant],
99    weight: i32,
100    italic: bool,
101) -> Option<&CustomFontVariant> {
102    variants.iter().min_by_key(|v| {
103        let italic_penalty = if v.italic == italic { 0 } else { 1_000_000 };
104        italic_penalty + (v.weight - weight).abs()
105    })
106}
107
108fn custom_typeface(family: &str, style: FontStyle) -> Option<Typeface> {
109    let weight = *style.weight();
110    let italic = style.slant() != skia_safe::font_style::Slant::Upright;
111    let cache_key = (family.to_string(), weight, italic);
112    CUSTOM_TYPEFACES.with(|cache| {
113        if let Some(tf) = cache.borrow_mut().current().get(&cache_key) {
114            return Some(tf.clone());
115        }
116        let data = {
117            let reg = custom_font_registry()
118                .lock()
119                .unwrap_or_else(|e| e.into_inner());
120            let variants = reg.get(family)?;
121            closest_variant(variants, weight, italic)?.data.clone()
122        };
123        let sk_data = skia_safe::Data::new_copy(&data);
124        let tf = font_mgr().new_from_data(&sk_data, None)?;
125        cache.borrow_mut().current().insert(cache_key, tf.clone());
126        Some(tf)
127    })
128}
129
130#[cfg(test)]
131fn what_this_thread_sees_in_its_fallback_cache() -> usize {
132    FALLBACK_CACHE.with(|cache| cache.borrow_mut().current().len())
133}
134
135pub fn resolve_custom_typeface(family: &str, style: FontStyle) -> Option<Typeface> {
136    custom_typeface(family, style)
137}
138
139pub fn resolve_font_entry(entry: &FontEntry) -> Result<Vec<std::path::PathBuf>> {
140    match (&entry.source, &entry.path) {
141        (Some(_), Some(_)) => Err(RustmotionError::FontSourceAndPathConflict {
142            family: entry.family.clone(),
143        }),
144        (Some(source), None) if source == "google" => {
145            let weights = entry
146                .weights
147                .as_deref()
148                .filter(|w| !w.is_empty())
149                .unwrap_or(&[400]);
150            let cache_dir = font_cache_dir();
151            resolve_google_font(&entry.family, weights, &cache_dir)
152        }
153        (Some(other), None) => Err(RustmotionError::Generic(format!(
154            "FontEntry for '{}': unknown source value '{}' (only \"google\" is supported)",
155            entry.family, other
156        ))),
157        (None, Some(path)) => Ok(vec![std::path::PathBuf::from(path)]),
158        (None, None) => Err(RustmotionError::FontMissingPath {
159            family: entry.family.clone(),
160        }),
161    }
162}
163
164pub fn load_custom_fonts(fonts: &[FontEntry]) {
165    if fonts.is_empty() {
166        return;
167    }
168    invalidate_font_caches();
169    let font_mgr = font_mgr();
170    for entry in fonts {
171        match resolve_font_entry(entry) {
172            Err(e) => {
173                eprintln!("Warning: {e}");
174            }
175            Ok(paths) => {
176                for path in paths {
177                    register_font_file(&font_mgr, &entry.family, &path);
178                }
179            }
180        }
181    }
182}
183
184fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) {
185    if !path.exists() {
186        eprintln!(
187            "Warning: custom font '{}' not found at '{}' — falling back to system fonts",
188            family,
189            path.display()
190        );
191        return;
192    }
193    match std::fs::read(path) {
194        Ok(data) => {
195            let sk_data = skia_safe::Data::new_copy(&data);
196            let Some(tf) = font_mgr.new_from_data(&sk_data, None) else {
197                eprintln!(
198                    "Warning: failed to register custom font '{}' from '{}'",
199                    family,
200                    path.display()
201                );
202                return;
203            };
204            let parsed_style = tf.font_style();
205            let weight = *parsed_style.weight();
206            let italic = parsed_style.slant() != skia_safe::font_style::Slant::Upright;
207            register_custom_font_variant(family, data, weight, italic);
208        }
209        Err(e) => {
210            eprintln!(
211                "Warning: failed to read custom font '{}' from '{}': {}",
212                family,
213                path.display(),
214                e
215            );
216        }
217    }
218}
219
220pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result<Typeface> {
221    if let Some(t) = custom_typeface(family, style) {
222        return Ok(t);
223    }
224    let fm = font_mgr();
225    if let Some(t) = fm.match_family_style(family, style) {
226        return Ok(t);
227    }
228    if let Some(t) = fm.match_family_style("Helvetica", style) {
229        return Ok(t);
230    }
231    if let Some(t) = fm.match_family_style("Arial", style) {
232        return Ok(t);
233    }
234    if let Some(t) = fm.legacy_make_typeface(None, style) {
235        return Ok(t);
236    }
237    Err(RustmotionError::FontNotFound)
238}
239
240pub fn emoji_typeface() -> Option<Typeface> {
241    thread_local! {
242        static EMOJI_TF: Option<Typeface> = {
243            let fm = FontMgr::default();
244            let style = FontStyle::normal();
245            fm.match_family_style("Apple Color Emoji", style)
246                .or_else(|| fm.match_family_style("Noto Color Emoji", style))
247                .or_else(|| fm.match_family_style("Segoe UI Emoji", style))
248        };
249    }
250    EMOJI_TF.with(|tf| tf.clone())
251}
252
253pub fn fallback_typeface_for_char(
254    primary_family: &str,
255    style: FontStyle,
256    c: char,
257) -> Option<Typeface> {
258    let weight = *style.weight();
259    let italic = style.slant() != skia_safe::font_style::Slant::Upright;
260    let key = (primary_family.to_string(), weight, italic, c as u32);
261    FALLBACK_CACHE.with(|cache| {
262        if let Some(hit) = cache.borrow_mut().current().get(&key) {
263            return hit.clone();
264        }
265        let resolved =
266            font_mgr().match_family_style_character(primary_family, style, &[], c as i32);
267        cache.borrow_mut().current().insert(key, resolved.clone());
268        resolved
269    })
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn local_entry(path: &str) -> FontEntry {
277        FontEntry {
278            path: Some(path.to_string()),
279            family: "TestFamily".to_string(),
280            source: None,
281            weights: None,
282        }
283    }
284
285    fn google_entry(family: &str, weights: Option<Vec<u16>>) -> FontEntry {
286        FontEntry {
287            path: None,
288            family: family.to_string(),
289            source: Some("google".to_string()),
290            weights,
291        }
292    }
293
294    fn neither_entry() -> FontEntry {
295        FontEntry {
296            path: None,
297            family: "Broken".to_string(),
298            source: None,
299            weights: None,
300        }
301    }
302
303    fn conflict_entry() -> FontEntry {
304        FontEntry {
305            path: Some("fonts/Inter.ttf".to_string()),
306            family: "Inter".to_string(),
307            source: Some("google".to_string()),
308            weights: None,
309        }
310    }
311
312    #[test]
313    fn local_entry_resolves_to_path() {
314        let entry = local_entry("fonts/Inter.ttf");
315        let paths = resolve_font_entry(&entry).unwrap();
316        assert_eq!(paths.len(), 1);
317        assert_eq!(paths[0].to_str().unwrap(), "fonts/Inter.ttf");
318    }
319
320    #[test]
321    fn custom_font_registry_stores_distinct_weights_and_serves_bytes() {
322        register_custom_font_variant("RmProbeRegistryFamily", vec![1, 2, 3], 400, false);
323        register_custom_font_variant("RmProbeRegistryFamily", vec![9, 9], 700, false);
324        assert_eq!(
325            custom_font_bytes("RmProbeRegistryFamily", 400, false),
326            Some(vec![1, 2, 3])
327        );
328        assert_eq!(
329            custom_font_bytes("RmProbeRegistryFamily", 700, false),
330            Some(vec![9, 9])
331        );
332        assert!(custom_font_bytes("RmProbeUnregistered", 400, false).is_none());
333    }
334
335    #[test]
336    fn registering_the_same_weight_twice_keeps_the_first() {
337        register_custom_font_variant("RmProbeDupeFamily", vec![1, 2, 3], 400, false);
338        register_custom_font_variant("RmProbeDupeFamily", vec![9, 9], 400, false);
339        assert_eq!(
340            custom_font_bytes("RmProbeDupeFamily", 400, false),
341            Some(vec![1, 2, 3]),
342            "re-registering the same (weight, italic) must not clobber the first file"
343        );
344    }
345
346    #[test]
347    fn custom_typeface_lookup_picks_the_closest_registered_weight() {
348        register_custom_font_variant("RmProbeClosestFamily", vec![1], 400, false);
349        register_custom_font_variant("RmProbeClosestFamily", vec![2], 700, false);
350        register_custom_font_variant("RmProbeClosestFamily", vec![3], 900, false);
351
352        let reg = custom_font_registry()
353            .lock()
354            .unwrap_or_else(|e| e.into_inner());
355        let variants = reg.get("RmProbeClosestFamily").expect("registered above");
356        assert_eq!(
357            closest_variant(variants, 650, false).unwrap().weight,
358            700,
359            "650 should resolve to the nearest registered weight, 700"
360        );
361        assert_eq!(
362            closest_variant(variants, 100, false).unwrap().weight,
363            400,
364            "100 should resolve to the nearest registered weight, 400"
365        );
366    }
367
368    #[test]
369    fn registered_custom_font_resolves_over_system_fallback() {
370        let path = format!(
371            "{}/.cache/rustmotion/fonts/anton-400.ttf",
372            std::env::var("HOME").unwrap_or_default()
373        );
374        let Ok(bytes) = std::fs::read(&path) else {
375            return;
376        };
377        let fm = font_mgr();
378        let parsed = fm
379            .new_from_data(&skia_safe::Data::new_copy(&bytes), None)
380            .expect("cached TTF must parse");
381        let style = parsed.font_style();
382        register_custom_font_variant(
383            "Anton",
384            bytes,
385            *style.weight(),
386            style.slant() != skia_safe::font_style::Slant::Upright,
387        );
388        let tf = typeface_with_fallback("Anton", FontStyle::normal()).unwrap();
389        assert_eq!(
390            tf.family_name(),
391            "Anton",
392            "must resolve the custom face, not a system fallback"
393        );
394    }
395
396    #[test]
397    fn family_with_two_registered_weights_resolves_distinct_typefaces() {
398        let cache_dir = format!(
399            "{}/.cache/rustmotion/fonts",
400            std::env::var("HOME").unwrap_or_default()
401        );
402        let (Ok(normal_bytes), Ok(bold_bytes)) = (
403            std::fs::read(format!("{cache_dir}/inter-400.ttf")),
404            std::fs::read(format!("{cache_dir}/inter-700.ttf")),
405        ) else {
406            return;
407        };
408
409        let fm = font_mgr();
410        let normal_parsed = fm
411            .new_from_data(&skia_safe::Data::new_copy(&normal_bytes), None)
412            .expect("cached TTF must parse");
413        let bold_parsed = fm
414            .new_from_data(&skia_safe::Data::new_copy(&bold_bytes), None)
415            .expect("cached TTF must parse");
416        let normal_weight = *normal_parsed.font_style().weight();
417        let bold_weight = *bold_parsed.font_style().weight();
418
419        register_custom_font_variant("RmProbeInterFamily", normal_bytes, normal_weight, false);
420        register_custom_font_variant("RmProbeInterFamily", bold_bytes, bold_weight, false);
421
422        let resolved_normal =
423            typeface_with_fallback("RmProbeInterFamily", FontStyle::normal()).unwrap();
424        let resolved_bold =
425            typeface_with_fallback("RmProbeInterFamily", FontStyle::bold()).unwrap();
426
427        assert_ne!(
428            *resolved_normal.font_style().weight(),
429            *resolved_bold.font_style().weight(),
430            "requesting normal vs bold on the same custom family must resolve different weights \
431             (both used to resolve to whichever file registered first)"
432        );
433        assert_eq!(*resolved_bold.font_style().weight(), bold_weight);
434        assert_eq!(*resolved_normal.font_style().weight(), normal_weight);
435    }
436
437    #[test]
438    fn neither_path_nor_source_is_error() {
439        let entry = neither_entry();
440        let err = resolve_font_entry(&entry).unwrap_err();
441        assert!(
442            matches!(err, RustmotionError::FontMissingPath { .. }),
443            "expected FontMissingPath, got: {err}"
444        );
445    }
446
447    #[test]
448    fn path_and_source_conflict_is_error() {
449        let entry = conflict_entry();
450        let err = resolve_font_entry(&entry).unwrap_err();
451        assert!(
452            matches!(err, RustmotionError::FontSourceAndPathConflict { .. }),
453            "expected FontSourceAndPathConflict, got: {err}"
454        );
455    }
456
457    #[test]
458    fn google_entry_with_cached_file_resolves() {
459        let cache_dir = std::env::temp_dir()
460            .join("rustmotion-test-fonts")
461            .join("fonts-rs-google-cache");
462        std::fs::create_dir_all(&cache_dir).unwrap();
463        std::fs::write(cache_dir.join("inter-400.ttf"), b"fake ttf").unwrap();
464
465        let entry = google_entry("Inter", None);
466        let paths = crate::engine::renderer::google_fonts::resolve_google_font(
467            &entry.family,
468            &[400],
469            &cache_dir,
470        )
471        .unwrap();
472        assert_eq!(paths.len(), 1);
473    }
474}
475
476#[cfg(test)]
477mod cache_invalidation_reaches_every_thread_tests {
478    use super::*;
479    use rayon::prelude::*;
480    use std::sync::atomic::Ordering;
481
482    static GENERATION_IS_SHARED: Mutex<()> = Mutex::new(());
483
484    fn hold_the_generation_still() -> std::sync::MutexGuard<'static, ()> {
485        GENERATION_IS_SHARED
486            .lock()
487            .unwrap_or_else(|e| e.into_inner())
488    }
489
490    fn a_face(marker: u8) -> Vec<u8> {
491        let mut data = vec![
492            0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x03, 0x00, 0x04,
493        ];
494        data.push(marker);
495        data
496    }
497
498    fn fill_this_threads_fallback_cache() -> usize {
499        for c in ['a', 'e', 'i', 'o', 'u', 'y', 'z'] {
500            fallback_typeface_for_char("rm-test-no-such-family", FontStyle::normal(), c);
501        }
502        what_this_thread_sees_in_its_fallback_cache()
503    }
504
505    #[test]
506    fn a_rayon_worker_drops_its_own_copy_when_the_generation_moves() {
507        let _serialised = hold_the_generation_still();
508        let before: Vec<usize> = (0..256)
509            .into_par_iter()
510            .map(|_| fill_this_threads_fallback_cache())
511            .collect();
512        assert!(
513            before.iter().any(|&len| len > 0),
514            "test setup: the workers must actually have filled a thread-local cache, or the \
515             assertion below proves nothing"
516        );
517
518        invalidate_font_caches();
519
520        let after: Vec<usize> = (0..256)
521            .into_par_iter()
522            .map(|_| what_this_thread_sees_in_its_fallback_cache())
523            .collect();
524        assert!(
525            after.iter().all(|&len| len == 0),
526            "the clearing is lazy, so this is what each worker sees on its next lookup — and \
527             that is the guarantee: clearing on the calling thread alone leaves the threads \
528             that actually render frames serving the stale face forever: {after:?}"
529        );
530    }
531
532    #[test]
533    fn a_generation_cache_holds_its_entries_until_the_generation_moves() {
534        let _serialised = hold_the_generation_still();
535        let mut cache: GenerationCache<u32, u32> = GenerationCache::new();
536        cache.current().insert(1, 10);
537        cache.current().insert(2, 20);
538        assert_eq!(
539            cache.current().len(),
540            2,
541            "nothing moved, nothing is dropped"
542        );
543        invalidate_font_caches();
544        assert_eq!(
545            cache.current().len(),
546            0,
547            "the next access after a bump is what clears it — a thread that is busy rendering \
548             does not have to be interrupted for its cache to go stale"
549        );
550    }
551
552    #[test]
553    fn a_second_file_in_the_same_load_does_not_clobber_the_first() {
554        let _serialised = hold_the_generation_still();
555        let family = "rm-test-same-load";
556        invalidate_font_caches();
557        register_custom_font_variant(family, a_face(1), 400, false);
558        register_custom_font_variant(family, a_face(2), 400, false);
559        assert_eq!(
560            custom_font_bytes(family, 400, false),
561            Some(a_face(1)),
562            "one FontEntry can resolve to several files; two of them landing on the same weight \
563             and slant must not fight, the first still wins"
564        );
565    }
566
567    #[test]
568    fn the_next_load_supersedes_the_variant_the_previous_one_registered() {
569        let _serialised = hold_the_generation_still();
570        let family = "rm-test-reload";
571        invalidate_font_caches();
572        register_custom_font_variant(family, a_face(1), 400, false);
573
574        invalidate_font_caches();
575        register_custom_font_variant(family, a_face(2), 400, false);
576        assert_eq!(
577            custom_font_bytes(family, 400, false),
578            Some(a_face(2)),
579            "editing the file under --watch is a new load, and its bytes must win — being \
580             skipped is what kept serving the previous face for the rest of the process"
581        );
582    }
583
584    #[test]
585    fn loading_an_empty_font_list_does_not_throw_away_every_threads_cache() {
586        let _serialised = hold_the_generation_still();
587        let generation = FONT_GENERATION.load(Ordering::Acquire);
588        load_custom_fonts(&[]);
589        assert_eq!(
590            FONT_GENERATION.load(Ordering::Acquire),
591            generation,
592            "a scenario with no custom fonts reloads constantly under --watch; bumping there \
593             would clear every worker's font cache for nothing"
594        );
595    }
596}