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 load.finish()
138 }
139
140 fn register_read_face(
141 &mut self,
142 reads: &mut FontFileReads,
143 family: &FontFamily,
144 weight: FontWeight,
145 style: FontStyle,
146 path: &Path,
147 ) -> Result<(), FontLoadError> {
148 let bytes = reads.read(path)?;
149 let face = SoftwareTextFont::from_registered_bytes(family, weight, style, bytes.to_vec())
150 .map_err(|source| FontLoadError::Parse {
151 path: path.to_path_buf(),
152 source,
153 })?;
154 self.faces.push(face);
155 Ok(())
156 }
157
158 pub fn register_face_reader(
165 &mut self,
166 family: &FontFamily,
167 weight: FontWeight,
168 style: FontStyle,
169 reader: &mut impl Read,
170 ) -> Result<(), FontLoadError> {
171 let mut bytes = Vec::new();
172 reader
173 .read_to_end(&mut bytes)
174 .map_err(|source| FontLoadError::Read {
175 path: PathBuf::new(),
176 source,
177 })?;
178 self.register_face_bytes(family, weight, style, bytes)
179 }
180
181 pub fn register_face_bytes(
183 &mut self,
184 family: &FontFamily,
185 weight: FontWeight,
186 style: FontStyle,
187 bytes: impl Into<Vec<u8>>,
188 ) -> Result<(), FontLoadError> {
189 let face = SoftwareTextFont::from_registered_bytes(family, weight, style, bytes)
190 .map_err(|source| FontLoadError::ParseBytes { source })?;
191 self.faces.push(face);
192 Ok(())
193 }
194
195 pub fn register_fallback_bytes(
200 &mut self,
201 bytes: impl Into<Vec<u8>>,
202 ) -> Result<(), FontLoadError> {
203 let face = SoftwareTextFont::from_bytes(bytes)
204 .map_err(|source| FontLoadError::ParseBytes { source })?;
205 self.faces.push(face);
206 Ok(())
207 }
208
209 pub fn register_system_family(
226 &mut self,
227 directory: impl AsRef<Path>,
228 family: &FontFamily,
229 weights: &[FontWeight],
230 ) -> Result<(), FontLoadError> {
231 let directory = directory.as_ref();
232 let mut reads = FontFileReads::default();
233 let mut load = TolerantLoad::default();
234 for weight in weights {
235 load.record(self.register_read_system_face(
236 &mut reads,
237 directory,
238 family,
239 *weight,
240 FontStyle::Normal,
241 ));
242 }
243 load.finish()
244 }
245
246 pub fn register_system_face(
255 &mut self,
256 directory: impl AsRef<Path>,
257 family: &FontFamily,
258 weight: FontWeight,
259 style: FontStyle,
260 ) -> Result<(), FontLoadError> {
261 self.register_read_system_face(
262 &mut FontFileReads::default(),
263 directory.as_ref(),
264 family,
265 weight,
266 style,
267 )
268 }
269
270 fn register_read_system_face(
271 &mut self,
272 reads: &mut FontFileReads,
273 directory: &Path,
274 family: &FontFamily,
275 weight: FontWeight,
276 style: FontStyle,
277 ) -> Result<(), FontLoadError> {
278 let weight = system_declared_weight(family, weight);
279 if self.has_system_face(family, weight, style) {
280 return Ok(());
281 }
282 let path = system_font_file(directory, family, weight).ok_or_else(|| {
283 FontLoadError::NoSystemFontFile {
284 directory: directory.to_path_buf(),
285 }
286 })?;
287 self.register_read_face(reads, family, weight, style, &path)?;
288 self.system_faces
289 .push((FontFamilyKey::of(family), weight, style));
290 Ok(())
291 }
292
293 fn has_system_face(&self, family: &FontFamily, weight: FontWeight, style: FontStyle) -> bool {
294 self.system_faces
295 .contains(&(FontFamilyKey::of(family), weight, style))
296 }
297
298 pub fn faces(&self) -> &[SoftwareTextFont] {
300 &self.faces
301 }
302
303 pub fn is_empty(&self) -> bool {
304 self.faces.is_empty()
305 }
306
307 pub fn into_font_set_or_default(mut self, fonts: &[&[u8]]) -> SoftwareTextFontSet {
315 for bytes in fonts {
316 let _ = self.register_fallback_bytes((*bytes).to_vec());
317 }
318 if self.faces.is_empty()
319 && let Some(default_font) = default_software_text_font()
320 {
321 self.faces.push(default_font);
322 }
323 SoftwareTextFontSet::from_faces(self.faces)
324 }
325}
326
327#[derive(Default)]
328struct FontFileReads {
329 entries: Vec<(PathBuf, Arc<[u8]>)>,
330}
331
332impl FontFileReads {
333 fn read(&mut self, path: &Path) -> Result<Arc<[u8]>, FontLoadError> {
334 if let Some((_, bytes)) = self.entries.iter().find(|(read, _)| read == path) {
335 return Ok(Arc::clone(bytes));
336 }
337 let bytes: Arc<[u8]> = std::fs::read(path)
338 .map_err(|source| FontLoadError::Read {
339 path: path.to_path_buf(),
340 source,
341 })?
342 .into();
343 self.entries.push((path.to_path_buf(), Arc::clone(&bytes)));
344 Ok(bytes)
345 }
346}
347
348pub fn system_declared_weight(family: &FontFamily, weight: FontWeight) -> FontWeight {
392 let Some(files) = system_family_files(family) else {
393 return weight;
394 };
395 closest_declared_weight(files.declared, weight).unwrap_or(weight)
396}
397
398fn closest_declared_weight(declared: &[u16], requested: FontWeight) -> Option<FontWeight> {
399 let mut best: Option<(u16, u16)> = None;
400 for candidate in declared {
401 let score = weight_match_score(*candidate, requested.value());
402 if best.is_none_or(|(_, best_score)| score < best_score) {
403 best = Some((*candidate, score));
404 }
405 }
406 best.map(|(candidate, _)| FontWeight(candidate))
407}
408
409fn weight_match_score(declared: u16, requested: u16) -> u16 {
410 (declared / 100).abs_diff(requested / 100)
411}
412
413pub fn system_font_file(
420 directory: &Path,
421 family: &FontFamily,
422 weight: FontWeight,
423) -> Option<PathBuf> {
424 let files = system_family_files(family)?;
425 files
426 .weighted
427 .iter()
428 .filter(|(candidate_weight, _)| *candidate_weight == weight.value())
429 .map(|(_, name)| directory.join(name))
430 .chain(files.regular.iter().map(|name| directory.join(name)))
431 .find(|path| path.is_file())
432}
433
434struct SystemFamilyFiles {
435 regular: &'static [&'static str],
436 weighted: &'static [(u16, &'static str)],
437 declared: &'static [u16],
438}
439
440const DECLARED_HUNDREDS: &[u16] = &[100, 200, 300, 400, 500, 600, 700, 800, 900];
441
442fn system_family_files(family: &FontFamily) -> Option<SystemFamilyFiles> {
443 match family {
444 FontFamily::Default | FontFamily::SansSerif => Some(SystemFamilyFiles {
445 regular: &[
446 "Roboto-Regular.ttf",
447 "RobotoStatic-Regular.ttf",
448 "NotoSans-Regular.ttf",
449 "DroidSans.ttf",
450 ],
451 weighted: &[
452 (300, "Roboto-Light.ttf"),
453 (500, "Roboto-Medium.ttf"),
454 (700, "Roboto-Bold.ttf"),
455 (900, "Roboto-Black.ttf"),
456 ],
457 declared: DECLARED_HUNDREDS,
458 }),
459 FontFamily::Serif | FontFamily::Fantasy => Some(SystemFamilyFiles {
460 regular: &["NotoSerif-Regular.ttf", "DroidSerif-Regular.ttf"],
461 weighted: &[(700, "NotoSerif-Bold.ttf"), (700, "DroidSerif-Bold.ttf")],
462 declared: &[400, 700],
463 }),
464 FontFamily::Monospace => Some(SystemFamilyFiles {
465 regular: &[
466 "DroidSansMono.ttf",
467 "RobotoMono-Regular.ttf",
468 "CutiveMono-Regular.ttf",
469 ],
470 weighted: &[(700, "RobotoMono-Bold.ttf")],
471 declared: &[400, 700],
472 }),
473 FontFamily::Cursive => Some(SystemFamilyFiles {
474 regular: &["DancingScript-Regular.ttf"],
475 weighted: &[(700, "DancingScript-Bold.ttf")],
476 declared: &[400, 700],
477 }),
478 FontFamily::Named(_) | FontFamily::FileBacked(_) | FontFamily::LoadedTypeface(_) => None,
479 }
480}
481
482fn font_files_for(family: &FontFamily) -> Result<Vec<FontFile>, FontLoadError> {
483 match family {
484 FontFamily::FileBacked(file_backed) => Ok(file_backed.fonts.clone()),
485 FontFamily::LoadedTypeface(typeface) => Ok(vec![FontFile::new(typeface.path.clone())]),
486 _ => Err(FontLoadError::NotFileBacked),
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use std::io::Cursor;
493
494 use cranpose_ui::text::{SpanStyle, TextStyle};
495
496 use super::*;
497
498 const REGULAR: &[u8] = include_bytes!("../assets/NotoSansMerged.ttf");
499 const BOLD: &[u8] = include_bytes!("../assets/NotoSansBold.ttf");
500
501 fn style_for(family: &FontFamily, weight: FontWeight) -> TextStyle {
502 TextStyle {
503 span_style: SpanStyle {
504 font_family: Some(family.clone()),
505 font_weight: Some(weight),
506 ..Default::default()
507 },
508 ..Default::default()
509 }
510 }
511
512 struct ScratchDir(PathBuf);
513
514 impl ScratchDir {
515 fn new(name: &str) -> Self {
516 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
517 .join("../../../target/test-output/cranpose-font-source")
518 .join(name);
519 let _ = std::fs::remove_dir_all(&path);
520 std::fs::create_dir_all(&path).expect("scratch directory");
521 Self(path)
522 }
523
524 fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
525 let path = self.0.join(name);
526 std::fs::write(&path, bytes).expect("scratch font file");
527 path
528 }
529
530 fn path(&self) -> &Path {
531 &self.0
532 }
533 }
534
535 impl Drop for ScratchDir {
536 fn drop(&mut self) {
537 let _ = std::fs::remove_dir_all(&self.0);
538 }
539 }
540
541 #[test]
542 fn register_family_picks_the_face_matching_the_requested_weight() {
543 let dir = ScratchDir::new("weights");
544 let regular = dir.write("Test-Regular.ttf", REGULAR);
545 let bold = dir.write("Test-Bold.ttf", BOLD);
546 let family = FontFamily::file_backed(vec![
547 FontFile::new(regular.to_string_lossy().into_owned()),
548 FontFile::new(bold.to_string_lossy().into_owned()).with_weight(FontWeight::BOLD),
549 ])
550 .expect("file-backed family");
551
552 let mut registry = SoftwareTextFontRegistry::new();
553 registry.register_family(&family).expect("family loads");
554 let fonts = registry.into_font_set_or_default(&[]);
555
556 let resolved_regular = fonts
557 .resolve(&style_for(&family, FontWeight::NORMAL))
558 .expect("regular face");
559 let resolved_bold = fonts
560 .resolve(&style_for(&family, FontWeight::BOLD))
561 .expect("bold face");
562
563 assert_eq!(resolved_regular.weight(), FontWeight::NORMAL);
564 assert_eq!(resolved_bold.weight(), FontWeight::BOLD);
565 assert_ne!(
566 resolved_regular.content_hash(),
567 resolved_bold.content_hash(),
568 "distinct faces must key the glyph atlas distinctly"
569 );
570 }
571
572 #[test]
573 fn register_family_honours_a_declared_weight_over_the_face_header() {
574 let dir = ScratchDir::new("declared");
575 let path = dir.write("Test-Regular.ttf", REGULAR);
576 let family = FontFamily::file_backed(vec![
577 FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight::MEDIUM),
578 ])
579 .expect("file-backed family");
580
581 let mut registry = SoftwareTextFontRegistry::new();
582 registry.register_family(&family).expect("family loads");
583 let fonts = registry.into_font_set_or_default(&[]);
584
585 let resolved = fonts
586 .resolve(&style_for(&family, FontWeight::MEDIUM))
587 .expect("declared face");
588 assert_eq!(resolved.weight(), FontWeight::MEDIUM);
589 }
590
591 #[test]
592 fn register_family_reports_a_missing_file_without_panicking() {
593 let dir = ScratchDir::new("missing");
594 let family = FontFamily::file_backed(vec![FontFile::new(
595 dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
596 )])
597 .expect("file-backed family");
598
599 let mut registry = SoftwareTextFontRegistry::new();
600 let error = registry
601 .register_family(&family)
602 .expect_err("a missing file cannot register");
603 assert!(matches!(error, FontLoadError::Read { .. }), "{error}");
604 assert!(registry.is_empty());
605 }
606
607 #[test]
608 fn register_family_reports_a_corrupt_file_without_panicking() {
609 let dir = ScratchDir::new("corrupt");
610 let path = dir.write("Corrupt.ttf", b"this is not a font");
611 let family =
612 FontFamily::file_backed(vec![FontFile::new(path.to_string_lossy().into_owned())])
613 .expect("file-backed family");
614
615 let mut registry = SoftwareTextFontRegistry::new();
616 let error = registry
617 .register_family(&family)
618 .expect_err("a corrupt file cannot register");
619 assert!(matches!(error, FontLoadError::Parse { .. }), "{error}");
620 assert!(registry.is_empty());
621 }
622
623 #[test]
624 #[cfg(feature = "embedded-default-font")]
625 fn a_family_that_failed_to_load_falls_back_to_the_default_face() {
626 let dir = ScratchDir::new("fallback");
627 let family = FontFamily::file_backed(vec![FontFile::new(
628 dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
629 )])
630 .expect("file-backed family");
631
632 let mut registry = SoftwareTextFontRegistry::new();
633 let _ = registry.register_family(&family);
634 let fonts = registry.into_font_set_or_default(&[]);
635
636 let resolved = fonts
637 .resolve(&style_for(&family, FontWeight::NORMAL))
638 .expect("fallback face");
639 assert_eq!(
640 resolved.content_hash(),
641 fonts.default_font().expect("default face").content_hash()
642 );
643 }
644
645 #[test]
646 fn a_partly_loadable_family_keeps_the_faces_that_did_load() {
647 let dir = ScratchDir::new("partial");
648 let regular = dir.write("Test-Regular.ttf", REGULAR);
649 let family = FontFamily::file_backed(vec![
650 FontFile::new(regular.to_string_lossy().into_owned()),
651 FontFile::new(dir.path().join("Absent.ttf").to_string_lossy().into_owned())
652 .with_weight(FontWeight::BOLD),
653 ])
654 .expect("file-backed family");
655
656 let mut registry = SoftwareTextFontRegistry::new();
657 registry
658 .register_family(&family)
659 .expect("one readable face is enough");
660 assert_eq!(registry.faces().len(), 1);
661 }
662
663 #[test]
664 fn register_face_reader_accepts_a_font_that_is_not_a_file() {
665 let family = FontFamily::named("Bundled");
666 let mut registry = SoftwareTextFontRegistry::new();
667 registry
668 .register_face_reader(
669 &family,
670 FontWeight::NORMAL,
671 FontStyle::Normal,
672 &mut Cursor::new(REGULAR.to_vec()),
673 )
674 .expect("streamed face loads");
675
676 let fonts = registry.into_font_set_or_default(&[]);
677 let resolved = fonts
678 .resolve(&style_for(&family, FontWeight::NORMAL))
679 .expect("streamed face");
680 assert_eq!(resolved.registered_family(), {
681 let mut expected = SoftwareTextFontRegistry::new();
682 expected
683 .register_face_bytes(
684 &family,
685 FontWeight::NORMAL,
686 FontStyle::Normal,
687 REGULAR.to_vec(),
688 )
689 .expect("face loads");
690 expected.faces()[0].registered_family()
691 });
692 }
693
694 #[test]
695 #[cfg(feature = "embedded-default-font")]
696 fn an_empty_registry_falls_back_to_the_embedded_default_face() {
697 let fonts = SoftwareTextFontRegistry::new().into_font_set_or_default(&[]);
698 assert!(
699 fonts.default_font().is_some(),
700 "the embedded default font must still serve apps that supply nothing"
701 );
702 assert!(
703 fonts
704 .resolve(&TextStyle::default())
705 .is_some_and(|font| font.registered_family().is_none())
706 );
707 }
708
709 #[test]
710 fn system_font_file_prefers_a_weight_specific_static_face() {
711 let dir = ScratchDir::new("system-static");
712 dir.write("Roboto-Regular.ttf", REGULAR);
713 let medium = dir.write("Roboto-Medium.ttf", BOLD);
714
715 assert_eq!(
716 system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
717 Some(medium)
718 );
719 }
720
721 #[test]
722 fn system_font_file_falls_back_to_the_regular_face_for_other_weights() {
723 let dir = ScratchDir::new("system-regular");
724 let regular = dir.write("Roboto-Regular.ttf", REGULAR);
725
726 assert_eq!(
727 system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
728 Some(regular)
729 );
730 }
731
732 #[test]
733 fn system_font_file_reports_nothing_when_the_directory_is_empty() {
734 let dir = ScratchDir::new("system-empty");
735 assert_eq!(
736 system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::NORMAL),
737 None
738 );
739 assert_eq!(
740 system_font_file(dir.path(), &FontFamily::named("Roboto"), FontWeight::NORMAL),
741 None
742 );
743 }
744
745 #[test]
746 fn register_system_family_binds_the_generic_alias_to_the_platform_face() {
747 let dir = ScratchDir::new("system-family");
748 dir.write("Roboto-Regular.ttf", REGULAR);
749 dir.write("Roboto-Bold.ttf", BOLD);
750
751 let mut registry = SoftwareTextFontRegistry::new();
752 registry
753 .register_system_family(
754 dir.path(),
755 &FontFamily::SansSerif,
756 DEFAULT_SYSTEM_FAMILY_WEIGHTS,
757 )
758 .expect("system family loads");
759 let fonts = registry.into_font_set_or_default(&[]);
760
761 assert!(fonts.has_registered_family(&FontFamily::SansSerif));
762 let bold = fonts
763 .resolve(&style_for(&FontFamily::SansSerif, FontWeight::BOLD))
764 .expect("bold system face");
765 assert_eq!(bold.weight(), FontWeight::BOLD);
766 assert_eq!(bold.registered_family(), {
767 fonts
768 .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
769 .expect("regular system face")
770 .registered_family()
771 });
772 }
773
774 #[test]
775 fn register_system_family_reports_an_absent_font_directory() {
776 let mut registry = SoftwareTextFontRegistry::new();
777 let error = registry
778 .register_system_family(
779 Path::new("/definitely/not/a/font/directory"),
780 &FontFamily::SansSerif,
781 DEFAULT_SYSTEM_FAMILY_WEIGHTS,
782 )
783 .expect_err("an absent directory cannot register");
784 assert!(
785 matches!(error, FontLoadError::NoSystemFontFile { .. }),
786 "{error}"
787 );
788 assert!(registry.is_empty());
789 }
790
791 #[test]
792 fn a_system_weight_the_font_config_does_not_declare_resolves_to_the_declared_one() {
793 for (requested, expected) in [(450u16, 400u16), (550, 500), (401, 400), (599, 500)] {
794 assert_eq!(
795 system_declared_weight(&FontFamily::SansSerif, FontWeight(requested)),
796 FontWeight(expected),
797 "sans-serif {requested}"
798 );
799 }
800 for weight in DECLARED_HUNDREDS {
801 assert_eq!(
802 system_declared_weight(&FontFamily::SansSerif, FontWeight(*weight)),
803 FontWeight(*weight)
804 );
805 }
806 assert_eq!(
807 system_declared_weight(&FontFamily::SansSerif, FontWeight(50)),
808 FontWeight(100)
809 );
810 assert_eq!(
811 system_declared_weight(&FontFamily::SansSerif, FontWeight(1000)),
812 FontWeight(900)
813 );
814 }
815
816 #[test]
817 fn a_sparse_system_family_resolves_by_distance_and_keeps_the_lighter_face_on_a_tie() {
818 for (requested, expected) in [(500u16, 400u16), (550, 400), (600, 700), (650, 700)] {
819 assert_eq!(
820 system_declared_weight(&FontFamily::Serif, FontWeight(requested)),
821 FontWeight(expected),
822 "serif {requested}"
823 );
824 }
825 assert_eq!(
826 closest_declared_weight(&[300, 500], FontWeight(400)),
827 Some(FontWeight(300))
828 );
829 assert_eq!(
830 closest_declared_weight(&[500, 300], FontWeight(400)),
831 Some(FontWeight(500)),
832 "declaration order, not magnitude, is what breaks the tie"
833 );
834 }
835
836 #[test]
837 fn a_family_with_no_system_files_keeps_the_weight_it_was_given() {
838 assert_eq!(
839 system_declared_weight(&FontFamily::named("Roboto"), FontWeight(450)),
840 FontWeight(450)
841 );
842 }
843
844 #[test]
845 fn register_system_face_registers_the_declared_weight_not_the_requested_one() {
846 let dir = ScratchDir::new("system-undeclared-weight");
847 dir.write("Roboto-Regular.ttf", REGULAR);
848
849 let mut registry = SoftwareTextFontRegistry::new();
850 registry
851 .register_system_face(
852 dir.path(),
853 &FontFamily::SansSerif,
854 FontWeight(450),
855 FontStyle::Normal,
856 )
857 .expect("system face loads");
858 let fonts = registry.into_font_set_or_default(&[]);
859
860 let resolved = fonts
861 .resolve(&style_for(&FontFamily::SansSerif, FontWeight(450)))
862 .expect("a face for the off-grid request");
863 assert_eq!(
864 resolved.weight(),
865 FontWeight::NORMAL,
866 "450 must land on the 400 entry Android's matcher returns"
867 );
868
869 let mut declared = SoftwareTextFontRegistry::new();
870 declared
871 .register_system_face(
872 dir.path(),
873 &FontFamily::SansSerif,
874 FontWeight::NORMAL,
875 FontStyle::Normal,
876 )
877 .expect("system face loads");
878 let declared = declared.into_font_set_or_default(&[]);
879 assert_eq!(
880 resolved.content_hash(),
881 declared
882 .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
883 .expect("the 400 face")
884 .content_hash(),
885 "the off-grid request must produce the very same face, glyph masks included"
886 );
887 }
888
889 #[test]
890 fn system_requests_that_resolve_alike_register_one_face() {
891 let dir = ScratchDir::new("system-dedupe");
892 dir.write("Roboto-Regular.ttf", REGULAR);
893
894 let mut registry = SoftwareTextFontRegistry::new();
895 for weight in [400u16, 450, 499] {
896 registry
897 .register_system_face(
898 dir.path(),
899 &FontFamily::SansSerif,
900 FontWeight(weight),
901 FontStyle::Normal,
902 )
903 .expect("system face loads");
904 }
905
906 assert_eq!(
907 registry.faces().len(),
908 1,
909 "three requests for one declared entry are one face"
910 );
911 }
912
913 #[test]
914 fn an_app_registered_face_keeps_the_weight_it_declares() {
915 let dir = ScratchDir::new("app-off-grid");
916 let path = dir.write("Test-Regular.ttf", REGULAR);
917 let family = FontFamily::file_backed(vec![
918 FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight(450)),
919 ])
920 .expect("file-backed family");
921
922 let mut registry = SoftwareTextFontRegistry::new();
923 registry.register_family(&family).expect("family loads");
924 let fonts = registry.into_font_set_or_default(&[]);
925
926 assert_eq!(
927 fonts
928 .resolve(&style_for(&family, FontWeight(450)))
929 .expect("app face")
930 .weight(),
931 FontWeight(450)
932 );
933 }
934
935 #[test]
936 fn register_family_rejects_a_family_that_names_no_files() {
937 let mut registry = SoftwareTextFontRegistry::new();
938 let error = registry
939 .register_family(&FontFamily::named("Roboto"))
940 .expect_err("a named family has nothing to read");
941 assert!(matches!(error, FontLoadError::NotFileBacked), "{error}");
942 }
943
944 #[test]
945 fn loaded_typeface_families_register_their_single_file() {
946 let dir = ScratchDir::new("typeface");
947 let path = dir.write("Test-Regular.ttf", REGULAR);
948 let family = FontFamily::loaded_typeface_path(path.to_string_lossy().into_owned());
949
950 let mut registry = SoftwareTextFontRegistry::new();
951 registry.register_family(&family).expect("typeface loads");
952 let fonts = registry.into_font_set_or_default(&[]);
953
954 assert!(fonts.has_registered_family(&family));
955 assert!(
956 fonts
957 .resolve(&style_for(&family, FontWeight::NORMAL))
958 .is_some_and(|font| font.registered_family().is_some())
959 );
960 }
961}