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