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}