1use std::{
2 collections::{HashMap, hash_map::DefaultHasher},
3 fs,
4 hash::{Hash, Hasher},
5 path::{Path, PathBuf},
6 sync::{Mutex, OnceLock},
7};
8
9static FONT_CHAR_SUPPORT_CACHE: OnceLock<Mutex<HashMap<(PathBuf, char), bool>>> = OnceLock::new();
10
11fn font_char_support_cache() -> &'static Mutex<HashMap<(PathBuf, char), bool>> {
12 FONT_CHAR_SUPPORT_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
13}
14
15#[cfg(not(target_arch = "wasm32"))]
16use fontdb::{Database, Family, Query, Source, Stretch, Style as FontdbStyle, Weight};
17
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub struct FontAttachment {
20 pub name: String,
21 pub data: Vec<u8>,
22}
23
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
25pub struct FontQuery {
26 pub family: String,
27 pub style: Option<String>,
28 pub weight: Option<i32>,
29}
30
31impl FontQuery {
32 pub fn new(family: impl Into<String>) -> Self {
33 Self {
34 family: family.into(),
35 style: None,
36 weight: None,
37 }
38 }
39
40 pub fn with_style(mut self, style: impl Into<String>) -> Self {
41 self.style = Some(style.into());
42 self
43 }
44
45 pub fn with_weight(mut self, weight: i32) -> Self {
46 self.weight = Some(weight);
47 self
48 }
49}
50
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub enum FontProviderKind {
53 #[default]
54 Null,
55 Fontconfig,
56 Attached,
57 DefaultFile,
58}
59
60#[derive(Clone, Debug, Default, PartialEq, Eq)]
61pub struct FontMatch {
62 pub family: String,
63 pub path: Option<PathBuf>,
64 pub face_index: Option<u32>,
65 pub style: Option<String>,
66 pub synthetic_bold: bool,
67 pub synthetic_italic: bool,
68 pub provider: FontProviderKind,
69}
70
71impl FontMatch {
72 pub fn unresolved(
73 family: impl Into<String>,
74 style: Option<String>,
75 provider: FontProviderKind,
76 ) -> Self {
77 Self {
78 family: family.into(),
79 path: None,
80 face_index: None,
81 style,
82 synthetic_bold: false,
83 synthetic_italic: false,
84 provider,
85 }
86 }
87}
88
89pub trait FontProvider {
90 fn resolve(&self, query: &FontQuery) -> FontMatch;
91
92 fn resolve_family(&self, family: &str) -> FontMatch {
93 self.resolve(&FontQuery::new(family))
94 }
95}
96
97impl<T: FontProvider + ?Sized> FontProvider for Box<T> {
98 fn resolve(&self, query: &FontQuery) -> FontMatch {
99 (**self).resolve(query)
100 }
101}
102
103impl<T: FontProvider + ?Sized> FontProvider for &T {
104 fn resolve(&self, query: &FontQuery) -> FontMatch {
105 (**self).resolve(query)
106 }
107}
108
109#[derive(Default)]
110pub struct NullFontProvider;
111
112impl FontProvider for NullFontProvider {
113 fn resolve(&self, query: &FontQuery) -> FontMatch {
114 FontMatch::unresolved(
115 query.family.clone(),
116 query.style.clone(),
117 FontProviderKind::Null,
118 )
119 }
120}
121
122pub struct CrossfontProvider {
123 fallback_family: Option<String>,
124 resolve_cache: Mutex<HashMap<FontResolveKey, FontMatch>>,
125}
126
127#[derive(Clone, Debug, PartialEq, Eq, Hash)]
128struct FontResolveKey {
129 family: String,
130 style: Option<String>,
131 weight: Option<i32>,
132}
133
134impl From<&FontQuery> for FontResolveKey {
135 fn from(query: &FontQuery) -> Self {
136 Self {
137 family: query.family.clone(),
138 style: query.style.clone(),
139 weight: query.weight,
140 }
141 }
142}
143
144pub type FontconfigProvider = CrossfontProvider;
145
146impl Default for CrossfontProvider {
147 fn default() -> Self {
148 Self::new()
149 }
150}
151
152impl CrossfontProvider {
153 pub fn new() -> Self {
154 Self {
155 fallback_family: None,
156 resolve_cache: Mutex::new(HashMap::new()),
157 }
158 }
159
160 pub fn with_fallback_family(fallback_family: impl Into<String>) -> Self {
161 Self {
162 fallback_family: Some(fallback_family.into()),
163 resolve_cache: Mutex::new(HashMap::new()),
164 }
165 }
166
167 #[cfg(test)]
168 fn resolve_cache_len_for_tests(&self) -> usize {
169 self.resolve_cache
170 .lock()
171 .expect("font resolve cache mutex poisoned")
172 .len()
173 }
174
175 #[cfg(not(target_arch = "wasm32"))]
176 fn find_font(
177 &self,
178 family: String,
179 style: Option<String>,
180 weight: Option<i32>,
181 ) -> Option<FontMatch> {
182 resolve_system_font(&family, style.as_deref(), weight).map(
183 |(resolved_family, resolved_path, face_index)| {
184 let resolved_style = resolved_path
185 .as_deref()
186 .and_then(|path| load_face_metadata(path).and_then(|(_, style)| style));
187 let (synthetic_bold, synthetic_italic) =
188 synthetic_style_flags(style.as_deref(), weight, resolved_style.as_deref());
189
190 FontMatch {
191 family: resolved_family,
192 path: resolved_path,
193 face_index,
194 style,
195 synthetic_bold,
196 synthetic_italic,
197 provider: FontProviderKind::Fontconfig,
198 }
199 },
200 )
201 }
202
203 #[cfg(target_arch = "wasm32")]
204 fn find_font(
205 &self,
206 _family: String,
207 _style: Option<String>,
208 _weight: Option<i32>,
209 ) -> Option<FontMatch> {
210 None
211 }
212}
213
214impl FontProvider for CrossfontProvider {
215 fn resolve(&self, query: &FontQuery) -> FontMatch {
216 let cache_key = FontResolveKey::from(query);
217 if let Some(cached) = self
218 .resolve_cache
219 .lock()
220 .expect("font resolve cache mutex poisoned")
221 .get(&cache_key)
222 .cloned()
223 {
224 return cached;
225 }
226
227 let resolved = if let Some(font) =
228 self.find_font(query.family.clone(), query.style.clone(), query.weight)
229 {
230 font
231 } else if let Some(fallback_family) = &self.fallback_family {
232 self.find_font(fallback_family.clone(), query.style.clone(), query.weight)
233 .unwrap_or_else(|| {
234 FontMatch::unresolved(
235 query.family.clone(),
236 query.style.clone(),
237 FontProviderKind::Fontconfig,
238 )
239 })
240 } else {
241 FontMatch::unresolved(
242 query.family.clone(),
243 query.style.clone(),
244 FontProviderKind::Fontconfig,
245 )
246 };
247
248 self.resolve_cache
249 .lock()
250 .expect("font resolve cache mutex poisoned")
251 .insert(cache_key, resolved.clone());
252 resolved
253 }
254}
255
256#[cfg(not(target_arch = "wasm32"))]
257fn resolve_system_font(
258 family: &str,
259 style: Option<&str>,
260 weight: Option<i32>,
261) -> Option<(String, Option<PathBuf>, Option<u32>)> {
262 let mut database = Database::new();
263 database.load_system_fonts();
264
265 #[cfg(all(unix, not(target_os = "macos")))]
266 if let Some((path, face_index)) = fontconfig_match_path(family, style, weight, None) {
267 let resolved_family = load_face_metadata(&path)
268 .map(|(family, _)| family)
269 .unwrap_or_else(|| family.to_owned());
270 return Some((resolved_family, Some(path), face_index));
271 }
272
273 let requested_style = style.map(normalize_font_key);
274 let wants_bold = requested_style
275 .as_deref()
276 .is_some_and(|style| style.contains("bold"))
277 || weight.is_some_and(bold_weight_is_active);
278 let fontdb_style = requested_style
279 .as_deref()
280 .map(|style| {
281 if style.contains("italic") || style.contains("oblique") {
282 FontdbStyle::Italic
283 } else {
284 FontdbStyle::Normal
285 }
286 })
287 .unwrap_or(FontdbStyle::Normal);
288
289 let normalized_family = normalize_font_key(family);
290 let family_query = match normalized_family.as_str() {
291 "sans" | "sansserif" => Family::SansSerif,
292 "serif" => Family::Serif,
293 "mono" | "monospace" => Family::Monospace,
294 "cursive" => Family::Cursive,
295 "fantasy" => Family::Fantasy,
296 _ => Family::Name(family),
297 };
298
299 let query = Query {
300 families: &[family_query],
301 weight: weight.map(fontdb_weight).unwrap_or(if wants_bold {
302 Weight::BOLD
303 } else {
304 Weight::NORMAL
305 }),
306 stretch: Stretch::Normal,
307 style: fontdb_style,
308 };
309 let Some(id) = database.query(&query).or_else(|| {
310 let fallback = Query {
311 families: &[family_query],
312 weight: Weight::NORMAL,
313 stretch: Stretch::Normal,
314 style: FontdbStyle::Normal,
315 };
316 database.query(&fallback)
317 }) else {
318 return windows_known_font_path(family).map(|path| (family.to_owned(), Some(path), None));
319 };
320 let face = database.face(id)?;
321 let resolved_family = face
322 .families
323 .first()
324 .map(|(name, _)| name.clone())
325 .unwrap_or_else(|| family.to_owned());
326 let (path, face_index) = match &face.source {
327 Source::File(path) => (
328 Some(path.clone()),
329 Some(face.index).filter(|index| *index > 0),
330 ),
331 Source::SharedFile(path, _) => (
332 Some(path.clone()),
333 Some(face.index).filter(|index| *index > 0),
334 ),
335 _ => (None, Some(face.index).filter(|index| *index > 0)),
336 };
337 let path = path
338 .or_else(|| windows_known_font_path(&resolved_family))
339 .or_else(|| windows_known_font_path(family));
340 Some((resolved_family, path, face_index))
341}
342
343#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
344pub fn resolve_system_font_for_char(
345 family: &str,
346 style: Option<&str>,
347 character: char,
348) -> Option<(String, Option<PathBuf>, Option<u32>)> {
349 let (path, face_index) = fontconfig_match_path(family, style, None, Some(character))?;
350 if !font_file_supports_char(&path, character) {
351 return None;
352 }
353 let resolved_family = load_face_metadata(&path)
354 .map(|(family, _)| family)
355 .unwrap_or_else(|| family.to_owned());
356 Some((resolved_family, Some(path), face_index))
357}
358
359#[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))]
360pub fn resolve_system_font_for_char(
361 _family: &str,
362 _style: Option<&str>,
363 _character: char,
364) -> Option<(String, Option<PathBuf>, Option<u32>)> {
365 None
366}
367
368pub fn font_match_supports_text(font: &FontMatch, text: &str) -> bool {
369 let Some(path) = &font.path else {
370 return false;
371 };
372 text.chars()
373 .filter(|character| !character.is_whitespace() && !character.is_control())
374 .all(|character| font_file_supports_char(path, character))
375}
376
377pub fn font_file_supports_char(path: &Path, character: char) -> bool {
378 let cache_key = (path.to_path_buf(), character);
379 if let Some(supports_char) = font_char_support_cache()
380 .lock()
381 .expect("font char support cache mutex poisoned")
382 .get(&cache_key)
383 .copied()
384 {
385 return supports_char;
386 }
387
388 let supports_char = font_file_supports_char_uncached(path, character);
389 font_char_support_cache()
390 .lock()
391 .expect("font char support cache mutex poisoned")
392 .insert(cache_key, supports_char);
393 supports_char
394}
395
396fn font_file_supports_char_uncached(path: &Path, character: char) -> bool {
397 let Ok(data) = fs::read(path) else {
398 return false;
399 };
400 let face_count = ttf_parser::fonts_in_collection(&data).unwrap_or(1).max(1);
401 (0..face_count).any(|index| {
402 ttf_parser::Face::parse(&data, index)
403 .ok()
404 .and_then(|face| face.glyph_index(character))
405 .is_some_and(|glyph| glyph.0 != 0)
406 })
407}
408
409#[cfg(windows)]
410fn windows_known_font_path(family: &str) -> Option<PathBuf> {
411 let normalized = normalize_font_key(family);
412 let candidates: &[&str] = match normalized.as_str() {
413 "arial" | "sans" | "sansserif" => &["arial.ttf", "segoeui.ttf"],
414 "segoeui" | "segoe ui" => &["segoeui.ttf"],
415 "timesnewroman" | "times new roman" | "serif" => &["times.ttf"],
416 "couriernew" | "courier new" | "mono" | "monospace" => &["cour.ttf", "consola.ttf"],
417 _ => &[],
418 };
419 let windows_dir = std::env::var_os("WINDIR")
420 .map(PathBuf::from)
421 .unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
422 candidates
423 .iter()
424 .map(|candidate| windows_dir.join("Fonts").join(candidate))
425 .find(|path| path.exists())
426}
427
428#[cfg(all(not(windows), not(target_arch = "wasm32")))]
429fn windows_known_font_path(_family: &str) -> Option<PathBuf> {
430 None
431}
432
433#[cfg(all(unix, not(target_os = "macos")))]
434fn fontconfig_match_path(
435 family: &str,
436 style: Option<&str>,
437 weight: Option<i32>,
438 character: Option<char>,
439) -> Option<(PathBuf, Option<u32>)> {
440 let pattern = fontconfig_pattern(family, style, weight, character);
441 let output = std::process::Command::new("fc-match")
442 .args(["-f", "%{file}\n%{index}", &pattern])
443 .output()
444 .ok()?;
445 if !output.status.success() || output.stdout.is_empty() {
446 return None;
447 }
448 let text = String::from_utf8(output.stdout).ok()?;
449 let mut lines = text.lines();
450 let path = PathBuf::from(lines.next()?.trim());
451 let face_index = lines
452 .next()
453 .and_then(|value| value.trim().parse::<u32>().ok())
454 .filter(|index| *index > 0);
455 path.exists().then_some((path, face_index))
456}
457
458#[cfg(all(unix, not(target_os = "macos")))]
459fn fontconfig_pattern(
460 family: &str,
461 style: Option<&str>,
462 weight: Option<i32>,
463 character: Option<char>,
464) -> String {
465 let mut pattern = family.to_owned();
466 if let Some(style) = style.filter(|value| !value.trim().is_empty()) {
467 let normalized = normalize_font_key(style);
468 if normalized.contains("bold") {
469 pattern.push_str(":weight=bold");
470 }
471 if normalized.contains("italic") || normalized.contains("oblique") {
472 pattern.push_str(":slant=italic");
473 }
474 if !normalized.contains("bold")
475 && !normalized.contains("italic")
476 && !normalized.contains("oblique")
477 {
478 pattern.push_str(":style=");
479 pattern.push_str(style.trim());
480 }
481 }
482 if let Some(weight) = weight {
483 pattern.push_str(":weight=");
484 pattern.push_str(&normalize_weight(weight).to_string());
485 }
486 if let Some(character) = character {
487 pattern.push_str(":charset=");
488 pattern.push_str(&format!("{:x}", character as u32));
489 }
490 pattern
491}
492
493#[cfg(all(unix, not(target_os = "macos")))]
494#[test]
495fn fontconfig_pattern_requests_weight_and_slant_for_bold_italic() {
496 let pattern = fontconfig_pattern("DejaVu Sans", Some("Bold Italic"), None, None);
497
498 assert!(pattern.contains(":weight=bold"));
499 assert!(pattern.contains(":slant=italic"));
500 assert!(!pattern.contains(":style=Bold Italic"));
501}
502
503#[cfg(all(unix, not(target_os = "macos")))]
504#[test]
505fn fontconfig_pattern_preserves_numeric_weight() {
506 let pattern = fontconfig_pattern("DejaVu Sans", None, Some(500), None);
507
508 assert!(pattern.contains(":weight=500"));
509 assert!(!pattern.contains(":weight=bold"));
510}
511
512#[derive(Clone, Debug, Default, PartialEq, Eq)]
513struct AttachedFontRecord {
514 family: String,
515 path: PathBuf,
516 style: Option<String>,
517 aliases: Vec<String>,
518}
519
520#[derive(Clone, Debug, Default, PartialEq, Eq)]
521pub struct AttachedFontProvider {
522 fonts: Vec<AttachedFontRecord>,
523}
524
525impl AttachedFontProvider {
526 pub fn from_attachments(attachments: &[FontAttachment]) -> Self {
527 Self::from_attachments_in_dir(attachments, None::<&Path>)
528 }
529
530 pub fn from_attachments_in_dir(
531 attachments: &[FontAttachment],
532 base_dir: Option<impl AsRef<Path>>,
533 ) -> Self {
534 let root = base_dir
535 .as_ref()
536 .map(|path| path.as_ref().to_path_buf())
537 .unwrap_or_else(|| std::env::temp_dir().join("rassa-attached-fonts"));
538 let _ = fs::create_dir_all(&root);
539 let fonts = attachments
540 .iter()
541 .filter_map(|attachment| AttachedFontRecord::from_attachment(attachment, &root))
542 .collect();
543
544 Self { fonts }
545 }
546}
547
548impl FontProvider for AttachedFontProvider {
549 fn resolve(&self, query: &FontQuery) -> FontMatch {
550 let family_key = normalize_font_key(&query.family);
551 let style_key = query.style.as_deref().map(normalize_font_key);
552
553 let exact = self.fonts.iter().find(|font| {
554 font.aliases.iter().any(|alias| alias == &family_key)
555 && style_key.as_ref().is_none_or(|style| {
556 font.style.as_deref().map(normalize_font_key).as_ref() == Some(style)
557 })
558 });
559 let fallback = self
560 .fonts
561 .iter()
562 .find(|font| font.aliases.iter().any(|alias| alias == &family_key));
563
564 if let Some(font) = exact.or(fallback) {
565 let (synthetic_bold, synthetic_italic) =
566 synthetic_style_flags(query.style.as_deref(), query.weight, font.style.as_deref());
567 return FontMatch {
568 family: font.family.clone(),
569 path: Some(font.path.clone()),
570 face_index: None,
571 style: font.style.clone(),
572 synthetic_bold,
573 synthetic_italic,
574 provider: FontProviderKind::Attached,
575 };
576 }
577
578 FontMatch::unresolved(
579 query.family.clone(),
580 query.style.clone(),
581 FontProviderKind::Attached,
582 )
583 }
584}
585
586pub struct MergedFontProvider<P, S> {
587 primary: P,
588 secondary: S,
589}
590
591impl<P, S> MergedFontProvider<P, S> {
592 pub fn new(primary: P, secondary: S) -> Self {
593 Self { primary, secondary }
594 }
595}
596
597impl<P: FontProvider, S: FontProvider> FontProvider for MergedFontProvider<P, S> {
598 fn resolve(&self, query: &FontQuery) -> FontMatch {
599 let primary = self.primary.resolve(query);
600 if primary.path.is_some() {
601 primary
602 } else {
603 self.secondary.resolve(query)
604 }
605 }
606}
607
608pub struct DefaultFontFileProvider<P> {
609 primary: P,
610 path: PathBuf,
611 family: Option<String>,
612}
613
614impl<P> DefaultFontFileProvider<P> {
615 pub fn new(primary: P, path: impl Into<PathBuf>) -> Self {
616 Self {
617 primary,
618 path: path.into(),
619 family: None,
620 }
621 }
622
623 pub fn with_family(mut self, family: impl Into<String>) -> Self {
624 self.family = Some(family.into());
625 self
626 }
627}
628
629impl<P: FontProvider> FontProvider for DefaultFontFileProvider<P> {
630 fn resolve(&self, query: &FontQuery) -> FontMatch {
631 let primary = self.primary.resolve(query);
632 if primary.path.is_some() {
633 return primary;
634 }
635
636 FontMatch {
637 family: self.family.clone().unwrap_or_else(|| query.family.clone()),
638 path: Some(self.path.clone()),
639 face_index: None,
640 style: query.style.clone(),
641 synthetic_bold: false,
642 synthetic_italic: false,
643 provider: FontProviderKind::DefaultFile,
644 }
645 }
646}
647
648fn synthetic_style_flags(
649 requested: Option<&str>,
650 requested_weight: Option<i32>,
651 resolved: Option<&str>,
652) -> (bool, bool) {
653 let requested = requested.map(normalize_font_key).unwrap_or_default();
654 let resolved = resolved.map(normalize_font_key).unwrap_or_default();
655 (
656 (requested.contains("bold") || requested_weight.is_some_and(bold_weight_is_active))
657 && !resolved.contains("bold"),
658 (requested.contains("italic") || requested.contains("oblique"))
659 && !(resolved.contains("italic") || resolved.contains("oblique")),
660 )
661}
662
663fn normalize_weight(weight: i32) -> i32 {
664 weight.clamp(1, 1000)
665}
666
667#[cfg(not(target_arch = "wasm32"))]
668fn fontdb_weight(weight: i32) -> Weight {
669 Weight(normalize_weight(weight) as u16)
670}
671
672fn bold_weight_is_active(weight: i32) -> bool {
673 weight == 1 || !(0..700).contains(&weight)
674}
675
676impl AttachedFontRecord {
677 fn from_attachment(attachment: &FontAttachment, root: &Path) -> Option<Self> {
678 if attachment.data.is_empty() {
679 return None;
680 }
681
682 let path = materialize_attachment(root, attachment)?;
683 let fallback_name = attachment_file_stem(attachment)
684 .filter(|name| !name.is_empty())
685 .unwrap_or_else(|| attachment.name.clone());
686 let (family, style) =
687 load_face_metadata(&path).unwrap_or_else(|| (fallback_name.clone(), None));
688 let mut aliases = vec![normalize_font_key(&family)];
689 if let Some(stem) = attachment_file_stem(attachment) {
690 aliases.push(normalize_font_key(&stem));
691 }
692 if !attachment.name.is_empty() {
693 aliases.push(normalize_font_key(&attachment.name));
694 }
695 aliases.sort();
696 aliases.dedup();
697
698 Some(Self {
699 family,
700 path,
701 style,
702 aliases,
703 })
704 }
705}
706
707fn materialize_attachment(root: &Path, attachment: &FontAttachment) -> Option<PathBuf> {
708 let mut hasher = DefaultHasher::new();
709 attachment.name.hash(&mut hasher);
710 attachment.data.hash(&mut hasher);
711 let hash = hasher.finish();
712 let sanitized = sanitize_attachment_name(&attachment.name);
713 let path = root.join(format!("{hash:016x}-{sanitized}"));
714 if !path.exists() && fs::write(&path, &attachment.data).is_err() {
715 return None;
716 }
717 Some(path)
718}
719
720fn load_face_metadata(path: &Path) -> Option<(String, Option<String>)> {
721 let data = fs::read(path).ok()?;
722 let face = ttf_parser::Face::parse(&data, 0).ok()?;
723 let family = font_name(&face, ttf_parser::name_id::TYPOGRAPHIC_FAMILY)
724 .or_else(|| font_name(&face, ttf_parser::name_id::FAMILY))?;
725 let style = font_name(&face, ttf_parser::name_id::TYPOGRAPHIC_SUBFAMILY)
726 .or_else(|| font_name(&face, ttf_parser::name_id::SUBFAMILY));
727 Some((family, style))
728}
729
730fn font_name(face: &ttf_parser::Face<'_>, name_id: u16) -> Option<String> {
731 face.names()
732 .into_iter()
733 .find(|name| name.name_id == name_id && name.is_unicode())
734 .and_then(|name| name.to_string())
735 .filter(|name| !name.is_empty())
736}
737
738fn attachment_file_stem(attachment: &FontAttachment) -> Option<String> {
739 Path::new(&attachment.name)
740 .file_stem()
741 .map(|stem| stem.to_string_lossy().into_owned())
742}
743
744fn sanitize_attachment_name(name: &str) -> String {
745 let sanitized = name
746 .chars()
747 .map(|character| match character {
748 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
749 _ => character,
750 })
751 .collect::<String>();
752 if sanitized.is_empty() {
753 "embedded-font.ttf".to_string()
754 } else {
755 sanitized
756 }
757}
758
759fn normalize_font_key(value: &str) -> String {
760 value
761 .chars()
762 .filter(|character| character.is_alphanumeric())
763 .flat_map(|character| character.to_lowercase())
764 .collect()
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn null_provider_returns_unresolved_match() {
773 let provider = NullFontProvider;
774 let result = provider.resolve(&FontQuery::new("Sans"));
775
776 assert_eq!(result.family, "Sans");
777 assert!(result.path.is_none());
778 assert_eq!(result.provider, FontProviderKind::Null);
779 }
780
781 #[test]
782 fn fontconfig_provider_resolves_system_font() {
783 let provider = FontconfigProvider::new();
784 let result = provider.resolve(&FontQuery::new("sans"));
785
786 assert_eq!(result.provider, FontProviderKind::Fontconfig);
787 assert!(result.path.is_some());
788 assert!(result.path.as_ref().is_some_and(|path| path.exists()));
789 }
790
791 #[test]
792 fn fontconfig_provider_caches_identical_resolve_queries() {
793 let provider = FontconfigProvider::new();
794 let query = FontQuery::new("sans");
795
796 assert_eq!(provider.resolve_cache_len_for_tests(), 0);
797 let first = provider.resolve(&query);
798 let cached_entries = provider.resolve_cache_len_for_tests();
799 let second = provider.resolve(&query);
800
801 assert!(cached_entries >= 1);
802 assert_eq!(provider.resolve_cache_len_for_tests(), cached_entries);
803 assert_eq!(second, first);
804 }
805
806 #[test]
807 fn fontconfig_provider_applies_fontconfig_substitutions_for_generic_families() {
808 let expected = std::process::Command::new("fc-match")
809 .args(["-f", "%{file}", "sans"])
810 .output()
811 .expect("fc-match should be available with fontconfig");
812 assert!(expected.status.success());
813 let expected_path = PathBuf::from(String::from_utf8(expected.stdout).expect("utf8 path"));
814
815 let provider = FontconfigProvider::new();
816 let result = provider.resolve(&FontQuery::new("sans"));
817
818 assert_eq!(result.path, Some(expected_path));
819 }
820
821 #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
822 #[test]
823 fn fontconfig_provider_respects_requested_weight_style() {
824 let expected = std::process::Command::new("fc-match")
825 .args(["-f", "%{file}", "DejaVu Sans:style=Bold"])
826 .output()
827 .expect("fc-match should be available with fontconfig");
828 assert!(expected.status.success());
829 let expected_path = PathBuf::from(String::from_utf8(expected.stdout).expect("utf8 path"));
830 if !expected_path.exists()
831 || expected_path
832 .file_name()
833 .is_none_or(|name| !name.to_string_lossy().contains("Bold"))
834 {
835 eprintln!("skipping: system fontconfig has no DejaVu Sans Bold fixture");
836 return;
837 }
838
839 let provider = FontconfigProvider::new();
840 let result = provider.resolve(&FontQuery::new("DejaVu Sans").with_style("Bold"));
841
842 assert_eq!(result.path, Some(expected_path));
843 }
844
845 #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
846 #[test]
847 fn fontconfig_provider_does_not_synthesize_weight_for_real_bold_face() {
848 let expected = std::process::Command::new("fc-match")
849 .args(["-f", "%{file}", "DejaVu Sans:weight=bold"])
850 .output()
851 .expect("fc-match should be available with fontconfig");
852 assert!(expected.status.success());
853 let expected_path = PathBuf::from(String::from_utf8(expected.stdout).expect("utf8 path"));
854 if !expected_path.exists()
855 || load_face_metadata(&expected_path)
856 .and_then(|(_, style)| style)
857 .is_none_or(|style| !normalize_font_key(&style).contains("bold"))
858 {
859 eprintln!("skipping: system fontconfig has no real DejaVu Sans Bold fixture");
860 return;
861 }
862
863 let provider = FontconfigProvider::new();
864 let result = provider.resolve(&FontQuery::new("DejaVu Sans").with_style("Bold"));
865
866 assert_eq!(result.path, Some(expected_path));
867 assert!(!result.synthetic_bold);
868 assert!(!result.synthetic_italic);
869 }
870
871 #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
872 #[test]
873 fn fontconfig_can_resolve_cjk_font_for_character_coverage() {
874 let Some(result) = resolve_system_font_for_char("DejaVu Sans", None, '日') else {
875 eprintln!("skipping: system fontconfig has no CJK-capable fallback font");
876 return;
877 };
878
879 assert!(result.1.as_ref().is_some_and(|path| path.exists()));
880 assert!(font_file_supports_char(result.1.as_ref().unwrap(), '日'));
881 }
882
883 #[test]
884 fn attached_font_provider_resolves_materialized_attachment() {
885 let system = FontconfigProvider::new().resolve(&FontQuery::new("sans"));
886 let path = system.path.expect("system font path should exist");
887 let data = fs::read(&path).expect("font bytes should be readable");
888 let provider = AttachedFontProvider::from_attachments(&[FontAttachment {
889 name: path
890 .file_name()
891 .expect("font filename")
892 .to_string_lossy()
893 .into_owned(),
894 data,
895 }]);
896
897 let result = provider.resolve(&FontQuery::new(&system.family));
898
899 assert_eq!(result.provider, FontProviderKind::Attached);
900 assert!(result.path.is_some());
901 assert!(
902 result
903 .path
904 .as_ref()
905 .is_some_and(|materialized| materialized.exists())
906 );
907 }
908
909 #[test]
910 fn merged_provider_falls_back_to_secondary() {
911 let provider = MergedFontProvider::new(NullFontProvider, FontconfigProvider::new());
912 let result = provider.resolve(&FontQuery::new("sans"));
913
914 assert_eq!(result.provider, FontProviderKind::Fontconfig);
915 assert!(result.path.is_some());
916 }
917
918 #[test]
919 fn default_font_file_provider_falls_back_to_configured_path() {
920 let provider = DefaultFontFileProvider::new(NullFontProvider, "/tmp/default-font.ttf")
921 .with_family("Default");
922 let result = provider.resolve(&FontQuery::new("missing"));
923
924 assert_eq!(result.provider, FontProviderKind::DefaultFile);
925 assert_eq!(result.family, "Default");
926 assert_eq!(result.path, Some(PathBuf::from("/tmp/default-font.ttf")));
927 }
928}