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! {
14 static THREAD_FONT_MGR: FontMgr = FontMgr::default();
15 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#[derive(Clone)]
32struct CustomFontVariant {
33 data: Vec<u8>,
34 weight: i32,
35 italic: bool,
36}
37
38fn 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
53pub 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#[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
85fn 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
101fn 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
126pub 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>> {
149 match (&entry.source, &entry.path) {
150 (Some(_), Some(_)) => Err(RustmotionError::FontSourceAndPathConflict {
152 family: entry.family.clone(),
153 }),
154 (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 (Some(other), None) => Err(RustmotionError::Generic(format!(
166 "FontEntry for '{}': unknown source value '{}' (only \"google\" is supported)",
167 entry.family, other
168 ))),
169 (None, Some(path)) => Ok(vec![std::path::PathBuf::from(path)]),
171 (None, None) => Err(RustmotionError::FontMissingPath {
173 family: entry.family.clone(),
174 }),
175 }
176}
177
178pub 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
196fn 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 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
243pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result<Typeface> {
249 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
273pub 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
287pub 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#[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 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 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 #[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; };
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 #[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; };
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 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 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}