1use crate::theme::FontStyle;
16
17#[derive(Clone, Copy, Debug)]
19pub struct FaceTraits<'a> {
20 pub families: &'a [&'a str],
22 pub width: u16,
24 pub style: FontStyle,
26 pub weight: u16,
28}
29
30#[must_use]
52pub fn select_face(
53 faces: &[FaceTraits<'_>],
54 family: &str,
55 weight: u16,
56 style: FontStyle,
57) -> Option<usize> {
58 let wanted = unicase::UniCase::new(family);
59 let mut candidates: Vec<(usize, &FaceTraits<'_>)> = faces
60 .iter()
61 .enumerate()
62 .filter(|(_, face)| {
63 face.families
64 .iter()
65 .any(|name| unicase::UniCase::new(*name) == wanted)
66 })
67 .collect();
68 if candidates.is_empty() {
69 return None;
70 }
71 keep_best(&mut candidates, |face| width_rank(face.width));
72 keep_best(&mut candidates, |face| style_rank(face.style, style));
73 keep_best(&mut candidates, |face| weight_rank(face.weight, weight));
74 candidates.first().map(|(index, _)| *index)
75}
76
77fn keep_best(
79 candidates: &mut Vec<(usize, &FaceTraits<'_>)>,
80 rank: impl Fn(&FaceTraits<'_>) -> (u8, u16),
81) {
82 if let Some(best) = candidates.iter().map(|(_, face)| rank(face)).min() {
83 candidates.retain(|(_, face)| rank(face) == best);
84 }
85}
86
87fn width_rank(width: u16) -> (u8, u16) {
89 const NORMAL: u16 = 5;
90 match width.cmp(&NORMAL) {
91 std::cmp::Ordering::Equal => (0, 0),
92 std::cmp::Ordering::Less => (1, NORMAL.saturating_sub(width)),
93 std::cmp::Ordering::Greater => (2, width),
94 }
95}
96
97fn style_rank(face: FontStyle, wanted: FontStyle) -> (u8, u16) {
99 let rank = match (wanted, face) {
100 (w, f) if w == f => 0,
101 (FontStyle::Italic, FontStyle::Oblique)
102 | (FontStyle::Oblique, FontStyle::Italic)
103 | (FontStyle::Normal, FontStyle::Oblique) => 1,
104 _ => 2,
105 };
106 (rank, 0)
107}
108
109fn weight_rank(face: u16, wanted: u16) -> (u8, u16) {
111 let below = wanted.saturating_sub(face);
112 match wanted {
113 400..=500 if face >= wanted && face <= 500 => (0, face),
114 400..=500 if face < wanted => (1, below),
115 400..=500 => (2, face),
116 0..=399 if face <= wanted => (0, below),
117 0..=399 => (1, face),
118 _ if face >= wanted => (0, face),
119 _ => (1, below),
120 }
121}
122
123#[must_use]
130pub fn is_macos_system_ui_family(family: &str) -> bool {
131 unicase::UniCase::new(family) == unicase::UniCase::new("SF Pro")
132}
133
134#[cfg(feature = "system-fonts")]
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct SystemFace {
138 pub data: std::sync::Arc<[u8]>,
141 pub index: u32,
143 pub family: std::sync::Arc<str>,
148 pub weight: u16,
150 pub style: FontStyle,
152}
153
154#[cfg(feature = "system-fonts")]
158fn database() -> &'static fontdb::Database {
159 static DB: std::sync::OnceLock<fontdb::Database> = std::sync::OnceLock::new();
160 DB.get_or_init(|| {
161 let mut db = fontdb::Database::new();
162 db.load_system_fonts();
163 db
164 })
165}
166
167#[cfg(feature = "system-fonts")]
168fn style_of(style: fontdb::Style) -> FontStyle {
169 match style {
170 fontdb::Style::Normal => FontStyle::Normal,
171 fontdb::Style::Italic => FontStyle::Italic,
172 fontdb::Style::Oblique => FontStyle::Oblique,
173 }
174}
175
176#[cfg(feature = "system-fonts")]
178fn face_in(
179 db: &fontdb::Database,
180 family: &str,
181 weight: u16,
182 style: FontStyle,
183) -> Option<SystemFace> {
184 let infos: Vec<&fontdb::FaceInfo> = db.faces().collect();
185 let names: Vec<Vec<&str>> = infos
186 .iter()
187 .map(|info| {
188 info.families
189 .iter()
190 .map(|(name, _)| name.as_str())
191 .collect()
192 })
193 .collect();
194 let traits: Vec<FaceTraits<'_>> = infos
195 .iter()
196 .zip(&names)
197 .map(|(info, names)| FaceTraits {
198 families: names,
199 width: info.stretch.to_number(),
200 style: style_of(info.style),
201 weight: info.weight.0,
202 })
203 .collect();
204 let chosen = select_face(&traits, family, weight, style)?;
205 let info = infos.get(chosen)?;
206 let (data, index) = db.with_face_data(info.id, |data, index| {
207 (std::sync::Arc::<[u8]>::from(data), index)
208 })?;
209 let (recorded, _) = info.families.first()?;
210 Some(SystemFace {
211 data,
212 index,
213 family: std::sync::Arc::from(recorded.as_str()),
214 weight: info.weight.0,
215 style: style_of(info.style),
216 })
217}
218
219#[cfg(feature = "system-fonts")]
239#[must_use]
240pub fn system_face(family: &str, weight: u16, style: FontStyle) -> Option<SystemFace> {
241 #[cfg(target_os = "macos")]
242 {
243 let italic = matches!(style, FontStyle::Italic | FontStyle::Oblique);
244 let ui = crate::macos::system_ui_font(italic);
245 let names_it = is_macos_system_ui_family(family)
246 || ui.as_ref().is_some_and(|font| {
247 unicase::UniCase::new(family) == unicase::UniCase::new(font.family.as_str())
248 });
249 if names_it {
250 return ui.and_then(|font| face_in_file(&font.path, weight, style));
251 }
252 }
253 face_in(database(), family, weight, style)
254}
255
256#[cfg(all(feature = "system-fonts", target_os = "macos"))]
259fn face_in_file(path: &std::path::Path, weight: u16, style: FontStyle) -> Option<SystemFace> {
260 let mut db = fontdb::Database::new();
261 db.load_font_file(path).ok()?;
262 let recorded = db.faces().next()?.families.first()?.0.clone();
263 face_in(&db, &recorded, weight, style)
264}
265
266#[cfg(feature = "system-fonts")]
288#[must_use]
289pub fn substitute_family(family: &str) -> Option<String> {
290 platform_substitute(family)
291}
292
293#[cfg(all(
294 feature = "system-fonts",
295 unix,
296 not(target_os = "macos"),
297 not(target_os = "ios"),
298 not(target_os = "android")
299))]
300fn platform_substitute(family: &str) -> Option<String> {
301 let output = std::process::Command::new("fc-match")
302 .arg("--format=%{family[0]}")
303 .arg(fontconfig_family(family))
304 .output()
305 .ok()?;
306 if !output.status.success() {
307 return None;
308 }
309 let printed = String::from_utf8(output.stdout).ok()?;
310 let matched = printed.trim();
311 if matched.is_empty() || unicase::UniCase::new(matched) == unicase::UniCase::new(family) {
312 return None;
313 }
314 Some(matched.to_owned())
315}
316
317#[cfg(all(
318 feature = "system-fonts",
319 not(all(
320 unix,
321 not(target_os = "macos"),
322 not(target_os = "ios"),
323 not(target_os = "android")
324 ))
325))]
326fn platform_substitute(_family: &str) -> Option<String> {
327 None
328}
329
330#[cfg(all(
337 feature = "system-fonts",
338 unix,
339 not(target_os = "macos"),
340 not(target_os = "ios"),
341 not(target_os = "android")
342))]
343fn fontconfig_family(family: &str) -> String {
344 let mut pattern = String::with_capacity(family.len());
345 for c in family.chars() {
346 if matches!(c, '\\' | '-' | ':' | ',') {
347 pattern.push('\\');
348 }
349 pattern.push(c);
350 }
351 pattern
352}
353
354#[cfg(test)]
355#[allow(
356 clippy::unwrap_used,
357 clippy::expect_used,
358 clippy::panic,
359 clippy::indexing_slicing,
360 reason = "a test fails by panicking"
361)]
362mod tests {
363 use super::*;
364
365 const N: FontStyle = FontStyle::Normal;
366 const I: FontStyle = FontStyle::Italic;
367 const O: FontStyle = FontStyle::Oblique;
368
369 fn face(
370 families: &'static [&'static str],
371 width: u16,
372 style: FontStyle,
373 weight: u16,
374 ) -> FaceTraits<'static> {
375 FaceTraits {
376 families,
377 width,
378 style,
379 weight,
380 }
381 }
382
383 #[test]
387 fn the_family_is_matched_caselessly_and_never_substituted() {
388 let faces = [face(&["Inter"], 5, N, 400), face(&["Roboto"], 5, N, 400)];
389 assert_eq!(select_face(&faces, "inter", 400, N), Some(0));
390 assert_eq!(select_face(&faces, "INTER", 400, N), Some(0));
391 assert_eq!(select_face(&faces, "Helvetica", 400, N), None);
392 assert_eq!(select_face(&[], "Inter", 400, N), None);
393 }
394
395 #[test]
397 fn any_recorded_family_name_matches() {
398 let faces = [face(&["Segoe UI", "Segoe UI Regular"], 5, N, 400)];
399 assert_eq!(select_face(&faces, "segoe ui regular", 400, N), Some(0));
400 }
401
402 #[test]
405 fn width_prefers_normal_then_narrower_descending_then_wider_ascending() {
406 let normal = [
407 face(&["F"], 3, N, 400),
408 face(&["F"], 7, N, 400),
409 face(&["F"], 5, N, 400),
410 ];
411 assert_eq!(select_face(&normal, "F", 400, N), Some(2));
412 let narrow = [
413 face(&["F"], 2, N, 400),
414 face(&["F"], 7, N, 400),
415 face(&["F"], 3, N, 400),
416 ];
417 assert_eq!(select_face(&narrow, "F", 400, N), Some(2));
418 let wide = [face(&["F"], 8, N, 400), face(&["F"], 6, N, 400)];
419 assert_eq!(select_face(&wide, "F", 400, N), Some(1));
420 }
421
422 #[test]
425 fn style_falls_back_in_the_css_order() {
426 let no_italic = [face(&["F"], 5, N, 400), face(&["F"], 5, O, 400)];
427 assert_eq!(select_face(&no_italic, "F", 400, I), Some(1));
428 let no_oblique = [face(&["F"], 5, N, 400), face(&["F"], 5, I, 400)];
429 assert_eq!(select_face(&no_oblique, "F", 400, O), Some(1));
430 let no_normal = [face(&["F"], 5, I, 400), face(&["F"], 5, O, 400)];
431 assert_eq!(select_face(&no_normal, "F", 400, N), Some(1));
432 let exact = [face(&["F"], 5, O, 400), face(&["F"], 5, I, 400)];
433 assert_eq!(select_face(&exact, "F", 400, I), Some(1));
434 }
435
436 #[test]
442 fn weight_between_400_and_500_climbs_to_500_first() {
443 let faces = [face(&["F"], 5, N, 300), face(&["F"], 5, N, 450)];
444 assert_eq!(select_face(&faces, "F", 400, N), Some(1));
445 let faces = [face(&["F"], 5, N, 600), face(&["F"], 5, N, 300)];
446 assert_eq!(select_face(&faces, "F", 400, N), Some(1));
447 let faces = [face(&["F"], 5, N, 400), face(&["F"], 5, N, 500)];
448 assert_eq!(select_face(&faces, "F", 450, N), Some(1));
449 let faces = [face(&["F"], 5, N, 700), face(&["F"], 5, N, 400)];
450 assert_eq!(select_face(&faces, "F", 500, N), Some(1));
451 }
452
453 #[test]
456 fn weight_below_400_descends_first() {
457 let faces = [face(&["F"], 5, N, 350), face(&["F"], 5, N, 200)];
458 assert_eq!(select_face(&faces, "F", 300, N), Some(1));
459 let faces = [face(&["F"], 5, N, 700), face(&["F"], 5, N, 350)];
460 assert_eq!(select_face(&faces, "F", 300, N), Some(1));
461 }
462
463 #[test]
466 fn weight_above_500_ascends_first() {
467 let faces = [face(&["F"], 5, N, 600), face(&["F"], 5, N, 800)];
468 assert_eq!(select_face(&faces, "F", 700, N), Some(1));
469 let faces = [face(&["F"], 5, N, 500), face(&["F"], 5, N, 600)];
470 assert_eq!(select_face(&faces, "F", 700, N), Some(1));
471 }
472
473 #[test]
475 fn width_outranks_style_which_outranks_weight() {
476 let faces = [
477 face(&["F"], 3, N, 400),
478 face(&["F"], 5, I, 700),
479 face(&["F"], 5, I, 400),
480 ];
481 assert_eq!(select_face(&faces, "F", 400, N), Some(2));
482 let tie = [face(&["F"], 5, N, 400), face(&["F"], 5, N, 400)];
483 assert_eq!(select_face(&tie, "F", 400, N), Some(0));
484 }
485
486 #[test]
489 fn the_macos_system_ui_family_is_sf_pro_caselessly() {
490 assert!(is_macos_system_ui_family("SF Pro"));
491 assert!(is_macos_system_ui_family("sf pro"));
492 assert!(!is_macos_system_ui_family("SF Mono"));
493 assert!(!is_macos_system_ui_family("SF Pro Text"));
494 assert!(!is_macos_system_ui_family("Inter"));
495 }
496
497 #[cfg(all(
501 feature = "system-fonts",
502 unix,
503 not(target_os = "macos"),
504 not(target_os = "ios"),
505 not(target_os = "android")
506 ))]
507 #[test]
508 fn a_fontconfig_family_escapes_the_name_syntax_specials() {
509 assert_eq!(fontconfig_family("JetBrains Mono"), "JetBrains Mono");
510 assert_eq!(fontconfig_family("sans-serif"), r"sans\-serif");
511 assert_eq!(fontconfig_family(r"a\b:c,d-e"), r"a\\b\:c\,d\-e");
512 assert_eq!(fontconfig_family(r"\\"), r"\\\\");
513 assert_eq!(fontconfig_family(""), "");
514 assert_eq!(
515 fontconfig_family("Noto Sans CJK 日本"),
516 "Noto Sans CJK 日本"
517 );
518 }
519
520 #[cfg(feature = "system-fonts")]
521 mod system {
522 use super::super::*;
523
524 #[test]
529 fn a_missing_family_has_a_substitute_the_system_has() {
530 let missing = format!("native-theme-no-such-family-{}", std::process::id());
531 let substitute = substitute_family(&missing);
532 let fontconfig_platform = cfg!(all(
533 unix,
534 not(target_os = "macos"),
535 not(target_os = "ios"),
536 not(target_os = "android")
537 ));
538 let fc_match_runs = std::process::Command::new("fc-match")
539 .arg("--version")
540 .output()
541 .is_ok_and(|output| output.status.success());
542 if !(fontconfig_platform && fc_match_runs) {
543 println!(
544 "a_missing_family_has_a_substitute_the_system_has: fc-match not run here, \
545 substitute {substitute:?}"
546 );
547 assert_eq!(substitute, None);
548 return;
549 }
550 let substitute = substitute.expect("fc-match names a substitute");
551 assert!(
552 system_face(&substitute, 400, FontStyle::Normal).is_some(),
553 "the substitute {substitute:?} has a system face"
554 );
555 assert_eq!(substitute_family(&substitute), None);
556 }
557
558 #[test]
560 fn a_family_no_system_has_is_none() {
561 assert_eq!(
562 system_face("native-theme-no-such-family-7f3c1a", 400, FontStyle::Normal),
563 None
564 );
565 }
566
567 #[test]
573 fn a_family_matches_caselessly_and_reports_its_recorded_name() {
574 let db = database();
575 let (recorded, weight, style) = db
576 .faces()
577 .find_map(|info| {
578 let (name, _) = info.families.first()?;
579 name.chars()
580 .any(char::is_lowercase)
581 .then(|| (name.clone(), info.weight.0, style_of(info.style)))
582 })
583 .expect("the font database holds a face whose family has a lower-case letter");
584 let asked = recorded.to_uppercase();
585 assert_ne!(asked, recorded);
586 let face = system_face(&asked, weight, style)
587 .expect("the upper-cased family name finds the face");
588 assert_ne!(
589 face.family.as_ref(),
590 asked.as_str(),
591 "not the spelling asked for"
592 );
593 let recorded_by_a_face_of_that_family = db.faces().any(|info| {
594 info.families
595 .first()
596 .is_some_and(|(first, _)| **first == *face.family)
597 && info.families.iter().any(|(name, _)| {
598 unicase::UniCase::new(name.as_str())
599 == unicase::UniCase::new(recorded.as_str())
600 })
601 });
602 assert!(
603 recorded_by_a_face_of_that_family,
604 "the family fontdb records for the face"
605 );
606 }
607
608 #[test]
610 fn the_same_query_gives_the_same_face() {
611 let info = database()
612 .faces()
613 .next()
614 .expect("the font database holds a face");
615 let (name, _) = info.families.first().expect("a face records a family");
616 let a = system_face(name, info.weight.0, style_of(info.style));
617 let b = system_face(name, info.weight.0, style_of(info.style));
618 assert!(a.is_some());
619 assert_eq!(a, b);
620 }
621
622 #[cfg(target_os = "macos")]
630 #[test]
631 fn the_macos_system_ui_font_is_found_by_its_file() {
632 let ui = crate::macos::system_ui_font(false)
633 .expect("Core Text names the system UI font's file");
634 let expected = {
635 let mut db = fontdb::Database::new();
636 db.load_font_file(&ui.path)
637 .expect("the system UI font's file loads");
638 db.faces()
639 .next()
640 .and_then(|face| face.families.first().map(|(name, _)| name.clone()))
641 .expect("the file records a family")
642 };
643 for asked in ["SF Pro", ui.family.as_str()] {
644 let face = system_face(asked, 400, FontStyle::Normal)
645 .unwrap_or_else(|| panic!("{asked} resolves through Core Text"));
646 assert_eq!(face.family.as_ref(), expected.as_str());
647 }
648 }
649 }
650}