Skip to main content

rassa_fonts/
lib.rs

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}