1use std::{
21 io::Read,
22 path::{Path, PathBuf},
23 sync::Arc,
24};
25
26use cranpose_ui::text::{FontFamily, FontFile, FontStyle, FontWeight};
27
28use crate::software_text_raster::{
29 FontFamilyKey, SoftwareTextFont, SoftwareTextFontError, SoftwareTextFontSet,
30 default_software_text_font,
31};
32
33pub const ANDROID_SYSTEM_FONT_DIR: &str = "/system/fonts";
35
36pub const DEFAULT_SYSTEM_FAMILY_WEIGHTS: &[FontWeight] =
39 &[FontWeight::NORMAL, FontWeight::MEDIUM, FontWeight::BOLD];
40
41#[derive(Debug, thiserror::Error)]
47pub enum FontLoadError {
48 #[error("font family declares no faces")]
49 EmptyFamily,
50 #[error("font family is not backed by files, so it has nothing to load")]
51 NotFileBacked,
52 #[error("no system font file for this family under {directory}")]
53 NoSystemFontFile { directory: PathBuf },
54 #[error("failed to read font file {path}: {source}")]
55 Read {
56 path: PathBuf,
57 #[source]
58 source: std::io::Error,
59 },
60 #[error("failed to parse font file {path}: {source}")]
61 Parse {
62 path: PathBuf,
63 #[source]
64 source: SoftwareTextFontError,
65 },
66 #[error("failed to parse font bytes: {source}")]
67 ParseBytes {
68 #[source]
69 source: SoftwareTextFontError,
70 },
71}
72
73#[derive(Clone, Default)]
79pub struct SoftwareTextFontRegistry {
80 faces: Vec<SoftwareTextFont>,
81 system_faces: Vec<(FontFamilyKey, FontWeight, FontStyle)>,
82}
83
84#[derive(Default)]
85struct TolerantLoad {
86 first_error: Option<FontLoadError>,
87 loaded: usize,
88}
89
90impl TolerantLoad {
91 fn record(&mut self, result: Result<(), FontLoadError>) {
92 match result {
93 Ok(()) => self.loaded += 1,
94 Err(error) => self.first_error = self.first_error.take().or(Some(error)),
95 }
96 }
97
98 fn finish(self) -> Result<(), FontLoadError> {
99 match self.first_error {
100 Some(error) if self.loaded == 0 => Err(error),
101 _ => Ok(()),
102 }
103 }
104}
105
106impl SoftwareTextFontRegistry {
107 pub fn new() -> Self {
108 Self::default()
109 }
110
111 pub fn register_family(&mut self, family: &FontFamily) -> Result<(), FontLoadError> {
121 let files = font_files_for(family)?;
122 if files.is_empty() {
123 return Err(FontLoadError::EmptyFamily);
124 }
125
126 let mut reads = FontFileReads::default();
127 let mut load = TolerantLoad::default();
128 for file in &files {
129 load.record(self.register_read_face(
130 &mut reads,
131 family,
132 file.weight,
133 file.style,
134 Path::new(&file.path),
135 &[],
136 ));
137 }
138 load.finish()
139 }
140
141 fn register_read_face(
142 &mut self,
143 reads: &mut FontFileReads,
144 family: &FontFamily,
145 weight: FontWeight,
146 style: FontStyle,
147 path: &Path,
148 variations: &[([u8; 4], f32)],
149 ) -> Result<(), FontLoadError> {
150 let bytes = reads.read(path)?;
151 let face = SoftwareTextFont::from_registered_bytes_with_variations(
152 family,
153 weight,
154 style,
155 bytes.to_vec(),
156 variations,
157 )
158 .map_err(|source| FontLoadError::Parse {
159 path: path.to_path_buf(),
160 source,
161 })?;
162 self.faces.push(face);
163 Ok(())
164 }
165
166 pub fn register_face_reader(
173 &mut self,
174 family: &FontFamily,
175 weight: FontWeight,
176 style: FontStyle,
177 reader: &mut impl Read,
178 ) -> Result<(), FontLoadError> {
179 let mut bytes = Vec::new();
180 reader
181 .read_to_end(&mut bytes)
182 .map_err(|source| FontLoadError::Read {
183 path: PathBuf::new(),
184 source,
185 })?;
186 self.register_face_bytes(family, weight, style, bytes)
187 }
188
189 pub fn register_face_bytes(
191 &mut self,
192 family: &FontFamily,
193 weight: FontWeight,
194 style: FontStyle,
195 bytes: impl Into<Vec<u8>>,
196 ) -> Result<(), FontLoadError> {
197 self.register_face_bytes_with_variations(family, weight, style, bytes, &[])
198 }
199
200 pub fn register_face_bytes_with_variations(
203 &mut self,
204 family: &FontFamily,
205 weight: FontWeight,
206 style: FontStyle,
207 bytes: impl Into<Vec<u8>>,
208 variations: &[([u8; 4], f32)],
209 ) -> Result<(), FontLoadError> {
210 let face = SoftwareTextFont::from_registered_bytes_with_variations(
211 family, weight, style, bytes, variations,
212 )
213 .map_err(|source| FontLoadError::ParseBytes { source })?;
214 self.faces.push(face);
215 Ok(())
216 }
217
218 pub fn register_fallback_bytes(
223 &mut self,
224 bytes: impl Into<Vec<u8>>,
225 ) -> Result<(), FontLoadError> {
226 let face = SoftwareTextFont::from_bytes(bytes)
227 .map_err(|source| FontLoadError::ParseBytes { source })?;
228 self.faces.push(face);
229 Ok(())
230 }
231
232 pub fn register_system_family(
249 &mut self,
250 directory: impl AsRef<Path>,
251 family: &FontFamily,
252 weights: &[FontWeight],
253 ) -> Result<(), FontLoadError> {
254 let directory = directory.as_ref();
255 let mut reads = FontFileReads::default();
256 let mut load = TolerantLoad::default();
257 for weight in weights {
258 load.record(self.register_read_system_face(
259 &mut reads,
260 directory,
261 family,
262 *weight,
263 FontStyle::Normal,
264 &[],
265 ));
266 }
267 load.finish()
268 }
269
270 pub fn register_system_face(
278 &mut self,
279 directory: impl AsRef<Path>,
280 family: &FontFamily,
281 weight: FontWeight,
282 style: FontStyle,
283 ) -> Result<(), FontLoadError> {
284 self.register_system_face_with_variations(directory, family, weight, style, &[])
285 }
286
287 pub fn register_system_face_with_variations(
291 &mut self,
292 directory: impl AsRef<Path>,
293 family: &FontFamily,
294 weight: FontWeight,
295 style: FontStyle,
296 variations: &[([u8; 4], f32)],
297 ) -> Result<(), FontLoadError> {
298 self.register_read_system_face(
299 &mut FontFileReads::default(),
300 directory.as_ref(),
301 family,
302 weight,
303 style,
304 variations,
305 )
306 }
307
308 fn register_read_system_face(
309 &mut self,
310 reads: &mut FontFileReads,
311 directory: &Path,
312 family: &FontFamily,
313 weight: FontWeight,
314 style: FontStyle,
315 variations: &[([u8; 4], f32)],
316 ) -> Result<(), FontLoadError> {
317 let weight = system_declared_weight(family, weight);
318 if self.has_system_face(family, weight, style) {
319 return Ok(());
320 }
321 let path = system_font_file(directory, family, weight).ok_or_else(|| {
322 FontLoadError::NoSystemFontFile {
323 directory: directory.to_path_buf(),
324 }
325 })?;
326 self.register_read_face(reads, family, weight, style, &path, variations)?;
327 self.system_faces
328 .push((FontFamilyKey::of(family), weight, style));
329 Ok(())
330 }
331
332 fn has_system_face(&self, family: &FontFamily, weight: FontWeight, style: FontStyle) -> bool {
333 self.system_faces
334 .contains(&(FontFamilyKey::of(family), weight, style))
335 }
336
337 pub fn faces(&self) -> &[SoftwareTextFont] {
339 &self.faces
340 }
341
342 pub fn is_empty(&self) -> bool {
343 self.faces.is_empty()
344 }
345
346 pub fn into_font_set_or_default(mut self, fonts: &[&[u8]]) -> SoftwareTextFontSet {
354 for bytes in fonts {
355 let _ = self.register_fallback_bytes((*bytes).to_vec());
356 }
357 if self.faces.is_empty()
358 && let Some(default_font) = default_software_text_font()
359 {
360 self.faces.push(default_font);
361 }
362 SoftwareTextFontSet::from_faces(self.faces)
363 }
364}
365
366#[derive(Default)]
367struct FontFileReads {
368 entries: Vec<(PathBuf, Arc<[u8]>)>,
369}
370
371impl FontFileReads {
372 fn read(&mut self, path: &Path) -> Result<Arc<[u8]>, FontLoadError> {
373 if let Some((_, bytes)) = self.entries.iter().find(|(read, _)| read == path) {
374 return Ok(Arc::clone(bytes));
375 }
376 let bytes: Arc<[u8]> = std::fs::read(path)
377 .map_err(|source| FontLoadError::Read {
378 path: path.to_path_buf(),
379 source,
380 })?
381 .into();
382 self.entries.push((path.to_path_buf(), Arc::clone(&bytes)));
383 Ok(bytes)
384 }
385}
386
387pub fn system_declared_weight(family: &FontFamily, weight: FontWeight) -> FontWeight {
431 let Some(files) = system_family_files(family) else {
432 return weight;
433 };
434 closest_declared_weight(files.declared, weight).unwrap_or(weight)
435}
436
437fn closest_declared_weight(declared: &[u16], requested: FontWeight) -> Option<FontWeight> {
438 let mut best: Option<(u16, u16)> = None;
439 for candidate in declared {
440 let score = weight_match_score(*candidate, requested.value());
441 if best.is_none_or(|(_, best_score)| score < best_score) {
442 best = Some((*candidate, score));
443 }
444 }
445 best.map(|(candidate, _)| FontWeight(candidate))
446}
447
448fn weight_match_score(declared: u16, requested: u16) -> u16 {
449 (declared / 100).abs_diff(requested / 100)
450}
451
452pub fn system_font_file(
459 directory: &Path,
460 family: &FontFamily,
461 weight: FontWeight,
462) -> Option<PathBuf> {
463 let files = system_family_files(family)?;
464 files
465 .weighted
466 .iter()
467 .filter(|(candidate_weight, _)| *candidate_weight == weight.value())
468 .map(|(_, name)| directory.join(name))
469 .chain(files.regular.iter().map(|name| directory.join(name)))
470 .find(|path| path.is_file())
471}
472
473struct SystemFamilyFiles {
474 regular: &'static [&'static str],
475 weighted: &'static [(u16, &'static str)],
476 declared: &'static [u16],
477}
478
479const DECLARED_HUNDREDS: &[u16] = &[100, 200, 300, 400, 500, 600, 700, 800, 900];
480
481fn system_family_files(family: &FontFamily) -> Option<SystemFamilyFiles> {
482 match family {
483 FontFamily::Default | FontFamily::SansSerif => Some(SystemFamilyFiles {
484 regular: &[
485 "Roboto-Regular.ttf",
486 "RobotoStatic-Regular.ttf",
487 "NotoSans-Regular.ttf",
488 "DroidSans.ttf",
489 "Core/SFUI.ttf",
490 "SFNS.ttf",
491 ],
492 weighted: &[
493 (300, "Roboto-Light.ttf"),
494 (500, "Roboto-Medium.ttf"),
495 (700, "Roboto-Bold.ttf"),
496 (900, "Roboto-Black.ttf"),
497 ],
498 declared: DECLARED_HUNDREDS,
499 }),
500 FontFamily::Serif | FontFamily::Fantasy => Some(SystemFamilyFiles {
501 regular: &["NotoSerif-Regular.ttf", "DroidSerif-Regular.ttf"],
502 weighted: &[(700, "NotoSerif-Bold.ttf"), (700, "DroidSerif-Bold.ttf")],
503 declared: &[400, 700],
504 }),
505 FontFamily::Monospace => Some(SystemFamilyFiles {
506 regular: &[
507 "DroidSansMono.ttf",
508 "RobotoMono-Regular.ttf",
509 "CutiveMono-Regular.ttf",
510 ],
511 weighted: &[(700, "RobotoMono-Bold.ttf")],
512 declared: &[400, 700],
513 }),
514 FontFamily::Cursive => Some(SystemFamilyFiles {
515 regular: &["DancingScript-Regular.ttf"],
516 weighted: &[(700, "DancingScript-Bold.ttf")],
517 declared: &[400, 700],
518 }),
519 FontFamily::Named(_) | FontFamily::FileBacked(_) | FontFamily::LoadedTypeface(_) => None,
520 }
521}
522
523fn font_files_for(family: &FontFamily) -> Result<Vec<FontFile>, FontLoadError> {
524 match family {
525 FontFamily::FileBacked(file_backed) => Ok(file_backed.fonts.clone()),
526 FontFamily::LoadedTypeface(typeface) => Ok(vec![FontFile::new(typeface.path.clone())]),
527 _ => Err(FontLoadError::NotFileBacked),
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use std::io::Cursor;
534
535 use cranpose_ui::text::{SpanStyle, TextStyle};
536
537 use super::*;
538
539 const REGULAR: &[u8] = include_bytes!("../assets/NotoSansMerged.ttf");
540 const BOLD: &[u8] = include_bytes!("../assets/NotoSansBold.ttf");
541
542 fn style_for(family: &FontFamily, weight: FontWeight) -> TextStyle {
543 TextStyle {
544 span_style: SpanStyle {
545 font_family: Some(family.clone()),
546 font_weight: Some(weight),
547 ..Default::default()
548 },
549 ..Default::default()
550 }
551 }
552
553 struct ScratchDir(PathBuf);
554
555 impl ScratchDir {
556 fn new(name: &str) -> Self {
557 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
558 .join("../../../target/test-output/cranpose-font-source")
559 .join(name);
560 let _ = std::fs::remove_dir_all(&path);
561 std::fs::create_dir_all(&path).expect("scratch directory");
562 Self(path)
563 }
564
565 fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
566 let path = self.0.join(name);
567 std::fs::write(&path, bytes).expect("scratch font file");
568 path
569 }
570
571 fn path(&self) -> &Path {
572 &self.0
573 }
574 }
575
576 impl Drop for ScratchDir {
577 fn drop(&mut self) {
578 let _ = std::fs::remove_dir_all(&self.0);
579 }
580 }
581
582 #[test]
583 fn register_family_picks_the_face_matching_the_requested_weight() {
584 let dir = ScratchDir::new("weights");
585 let regular = dir.write("Test-Regular.ttf", REGULAR);
586 let bold = dir.write("Test-Bold.ttf", BOLD);
587 let family = FontFamily::file_backed(vec![
588 FontFile::new(regular.to_string_lossy().into_owned()),
589 FontFile::new(bold.to_string_lossy().into_owned()).with_weight(FontWeight::BOLD),
590 ])
591 .expect("file-backed family");
592
593 let mut registry = SoftwareTextFontRegistry::new();
594 registry.register_family(&family).expect("family loads");
595 let fonts = registry.into_font_set_or_default(&[]);
596
597 let resolved_regular = fonts
598 .resolve(&style_for(&family, FontWeight::NORMAL))
599 .expect("regular face");
600 let resolved_bold = fonts
601 .resolve(&style_for(&family, FontWeight::BOLD))
602 .expect("bold face");
603
604 assert_eq!(resolved_regular.weight(), FontWeight::NORMAL);
605 assert_eq!(resolved_bold.weight(), FontWeight::BOLD);
606 assert_ne!(
607 resolved_regular.content_hash(),
608 resolved_bold.content_hash(),
609 "distinct faces must key the glyph atlas distinctly"
610 );
611 }
612
613 #[test]
614 fn register_family_honours_a_declared_weight_over_the_face_header() {
615 let dir = ScratchDir::new("declared");
616 let path = dir.write("Test-Regular.ttf", REGULAR);
617 let family = FontFamily::file_backed(vec![
618 FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight::MEDIUM),
619 ])
620 .expect("file-backed family");
621
622 let mut registry = SoftwareTextFontRegistry::new();
623 registry.register_family(&family).expect("family loads");
624 let fonts = registry.into_font_set_or_default(&[]);
625
626 let resolved = fonts
627 .resolve(&style_for(&family, FontWeight::MEDIUM))
628 .expect("declared face");
629 assert_eq!(resolved.weight(), FontWeight::MEDIUM);
630 }
631
632 #[test]
633 fn register_family_reports_a_missing_file_without_panicking() {
634 let dir = ScratchDir::new("missing");
635 let family = FontFamily::file_backed(vec![FontFile::new(
636 dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
637 )])
638 .expect("file-backed family");
639
640 let mut registry = SoftwareTextFontRegistry::new();
641 let error = registry
642 .register_family(&family)
643 .expect_err("a missing file cannot register");
644 assert!(matches!(error, FontLoadError::Read { .. }), "{error}");
645 assert!(registry.is_empty());
646 }
647
648 #[test]
649 fn register_family_reports_a_corrupt_file_without_panicking() {
650 let dir = ScratchDir::new("corrupt");
651 let path = dir.write("Corrupt.ttf", b"this is not a font");
652 let family =
653 FontFamily::file_backed(vec![FontFile::new(path.to_string_lossy().into_owned())])
654 .expect("file-backed family");
655
656 let mut registry = SoftwareTextFontRegistry::new();
657 let error = registry
658 .register_family(&family)
659 .expect_err("a corrupt file cannot register");
660 assert!(matches!(error, FontLoadError::Parse { .. }), "{error}");
661 assert!(registry.is_empty());
662 }
663
664 #[test]
665 #[cfg(feature = "embedded-default-font")]
666 fn a_family_that_failed_to_load_falls_back_to_the_default_face() {
667 let dir = ScratchDir::new("fallback");
668 let family = FontFamily::file_backed(vec![FontFile::new(
669 dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
670 )])
671 .expect("file-backed family");
672
673 let mut registry = SoftwareTextFontRegistry::new();
674 let _ = registry.register_family(&family);
675 let fonts = registry.into_font_set_or_default(&[]);
676
677 let resolved = fonts
678 .resolve(&style_for(&family, FontWeight::NORMAL))
679 .expect("fallback face");
680 assert_eq!(
681 resolved.content_hash(),
682 fonts.default_font().expect("default face").content_hash()
683 );
684 }
685
686 #[test]
687 fn a_partly_loadable_family_keeps_the_faces_that_did_load() {
688 let dir = ScratchDir::new("partial");
689 let regular = dir.write("Test-Regular.ttf", REGULAR);
690 let family = FontFamily::file_backed(vec![
691 FontFile::new(regular.to_string_lossy().into_owned()),
692 FontFile::new(dir.path().join("Absent.ttf").to_string_lossy().into_owned())
693 .with_weight(FontWeight::BOLD),
694 ])
695 .expect("file-backed family");
696
697 let mut registry = SoftwareTextFontRegistry::new();
698 registry
699 .register_family(&family)
700 .expect("one readable face is enough");
701 assert_eq!(registry.faces().len(), 1);
702 }
703
704 #[test]
705 fn register_face_reader_accepts_a_font_that_is_not_a_file() {
706 let family = FontFamily::named("Bundled");
707 let mut registry = SoftwareTextFontRegistry::new();
708 registry
709 .register_face_reader(
710 &family,
711 FontWeight::NORMAL,
712 FontStyle::Normal,
713 &mut Cursor::new(REGULAR.to_vec()),
714 )
715 .expect("streamed face loads");
716
717 let fonts = registry.into_font_set_or_default(&[]);
718 let resolved = fonts
719 .resolve(&style_for(&family, FontWeight::NORMAL))
720 .expect("streamed face");
721 assert_eq!(resolved.registered_family(), {
722 let mut expected = SoftwareTextFontRegistry::new();
723 expected
724 .register_face_bytes(
725 &family,
726 FontWeight::NORMAL,
727 FontStyle::Normal,
728 REGULAR.to_vec(),
729 )
730 .expect("face loads");
731 expected.faces()[0].registered_family()
732 });
733 }
734
735 #[test]
736 #[cfg(feature = "embedded-default-font")]
737 fn an_empty_registry_falls_back_to_the_embedded_default_face() {
738 let fonts = SoftwareTextFontRegistry::new().into_font_set_or_default(&[]);
739 assert!(
740 fonts.default_font().is_some(),
741 "the embedded default font must still serve apps that supply nothing"
742 );
743 assert!(
744 fonts
745 .resolve(&TextStyle::default())
746 .is_some_and(|font| font.registered_family().is_none())
747 );
748 }
749
750 #[test]
751 fn system_sans_resolves_the_apple_variable_face() {
752 let directory = ScratchDir::new("apple-system-sans");
753 let core = directory.path().join("Core");
754 std::fs::create_dir(&core).expect("Core directory");
755 let path = core.join("SFUI.ttf");
756 std::fs::write(&path, []).expect("system font");
757 for weight in [FontWeight::NORMAL, FontWeight::MEDIUM, FontWeight::BOLD] {
758 assert_eq!(
759 system_font_file(directory.path(), &FontFamily::SansSerif, weight),
760 Some(path.clone())
761 );
762 }
763 assert_eq!(
764 system_font_file(directory.path(), &FontFamily::Serif, FontWeight::NORMAL),
765 None
766 );
767 }
768
769 #[test]
770 fn system_font_file_prefers_a_weight_specific_static_face() {
771 let dir = ScratchDir::new("system-static");
772 dir.write("Roboto-Regular.ttf", REGULAR);
773 let medium = dir.write("Roboto-Medium.ttf", BOLD);
774
775 assert_eq!(
776 system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
777 Some(medium)
778 );
779 }
780
781 #[test]
782 fn system_font_file_falls_back_to_the_regular_face_for_other_weights() {
783 let dir = ScratchDir::new("system-regular");
784 let regular = dir.write("Roboto-Regular.ttf", REGULAR);
785
786 assert_eq!(
787 system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
788 Some(regular)
789 );
790 }
791
792 #[test]
793 fn system_font_file_reports_nothing_when_the_directory_is_empty() {
794 let dir = ScratchDir::new("system-empty");
795 assert_eq!(
796 system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::NORMAL),
797 None
798 );
799 assert_eq!(
800 system_font_file(dir.path(), &FontFamily::named("Roboto"), FontWeight::NORMAL),
801 None
802 );
803 }
804
805 #[test]
806 fn register_system_family_binds_the_generic_alias_to_the_platform_face() {
807 let dir = ScratchDir::new("system-family");
808 dir.write("Roboto-Regular.ttf", REGULAR);
809 dir.write("Roboto-Bold.ttf", BOLD);
810
811 let mut registry = SoftwareTextFontRegistry::new();
812 registry
813 .register_system_family(
814 dir.path(),
815 &FontFamily::SansSerif,
816 DEFAULT_SYSTEM_FAMILY_WEIGHTS,
817 )
818 .expect("system family loads");
819 let fonts = registry.into_font_set_or_default(&[]);
820
821 assert!(fonts.has_registered_family(&FontFamily::SansSerif));
822 let bold = fonts
823 .resolve(&style_for(&FontFamily::SansSerif, FontWeight::BOLD))
824 .expect("bold system face");
825 assert_eq!(bold.weight(), FontWeight::BOLD);
826 assert_eq!(bold.registered_family(), {
827 fonts
828 .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
829 .expect("regular system face")
830 .registered_family()
831 });
832 }
833
834 #[test]
835 fn register_system_family_reports_an_absent_font_directory() {
836 let mut registry = SoftwareTextFontRegistry::new();
837 let error = registry
838 .register_system_family(
839 Path::new("/definitely/not/a/font/directory"),
840 &FontFamily::SansSerif,
841 DEFAULT_SYSTEM_FAMILY_WEIGHTS,
842 )
843 .expect_err("an absent directory cannot register");
844 assert!(
845 matches!(error, FontLoadError::NoSystemFontFile { .. }),
846 "{error}"
847 );
848 assert!(registry.is_empty());
849 }
850
851 #[test]
852 fn a_system_weight_the_font_config_does_not_declare_resolves_to_the_declared_one() {
853 for (requested, expected) in [(450u16, 400u16), (550, 500), (401, 400), (599, 500)] {
854 assert_eq!(
855 system_declared_weight(&FontFamily::SansSerif, FontWeight(requested)),
856 FontWeight(expected),
857 "sans-serif {requested}"
858 );
859 }
860 for weight in DECLARED_HUNDREDS {
861 assert_eq!(
862 system_declared_weight(&FontFamily::SansSerif, FontWeight(*weight)),
863 FontWeight(*weight)
864 );
865 }
866 assert_eq!(
867 system_declared_weight(&FontFamily::SansSerif, FontWeight(50)),
868 FontWeight(100)
869 );
870 assert_eq!(
871 system_declared_weight(&FontFamily::SansSerif, FontWeight(1000)),
872 FontWeight(900)
873 );
874 }
875
876 #[test]
877 fn a_sparse_system_family_resolves_by_distance_and_keeps_the_lighter_face_on_a_tie() {
878 for (requested, expected) in [(500u16, 400u16), (550, 400), (600, 700), (650, 700)] {
879 assert_eq!(
880 system_declared_weight(&FontFamily::Serif, FontWeight(requested)),
881 FontWeight(expected),
882 "serif {requested}"
883 );
884 }
885 assert_eq!(
886 closest_declared_weight(&[300, 500], FontWeight(400)),
887 Some(FontWeight(300))
888 );
889 assert_eq!(
890 closest_declared_weight(&[500, 300], FontWeight(400)),
891 Some(FontWeight(500)),
892 "declaration order, not magnitude, is what breaks the tie"
893 );
894 }
895
896 #[test]
897 fn a_family_with_no_system_files_keeps_the_weight_it_was_given() {
898 assert_eq!(
899 system_declared_weight(&FontFamily::named("Roboto"), FontWeight(450)),
900 FontWeight(450)
901 );
902 }
903
904 #[test]
905 fn explicit_system_axes_reject_invalid_registration_without_poisoning_retry() {
906 let dir = ScratchDir::new("system-explicit-axes");
907 dir.write("Roboto-Regular.ttf", REGULAR);
908 let mut registry = SoftwareTextFontRegistry::new();
909 assert!(matches!(
910 registry.register_system_face_with_variations(
911 dir.path(),
912 &FontFamily::SansSerif,
913 FontWeight::NORMAL,
914 FontStyle::Normal,
915 &[(*b"opsz", 17.0)],
916 ),
917 Err(FontLoadError::Parse {
918 source: SoftwareTextFontError::InvalidVariation { .. },
919 ..
920 })
921 ));
922 assert!(registry.is_empty());
923 registry
924 .register_system_face_with_variations(
925 dir.path(),
926 &FontFamily::SansSerif,
927 FontWeight::NORMAL,
928 FontStyle::Normal,
929 &[],
930 )
931 .unwrap();
932 assert_eq!(registry.faces().len(), 1);
933 }
934
935 #[test]
936 fn explicit_byte_axes_reject_unknown_axis_without_registering() {
937 let mut registry = SoftwareTextFontRegistry::new();
938 assert!(
939 registry
940 .register_face_bytes_with_variations(
941 &FontFamily::SansSerif,
942 FontWeight::NORMAL,
943 FontStyle::Normal,
944 REGULAR,
945 &[(*b"opsz", 17.0)],
946 )
947 .is_err()
948 );
949 assert!(registry.is_empty());
950 registry
951 .register_face_bytes_with_variations(
952 &FontFamily::SansSerif,
953 FontWeight::NORMAL,
954 FontStyle::Normal,
955 REGULAR,
956 &[],
957 )
958 .unwrap();
959 assert_eq!(registry.faces().len(), 1);
960 }
961
962 #[test]
963 fn register_system_face_registers_the_declared_weight_not_the_requested_one() {
964 let dir = ScratchDir::new("system-undeclared-weight");
965 dir.write("Roboto-Regular.ttf", REGULAR);
966
967 let mut registry = SoftwareTextFontRegistry::new();
968 registry
969 .register_system_face(
970 dir.path(),
971 &FontFamily::SansSerif,
972 FontWeight(450),
973 FontStyle::Normal,
974 )
975 .expect("system face loads");
976 let fonts = registry.into_font_set_or_default(&[]);
977
978 let resolved = fonts
979 .resolve(&style_for(&FontFamily::SansSerif, FontWeight(450)))
980 .expect("a face for the off-grid request");
981 assert_eq!(
982 resolved.weight(),
983 FontWeight::NORMAL,
984 "450 must land on the 400 entry Android's matcher returns"
985 );
986
987 let mut declared = SoftwareTextFontRegistry::new();
988 declared
989 .register_system_face(
990 dir.path(),
991 &FontFamily::SansSerif,
992 FontWeight::NORMAL,
993 FontStyle::Normal,
994 )
995 .expect("system face loads");
996 let declared = declared.into_font_set_or_default(&[]);
997 assert_eq!(
998 resolved.content_hash(),
999 declared
1000 .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
1001 .expect("the 400 face")
1002 .content_hash(),
1003 "the off-grid request must produce the very same face, glyph masks included"
1004 );
1005 }
1006
1007 #[test]
1008 fn system_requests_that_resolve_alike_register_one_face() {
1009 let dir = ScratchDir::new("system-dedupe");
1010 dir.write("Roboto-Regular.ttf", REGULAR);
1011
1012 let mut registry = SoftwareTextFontRegistry::new();
1013 for weight in [400u16, 450, 499] {
1014 registry
1015 .register_system_face(
1016 dir.path(),
1017 &FontFamily::SansSerif,
1018 FontWeight(weight),
1019 FontStyle::Normal,
1020 )
1021 .expect("system face loads");
1022 }
1023
1024 assert_eq!(
1025 registry.faces().len(),
1026 1,
1027 "three requests for one declared entry are one face"
1028 );
1029 }
1030
1031 #[test]
1032 fn an_app_registered_face_keeps_the_weight_it_declares() {
1033 let dir = ScratchDir::new("app-off-grid");
1034 let path = dir.write("Test-Regular.ttf", REGULAR);
1035 let family = FontFamily::file_backed(vec![
1036 FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight(450)),
1037 ])
1038 .expect("file-backed family");
1039
1040 let mut registry = SoftwareTextFontRegistry::new();
1041 registry.register_family(&family).expect("family loads");
1042 let fonts = registry.into_font_set_or_default(&[]);
1043
1044 assert_eq!(
1045 fonts
1046 .resolve(&style_for(&family, FontWeight(450)))
1047 .expect("app face")
1048 .weight(),
1049 FontWeight(450)
1050 );
1051 }
1052
1053 #[test]
1054 fn register_family_rejects_a_family_that_names_no_files() {
1055 let mut registry = SoftwareTextFontRegistry::new();
1056 let error = registry
1057 .register_family(&FontFamily::named("Roboto"))
1058 .expect_err("a named family has nothing to read");
1059 assert!(matches!(error, FontLoadError::NotFileBacked), "{error}");
1060 }
1061
1062 #[test]
1063 fn loaded_typeface_families_register_their_single_file() {
1064 let dir = ScratchDir::new("typeface");
1065 let path = dir.write("Test-Regular.ttf", REGULAR);
1066 let family = FontFamily::loaded_typeface_path(path.to_string_lossy().into_owned());
1067
1068 let mut registry = SoftwareTextFontRegistry::new();
1069 registry.register_family(&family).expect("typeface loads");
1070 let fonts = registry.into_font_set_or_default(&[]);
1071
1072 assert!(fonts.has_registered_family(&family));
1073 assert!(
1074 fonts
1075 .resolve(&style_for(&family, FontWeight::NORMAL))
1076 .is_some_and(|font| font.registered_family().is_some())
1077 );
1078 }
1079}