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}