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
12// Thread-local FontMgr instance, created once per thread and reused
13thread_local! {
14    static THREAD_FONT_MGR: FontMgr = FontMgr::default();
15    // Per-thread cache of Typefaces built from the global custom-font bytes,
16    // keyed by (family, weight, italic) — the exact variant `custom_typeface`
17    // picked — so each render thread builds each custom face at most once.
18    static CUSTOM_TYPEFACES: RefCell<HashMap<(String, i32, bool), Typeface>> =
19        RefCell::new(HashMap::new());
20}
21
22pub fn font_mgr() -> FontMgr {
23    THREAD_FONT_MGR.with(|mgr| mgr.clone())
24}
25
26/// One registered custom-font file: its raw bytes plus the `(weight,
27/// italic)` style Skia parsed out of the file itself when it was
28/// registered — the ground truth for what that file actually renders as,
29/// independent of which nominal weight the caller happened to request it
30/// under.
31#[derive(Clone)]
32struct CustomFontVariant {
33    data: Vec<u8>,
34    weight: i32,
35    italic: bool,
36}
37
38/// Global registry of custom/Google-font bytes, keyed by family name. Filled
39/// once by [`load_custom_fonts`] on the main thread; read by every render
40/// thread through [`custom_typeface`]. Each family holds every distinct
41/// `(weight, italic)` variant registered for it — e.g. a Google Fonts
42/// declaration with `weights: [400, 700]` registers two variants — so
43/// [`custom_typeface`] can pick whichever is the closest match to what a
44/// paint call asks for, instead of always returning the first file that
45/// happened to register (the previous behaviour: every variant after the
46/// first was invisible, and every weight/style request resolved to
47/// whichever one file won the race).
48fn custom_font_registry() -> &'static Mutex<HashMap<String, Vec<CustomFontVariant>>> {
49    static REG: OnceLock<Mutex<HashMap<String, Vec<CustomFontVariant>>>> = OnceLock::new();
50    REG.get_or_init(|| Mutex::new(HashMap::new()))
51}
52
53/// Register a custom font's bytes under `family`, tagged with the `(weight,
54/// italic)` style Skia reports for the parsed file. A no-op if that exact
55/// `(family, weight, italic)` combination is already registered.
56pub fn register_custom_font_variant(family: &str, data: Vec<u8>, weight: i32, italic: bool) {
57    let mut reg = custom_font_registry()
58        .lock()
59        .unwrap_or_else(|e| e.into_inner());
60    let variants = reg.entry(family.to_string()).or_default();
61    if !variants
62        .iter()
63        .any(|v| v.weight == weight && v.italic == italic)
64    {
65        variants.push(CustomFontVariant {
66            data,
67            weight,
68            italic,
69        });
70    }
71}
72
73/// The raw bytes registered for `family`'s closest `(weight, italic)` match,
74/// if any variant is registered under that family (test/introspection
75/// helper).
76#[cfg(test)]
77fn custom_font_bytes(family: &str, weight: i32, italic: bool) -> Option<Vec<u8>> {
78    let reg = custom_font_registry()
79        .lock()
80        .unwrap_or_else(|e| e.into_inner());
81    let variants = reg.get(family)?;
82    closest_variant(variants, weight, italic).map(|v| v.data.clone())
83}
84
85/// Pick the registered variant closest to `(weight, italic)`: exact
86/// italic-ness match preferred, then the smallest weight distance — the
87/// same nearest-match spirit as CSS font matching (`font-weight`/
88/// `font-style` never fail to resolve to *something*, they resolve to the
89/// closest available face).
90fn closest_variant(
91    variants: &[CustomFontVariant],
92    weight: i32,
93    italic: bool,
94) -> Option<&CustomFontVariant> {
95    variants.iter().min_by_key(|v| {
96        let italic_penalty = if v.italic == italic { 0 } else { 1_000_000 };
97        italic_penalty + (v.weight - weight).abs()
98    })
99}
100
101/// Resolve a registered custom font to a Typeface for the requested `style`,
102/// building it from the global bytes on first use per thread and caching it
103/// thereafter. `None` when no custom font is registered under `family`.
104fn custom_typeface(family: &str, style: FontStyle) -> Option<Typeface> {
105    let weight = *style.weight();
106    let italic = style.slant() != skia_safe::font_style::Slant::Upright;
107    let cache_key = (family.to_string(), weight, italic);
108    CUSTOM_TYPEFACES.with(|cache| {
109        if let Some(tf) = cache.borrow().get(&cache_key) {
110            return Some(tf.clone());
111        }
112        let data = {
113            let reg = custom_font_registry()
114                .lock()
115                .unwrap_or_else(|e| e.into_inner());
116            let variants = reg.get(family)?;
117            closest_variant(variants, weight, italic)?.data.clone()
118        };
119        let sk_data = skia_safe::Data::new_copy(&data);
120        let tf = font_mgr().new_from_data(&sk_data, None)?;
121        cache.borrow_mut().insert(cache_key, tf.clone());
122        Some(tf)
123    })
124}
125
126/// Look up only the custom/Google-font registry for `family` at the
127/// requested `style`, without falling through to any system font. Exposed
128/// for callers (e.g. `codeblock`/`terminal`'s monospace font resolver) that
129/// need to check "did the scenario declare a custom font for this family"
130/// *before* trying their own family-specific system fallback chain — unlike
131/// [`typeface_with_fallback`], which interleaves a single system-family
132/// lookup between the custom check and its own generic Helvetica/Arial
133/// catch-all, an order that doesn't suit every caller (see issue: codeblock/
134/// terminal's hardcoded monospace fallback list was never reached because
135/// `typeface_with_fallback`'s own system lookup already matched a decoy
136/// system family, e.g. "JetBrains Mono").
137pub fn resolve_custom_typeface(family: &str, style: FontStyle) -> Option<Typeface> {
138    custom_typeface(family, style)
139}
140
141/// Validate a `FontEntry` and resolve it to a list of TTF file paths.
142///
143/// - Local entry (`path` set, `source` absent): returns `[path]` as-is.
144/// - Google Fonts entry (`source = "google"`, `path` absent): downloads
145///   (or reads from cache) and returns one path per requested weight.
146/// - Conflict (`source` and `path` both set): returns an error.
147/// - Neither (`path` absent and `source` absent): returns an error.
148pub fn resolve_font_entry(entry: &FontEntry) -> Result<Vec<std::path::PathBuf>> {
149    match (&entry.source, &entry.path) {
150        // Conflict: both path and source set.
151        (Some(_), Some(_)) => Err(RustmotionError::FontSourceAndPathConflict {
152            family: entry.family.clone(),
153        }),
154        // Google Fonts.
155        (Some(source), None) if source == "google" => {
156            let weights = entry
157                .weights
158                .as_deref()
159                .filter(|w| !w.is_empty())
160                .unwrap_or(&[400]);
161            let cache_dir = font_cache_dir();
162            resolve_google_font(&entry.family, weights, &cache_dir)
163        }
164        // Unknown source value — treat as a user error.
165        (Some(other), None) => Err(RustmotionError::Generic(format!(
166            "FontEntry for '{}': unknown source value '{}' (only \"google\" is supported)",
167            entry.family, other
168        ))),
169        // Local file.
170        (None, Some(path)) => Ok(vec![std::path::PathBuf::from(path)]),
171        // Neither path nor source.
172        (None, None) => Err(RustmotionError::FontMissingPath {
173            family: entry.family.clone(),
174        }),
175    }
176}
177
178/// Load custom fonts from FontEntry definitions. Emits a single warning per
179/// missing or unreadable file so the user notices broken paths up-front.
180pub fn load_custom_fonts(fonts: &[FontEntry]) {
181    let font_mgr = font_mgr();
182    for entry in fonts {
183        match resolve_font_entry(entry) {
184            Err(e) => {
185                eprintln!("Warning: {e}");
186            }
187            Ok(paths) => {
188                for path in paths {
189                    register_font_file(&font_mgr, &entry.family, &path);
190                }
191            }
192        }
193    }
194}
195
196/// Register a single TTF/OTF file into the given FontMgr.
197fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) {
198    if !path.exists() {
199        eprintln!(
200            "Warning: custom font '{}' not found at '{}' — falling back to system fonts",
201            family,
202            path.display()
203        );
204        return;
205    }
206    match std::fs::read(path) {
207        Ok(data) => {
208            let sk_data = skia_safe::Data::new_copy(&data);
209            let Some(tf) = font_mgr.new_from_data(&sk_data, None) else {
210                eprintln!(
211                    "Warning: failed to register custom font '{}' from '{}'",
212                    family,
213                    path.display()
214                );
215                return;
216            };
217            // Skia's default FontMgr can build a Typeface from `new_from_data`
218            // but never exposes it to `match_family_style` (name lookup only
219            // sees installed system fonts). So keep the raw bytes in a global
220            // registry; `typeface_with_fallback` builds and caches a Typeface
221            // from them per thread, ahead of the system match. Tag the
222            // variant with the (weight, italic) Skia parsed out of the file
223            // itself — the ground truth for what it actually renders as —
224            // so a family with several registered weights (e.g. Google
225            // Fonts `weights: [400, 700]`) exposes every one of them instead
226            // of only whichever file happened to register first.
227            let parsed_style = tf.font_style();
228            let weight = *parsed_style.weight();
229            let italic = parsed_style.slant() != skia_safe::font_style::Slant::Upright;
230            register_custom_font_variant(family, data, weight, italic);
231        }
232        Err(e) => {
233            eprintln!(
234                "Warning: failed to read custom font '{}' from '{}': {}",
235                family,
236                path.display(),
237                e
238            );
239        }
240    }
241}
242
243/// Resolve a typeface for `family` falling back through Helvetica → Arial →
244/// the OS default. Returns `RustmotionError::FontNotFound` only if the host
245/// system has no usable font at all (essentially unreachable on every
246/// supported platform). Use this instead of `.expect("FontNotFound")` so we
247/// never panic from a `paint` callback.
248pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result<Typeface> {
249    // Custom/Google fonts declared in the scenario win over system fonts:
250    // they are not visible to `match_family_style`, so resolve them from the
251    // registry first — matched against the requested `style` so a family
252    // registered with several weights picks the right one instead of
253    // whichever file happened to register first (#6).
254    if let Some(t) = custom_typeface(family, style) {
255        return Ok(t);
256    }
257    let fm = font_mgr();
258    if let Some(t) = fm.match_family_style(family, style) {
259        return Ok(t);
260    }
261    if let Some(t) = fm.match_family_style("Helvetica", style) {
262        return Ok(t);
263    }
264    if let Some(t) = fm.match_family_style("Arial", style) {
265        return Ok(t);
266    }
267    if let Some(t) = fm.legacy_make_typeface(None, style) {
268        return Ok(t);
269    }
270    Err(RustmotionError::FontNotFound)
271}
272
273/// Resolve the system emoji typeface. Cached per thread.
274pub fn emoji_typeface() -> Option<Typeface> {
275    thread_local! {
276        static EMOJI_TF: Option<Typeface> = {
277            let fm = FontMgr::default();
278            let style = FontStyle::normal();
279            fm.match_family_style("Apple Color Emoji", style)
280                .or_else(|| fm.match_family_style("Noto Color Emoji", style))
281                .or_else(|| fm.match_family_style("Segoe UI Emoji", style))
282        };
283    }
284    EMOJI_TF.with(|tf| tf.clone())
285}
286
287/// Resolve a system fallback typeface that actually contains a glyph for
288/// `c`, for when `primary_family`'s own face doesn't cover it (audit #3:
289/// CJK/Arabic/Devanagari/other scripts rendered as `.notdef` tofu when only
290/// a Latin `font-family` was requested, because neither measurement nor
291/// painting ever looked past the single requested typeface). This is
292/// Skia's font-fallback-by-character API — the same mechanism a browser
293/// uses to substitute, say, a CJK font for Chinese text embedded in an
294/// otherwise-Latin paragraph, instead of leaving `.notdef` tofu. Memoized
295/// per thread (keyed on the inputs that actually affect the OS's fallback
296/// decision) since callers may probe this once per uncovered code point
297/// during run segmentation. Returns `None` if no installed font covers `c`
298/// either — the caller falls back to the originally requested (tofu-
299/// producing) font, exactly the pre-fix behaviour, not worse.
300pub fn fallback_typeface_for_char(
301    primary_family: &str,
302    style: FontStyle,
303    c: char,
304) -> Option<Typeface> {
305    thread_local! {
306        static FALLBACK_CACHE: RefCell<HashMap<(String, i32, bool, u32), Option<Typeface>>> =
307            RefCell::new(HashMap::new());
308    }
309    let weight = *style.weight();
310    let italic = style.slant() != skia_safe::font_style::Slant::Upright;
311    let key = (primary_family.to_string(), weight, italic, c as u32);
312    FALLBACK_CACHE.with(|cache| {
313        if let Some(hit) = cache.borrow().get(&key) {
314            return hit.clone();
315        }
316        let resolved =
317            font_mgr().match_family_style_character(primary_family, style, &[], c as i32);
318        cache.borrow_mut().insert(key, resolved.clone());
319        resolved
320    })
321}
322
323// ─── Unit tests ──────────────────────────────────────────────────────────────
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn local_entry(path: &str) -> FontEntry {
330        FontEntry {
331            path: Some(path.to_string()),
332            family: "TestFamily".to_string(),
333            source: None,
334            weights: None,
335        }
336    }
337
338    fn google_entry(family: &str, weights: Option<Vec<u16>>) -> FontEntry {
339        FontEntry {
340            path: None,
341            family: family.to_string(),
342            source: Some("google".to_string()),
343            weights,
344        }
345    }
346
347    fn neither_entry() -> FontEntry {
348        FontEntry {
349            path: None,
350            family: "Broken".to_string(),
351            source: None,
352            weights: None,
353        }
354    }
355
356    fn conflict_entry() -> FontEntry {
357        FontEntry {
358            path: Some("fonts/Inter.ttf".to_string()),
359            family: "Inter".to_string(),
360            source: Some("google".to_string()),
361            weights: None,
362        }
363    }
364
365    #[test]
366    fn local_entry_resolves_to_path() {
367        let entry = local_entry("fonts/Inter.ttf");
368        let paths = resolve_font_entry(&entry).unwrap();
369        assert_eq!(paths.len(), 1);
370        assert_eq!(paths[0].to_str().unwrap(), "fonts/Inter.ttf");
371    }
372
373    #[test]
374    fn custom_font_registry_stores_distinct_weights_and_serves_bytes() {
375        register_custom_font_variant("RmProbeRegistryFamily", vec![1, 2, 3], 400, false);
376        // A *different* (weight, italic) is a genuinely new variant — not a
377        // clobber of the first (the old `family`-only-keyed `or_insert`
378        // registry made every registration after the first invisible; #6).
379        register_custom_font_variant("RmProbeRegistryFamily", vec![9, 9], 700, false);
380        assert_eq!(
381            custom_font_bytes("RmProbeRegistryFamily", 400, false),
382            Some(vec![1, 2, 3])
383        );
384        assert_eq!(
385            custom_font_bytes("RmProbeRegistryFamily", 700, false),
386            Some(vec![9, 9])
387        );
388        assert!(custom_font_bytes("RmProbeUnregistered", 400, false).is_none());
389    }
390
391    #[test]
392    fn registering_the_same_weight_twice_keeps_the_first() {
393        register_custom_font_variant("RmProbeDupeFamily", vec![1, 2, 3], 400, false);
394        register_custom_font_variant("RmProbeDupeFamily", vec![9, 9], 400, false);
395        assert_eq!(
396            custom_font_bytes("RmProbeDupeFamily", 400, false),
397            Some(vec![1, 2, 3]),
398            "re-registering the same (weight, italic) must not clobber the first file"
399        );
400    }
401
402    #[test]
403    fn custom_typeface_lookup_picks_the_closest_registered_weight() {
404        // Pure selection-logic reproduction of #6's fix mechanism,
405        // independent of any font actually installed on the host: three
406        // variants registered under one family; a lookup for an
407        // intermediate weight must pick the *closest* one, not always the
408        // first registered — the defect the audit measured (bold and
409        // normal always resolving to the same file).
410        register_custom_font_variant("RmProbeClosestFamily", vec![1], 400, false);
411        register_custom_font_variant("RmProbeClosestFamily", vec![2], 700, false);
412        register_custom_font_variant("RmProbeClosestFamily", vec![3], 900, false);
413
414        let reg = custom_font_registry()
415            .lock()
416            .unwrap_or_else(|e| e.into_inner());
417        let variants = reg.get("RmProbeClosestFamily").expect("registered above");
418        assert_eq!(
419            closest_variant(variants, 650, false).unwrap().weight,
420            700,
421            "650 should resolve to the nearest registered weight, 700"
422        );
423        assert_eq!(
424            closest_variant(variants, 100, false).unwrap().weight,
425            400,
426            "100 should resolve to the nearest registered weight, 400"
427        );
428    }
429
430    /// The bug this fix targets: a registered custom family must resolve to the
431    /// custom typeface, not the Helvetica/Arial fallback. Uses the cached Anton
432    /// TTF when present (Google-font path); skips on a cold cache so CI without
433    /// network still passes — the render QA is the visual counterpart.
434    #[test]
435    fn registered_custom_font_resolves_over_system_fallback() {
436        let path = format!(
437            "{}/.cache/rustmotion/fonts/anton-400.ttf",
438            std::env::var("HOME").unwrap_or_default()
439        );
440        let Ok(bytes) = std::fs::read(&path) else {
441            return; // cold font cache → skip (render QA covers it)
442        };
443        let fm = font_mgr();
444        let parsed = fm
445            .new_from_data(&skia_safe::Data::new_copy(&bytes), None)
446            .expect("cached TTF must parse");
447        let style = parsed.font_style();
448        register_custom_font_variant(
449            "Anton",
450            bytes,
451            *style.weight(),
452            style.slant() != skia_safe::font_style::Slant::Upright,
453        );
454        let tf = typeface_with_fallback("Anton", FontStyle::normal()).unwrap();
455        assert_eq!(
456            tf.family_name(),
457            "Anton",
458            "must resolve the custom face, not a system fallback"
459        );
460    }
461
462    /// End-to-end reproduction of #6: a family registered with two distinct
463    /// weights (mirrors `fonts: [{"family":"Inter","source":"google",
464    /// "weights":[400,700]}]`) must resolve *different* typefaces for
465    /// `font-weight: normal` vs `font-weight: bold`. Before the fix,
466    /// `custom_typeface` ignored `style` entirely and `register_custom_
467    /// font_bytes` kept only the first-registered file, so the audit's two
468    /// rendered PNGs (bold vs normal) came out byte-for-byte identical.
469    /// Skips on a cold font cache (no network access in CI) — the render QA
470    /// in `examples/` is the visual counterpart.
471    #[test]
472    fn family_with_two_registered_weights_resolves_distinct_typefaces() {
473        let cache_dir = format!(
474            "{}/.cache/rustmotion/fonts",
475            std::env::var("HOME").unwrap_or_default()
476        );
477        let (Ok(normal_bytes), Ok(bold_bytes)) = (
478            std::fs::read(format!("{cache_dir}/inter-400.ttf")),
479            std::fs::read(format!("{cache_dir}/inter-700.ttf")),
480        ) else {
481            return; // cold font cache → skip (render QA covers it)
482        };
483
484        let fm = font_mgr();
485        let normal_parsed = fm
486            .new_from_data(&skia_safe::Data::new_copy(&normal_bytes), None)
487            .expect("cached TTF must parse");
488        let bold_parsed = fm
489            .new_from_data(&skia_safe::Data::new_copy(&bold_bytes), None)
490            .expect("cached TTF must parse");
491        let normal_weight = *normal_parsed.font_style().weight();
492        let bold_weight = *bold_parsed.font_style().weight();
493
494        register_custom_font_variant("RmProbeInterFamily", normal_bytes, normal_weight, false);
495        register_custom_font_variant("RmProbeInterFamily", bold_bytes, bold_weight, false);
496
497        let resolved_normal =
498            typeface_with_fallback("RmProbeInterFamily", FontStyle::normal()).unwrap();
499        let resolved_bold =
500            typeface_with_fallback("RmProbeInterFamily", FontStyle::bold()).unwrap();
501
502        assert_ne!(
503            *resolved_normal.font_style().weight(),
504            *resolved_bold.font_style().weight(),
505            "requesting normal vs bold on the same custom family must resolve different weights \
506             (both used to resolve to whichever file registered first)"
507        );
508        assert_eq!(*resolved_bold.font_style().weight(), bold_weight);
509        assert_eq!(*resolved_normal.font_style().weight(), normal_weight);
510    }
511
512    #[test]
513    fn neither_path_nor_source_is_error() {
514        let entry = neither_entry();
515        let err = resolve_font_entry(&entry).unwrap_err();
516        assert!(
517            matches!(err, RustmotionError::FontMissingPath { .. }),
518            "expected FontMissingPath, got: {err}"
519        );
520    }
521
522    #[test]
523    fn path_and_source_conflict_is_error() {
524        let entry = conflict_entry();
525        let err = resolve_font_entry(&entry).unwrap_err();
526        assert!(
527            matches!(err, RustmotionError::FontSourceAndPathConflict { .. }),
528            "expected FontSourceAndPathConflict, got: {err}"
529        );
530    }
531
532    #[test]
533    fn google_entry_with_cached_file_resolves() {
534        // Build a pre-warmed cache dir and inject it via resolve_google_font directly.
535        let cache_dir = std::env::temp_dir()
536            .join("rustmotion-test-fonts")
537            .join("fonts-rs-google-cache");
538        std::fs::create_dir_all(&cache_dir).unwrap();
539        std::fs::write(cache_dir.join("inter-400.ttf"), b"fake ttf").unwrap();
540
541        let entry = google_entry("Inter", None);
542        // We call resolve_google_font directly with the injected dir to avoid
543        // any real network in unit tests.
544        let paths = crate::engine::renderer::google_fonts::resolve_google_font(
545            &entry.family,
546            &[400],
547            &cache_dir,
548        )
549        .unwrap();
550        assert_eq!(paths.len(), 1);
551    }
552}