Skip to main content

cranpose_render_common/
font_source.rs

1//! Turning app-declared font families into parsed faces.
2//!
3//! [`FontFamily::FileBacked`] and [`FontFamily::LoadedTypeface`] name faces by
4//! file rather than by a name inside the font, so something has to read those
5//! files and hand the bytes to the rasterizer. That is this module: it parses
6//! each face exactly once, at registration, and produces an immutable
7//! [`SoftwareTextFontSet`] that measurement and rasterization then share.
8//!
9//! Nothing here runs per frame or per string. A registry is built at startup,
10//! consumed into a font set, and the font set is cloned (it is `Arc`-backed)
11//! into every measurer and rasterizer that needs it.
12//!
13//! Fonts that are not files on disk come in through
14//! [`SoftwareTextFontRegistry::register_face_reader`] or
15//! [`SoftwareTextFontRegistry::register_face_bytes`]: an APK asset opened with
16//! `AndroidApp::asset_manager()`, or anything `cranpose-assets` resolved out of
17//! a desktop bundle. `cranpose-assets` is a filesystem path resolver, so it
18//! covers bundles but not APK entries, which are not filesystem paths.
19
20use std::io::Read;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use cranpose_ui::text::{FontFamily, FontFile, FontStyle, FontWeight};
25
26use crate::software_text_raster::{
27    default_software_text_font, SoftwareTextFont, SoftwareTextFontError, SoftwareTextFontSet,
28};
29
30/// Directory Android keeps its system font files in.
31pub const ANDROID_SYSTEM_FONT_DIR: &str = "/system/fonts";
32
33/// The weights [`SoftwareTextFontRegistry::register_system_family`] registers
34/// when an app does not name its own: Compose's Regular/Medium/Bold set.
35pub const DEFAULT_SYSTEM_FAMILY_WEIGHTS: &[FontWeight] =
36    &[FontWeight::NORMAL, FontWeight::MEDIUM, FontWeight::BOLD];
37
38/// Why an app-supplied face could not be registered.
39///
40/// Every variant is recoverable: the caller logs it and keeps whatever faces
41/// did load, and resolution falls back to the default face for families that
42/// ended up with none.
43#[derive(Debug, thiserror::Error)]
44pub enum FontLoadError {
45    #[error("font family declares no faces")]
46    EmptyFamily,
47    #[error("font family is not backed by files, so it has nothing to load")]
48    NotFileBacked,
49    #[error("no system font file for this family under {directory}")]
50    NoSystemFontFile { directory: PathBuf },
51    #[error("failed to read font file {path}: {source}")]
52    Read {
53        path: PathBuf,
54        #[source]
55        source: std::io::Error,
56    },
57    #[error("failed to parse font file {path}: {source}")]
58    Parse {
59        path: PathBuf,
60        #[source]
61        source: SoftwareTextFontError,
62    },
63    #[error("failed to parse font bytes: {source}")]
64    ParseBytes {
65        #[source]
66        source: SoftwareTextFontError,
67    },
68}
69
70/// Parsed app-supplied faces, on their way to a [`SoftwareTextFontSet`].
71///
72/// Register everything an app needs once at startup, then call
73/// [`SoftwareTextFontRegistry::into_font_set_or_default`]. Registration is
74/// where files are read and faces parsed; nothing after it touches the disk.
75#[derive(Clone, Default)]
76pub struct SoftwareTextFontRegistry {
77    faces: Vec<SoftwareTextFont>,
78}
79
80impl SoftwareTextFontRegistry {
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Register every face of a file-backed family, reading each file from the
86    /// filesystem.
87    ///
88    /// Each [`FontFile`]'s declared weight and style are what resolution
89    /// matches on, so one family can carry Regular/Medium/Bold/Italic faces and
90    /// a `FontWeight`/`FontStyle` picks between them. A family whose files all
91    /// fail to load registers nothing and reports the first failure; text
92    /// asking for it then falls back to the default face rather than
93    /// disappearing.
94    pub fn register_family(&mut self, family: &FontFamily) -> Result<(), FontLoadError> {
95        let files = font_files_for(family)?;
96        if files.is_empty() {
97            return Err(FontLoadError::EmptyFamily);
98        }
99
100        let mut reads = FontFileReads::default();
101        let mut first_error = None;
102        let mut loaded = 0usize;
103        for file in &files {
104            match self.register_read_face(
105                &mut reads,
106                family,
107                file.weight,
108                file.style,
109                Path::new(&file.path),
110            ) {
111                Ok(()) => loaded += 1,
112                Err(error) => first_error = first_error.or(Some(error)),
113            }
114        }
115
116        match first_error {
117            Some(error) if loaded == 0 => Err(error),
118            _ => Ok(()),
119        }
120    }
121
122    /// Register one face read from `path`.
123    pub fn register_face_path(
124        &mut self,
125        family: &FontFamily,
126        weight: FontWeight,
127        style: FontStyle,
128        path: impl AsRef<Path>,
129    ) -> Result<(), FontLoadError> {
130        self.register_read_face(
131            &mut FontFileReads::default(),
132            family,
133            weight,
134            style,
135            path.as_ref(),
136        )
137    }
138
139    fn register_read_face(
140        &mut self,
141        reads: &mut FontFileReads,
142        family: &FontFamily,
143        weight: FontWeight,
144        style: FontStyle,
145        path: &Path,
146    ) -> Result<(), FontLoadError> {
147        let bytes = reads.read(path)?;
148        let face = SoftwareTextFont::from_registered_bytes(family, weight, style, bytes.to_vec())
149            .map_err(|source| FontLoadError::Parse {
150            path: path.to_path_buf(),
151            source,
152        })?;
153        self.faces.push(face);
154        Ok(())
155    }
156
157    /// Register one face read from an arbitrary stream.
158    ///
159    /// This is the seam for fonts that are not files on disk — an APK asset
160    /// opened through `AndroidApp::asset_manager()`, an archive entry, a
161    /// download cache. It mirrors
162    /// `SoftwareTextMeasurer::register_hyphenation_dictionary_reader`.
163    pub fn register_face_reader(
164        &mut self,
165        family: &FontFamily,
166        weight: FontWeight,
167        style: FontStyle,
168        reader: &mut impl Read,
169    ) -> Result<(), FontLoadError> {
170        let mut bytes = Vec::new();
171        reader
172            .read_to_end(&mut bytes)
173            .map_err(|source| FontLoadError::Read {
174                path: PathBuf::new(),
175                source,
176            })?;
177        self.register_face_bytes(family, weight, style, bytes)
178    }
179
180    /// Register one face from bytes the app already holds.
181    pub fn register_face_bytes(
182        &mut self,
183        family: &FontFamily,
184        weight: FontWeight,
185        style: FontStyle,
186        bytes: impl Into<Vec<u8>>,
187    ) -> Result<(), FontLoadError> {
188        let face = SoftwareTextFont::from_registered_bytes(family, weight, style, bytes)
189            .map_err(|source| FontLoadError::ParseBytes { source })?;
190        self.faces.push(face);
191        Ok(())
192    }
193
194    /// Register a face that belongs to no declared family.
195    ///
196    /// These are the fallbacks: they are eligible for any request that names no
197    /// family, and for a `Named` request their own `name` table decides.
198    pub fn register_fallback_bytes(
199        &mut self,
200        bytes: impl Into<Vec<u8>>,
201    ) -> Result<(), FontLoadError> {
202        let face = SoftwareTextFont::from_bytes(bytes)
203            .map_err(|source| FontLoadError::ParseBytes { source })?;
204        self.faces.push(face);
205        Ok(())
206    }
207
208    /// Register the platform's own face for a generic family alias, at each of
209    /// `weights`, so styles keep naming `FontFamily::SansSerif` and get the
210    /// real system typeface.
211    ///
212    /// Android backs `sans-serif` with a single variable `Roboto-Regular.ttf`
213    /// and describes each weight as a `wght` axis position on it, so most
214    /// devices resolve every weight to one file instanced several ways. Where a
215    /// build does ship weight-specific static files (`Roboto-Medium.ttf`), they
216    /// are preferred. Faces are registered in `FontStyle::Normal`; an app that
217    /// wants a real italic rather than a synthesized slant should call
218    /// [`SoftwareTextFontRegistry::register_system_face`] for it, because each
219    /// extra face is another copy of the file's bytes.
220    pub fn register_system_family(
221        &mut self,
222        directory: impl AsRef<Path>,
223        family: &FontFamily,
224        weights: &[FontWeight],
225    ) -> Result<(), FontLoadError> {
226        let directory = directory.as_ref();
227        let mut reads = FontFileReads::default();
228        let mut first_error = None;
229        let mut loaded = 0usize;
230        for weight in weights {
231            match self.register_read_system_face(
232                &mut reads,
233                directory,
234                family,
235                *weight,
236                FontStyle::Normal,
237            ) {
238                Ok(()) => loaded += 1,
239                Err(error) => first_error = first_error.or(Some(error)),
240            }
241        }
242
243        match first_error {
244            Some(error) if loaded == 0 => Err(error),
245            _ => Ok(()),
246        }
247    }
248
249    /// Register one weight/style of a generic family alias from the platform's
250    /// font directory.
251    pub fn register_system_face(
252        &mut self,
253        directory: impl AsRef<Path>,
254        family: &FontFamily,
255        weight: FontWeight,
256        style: FontStyle,
257    ) -> Result<(), FontLoadError> {
258        self.register_read_system_face(
259            &mut FontFileReads::default(),
260            directory.as_ref(),
261            family,
262            weight,
263            style,
264        )
265    }
266
267    fn register_read_system_face(
268        &mut self,
269        reads: &mut FontFileReads,
270        directory: &Path,
271        family: &FontFamily,
272        weight: FontWeight,
273        style: FontStyle,
274    ) -> Result<(), FontLoadError> {
275        let path = system_font_file(directory, family, weight).ok_or_else(|| {
276            FontLoadError::NoSystemFontFile {
277                directory: directory.to_path_buf(),
278            }
279        })?;
280        self.register_read_face(reads, family, weight, style, &path)
281    }
282
283    /// The faces registered so far.
284    pub fn faces(&self) -> &[SoftwareTextFont] {
285        &self.faces
286    }
287
288    pub fn is_empty(&self) -> bool {
289        self.faces.is_empty()
290    }
291
292    /// Finish, folding in the static byte slices from `AppLauncher::with_fonts`
293    /// as unregistered fallbacks and the embedded default face when nothing
294    /// else loaded.
295    ///
296    /// Registered faces come first, so when a request names no family and the
297    /// scores tie, a face the app declared wins over one it merely handed over
298    /// as bytes.
299    pub fn into_font_set_or_default(mut self, fonts: &[&[u8]]) -> SoftwareTextFontSet {
300        for bytes in fonts {
301            // Unparseable app bytes have always been skipped rather than fatal.
302            let _ = self.register_fallback_bytes((*bytes).to_vec());
303        }
304        if self.faces.is_empty() {
305            if let Some(default_font) = default_software_text_font() {
306                self.faces.push(default_font);
307            }
308        }
309        SoftwareTextFontSet::from_faces(self.faces)
310    }
311}
312
313/// Font files read during one registration call.
314///
315/// A family that instances a single variable file at several weights names that
316/// file once per weight, and on Wear the file backing `sans-serif` is 2.3 MiB —
317/// worth reading once. Each face still needs its own copy of the bytes, because
318/// `ab_glyph` bakes the axis values into the parsed face.
319#[derive(Default)]
320struct FontFileReads {
321    entries: Vec<(PathBuf, Arc<[u8]>)>,
322}
323
324impl FontFileReads {
325    fn read(&mut self, path: &Path) -> Result<Arc<[u8]>, FontLoadError> {
326        if let Some((_, bytes)) = self.entries.iter().find(|(read, _)| read == path) {
327            return Ok(Arc::clone(bytes));
328        }
329        let bytes: Arc<[u8]> = std::fs::read(path)
330            .map_err(|source| FontLoadError::Read {
331                path: path.to_path_buf(),
332                source,
333            })?
334            .into();
335        self.entries.push((path.to_path_buf(), Arc::clone(&bytes)));
336        Ok(bytes)
337    }
338}
339
340/// The file a platform backs `family` with at `weight`, if one is present.
341///
342/// Weight-specific static files win when the build ships them; otherwise the
343/// family's regular file is returned and instanced on its `wght` axis at
344/// registration.
345pub fn system_font_file(
346    directory: &Path,
347    family: &FontFamily,
348    weight: FontWeight,
349) -> Option<PathBuf> {
350    let files = system_family_files(family)?;
351    files
352        .weighted
353        .iter()
354        .filter(|(candidate_weight, _)| *candidate_weight == weight.value())
355        .map(|(_, name)| directory.join(name))
356        .chain(files.regular.iter().map(|name| directory.join(name)))
357        .find(|path| path.is_file())
358}
359
360/// Files a platform is known to back a generic family with, best first.
361struct SystemFamilyFiles {
362    regular: &'static [&'static str],
363    weighted: &'static [(u16, &'static str)],
364}
365
366fn system_family_files(family: &FontFamily) -> Option<SystemFamilyFiles> {
367    // Names come from Android's `/system/fonts`; the alias-to-file mapping is
368    // the one `/system/etc/fonts.xml` describes, which that file itself warns
369    // third parties not to parse.
370    match family {
371        FontFamily::Default | FontFamily::SansSerif => Some(SystemFamilyFiles {
372            regular: &[
373                "Roboto-Regular.ttf",
374                "RobotoStatic-Regular.ttf",
375                "NotoSans-Regular.ttf",
376                "DroidSans.ttf",
377            ],
378            weighted: &[
379                (300, "Roboto-Light.ttf"),
380                (500, "Roboto-Medium.ttf"),
381                (700, "Roboto-Bold.ttf"),
382                (900, "Roboto-Black.ttf"),
383            ],
384        }),
385        // Android aliases `fantasy` to `serif`.
386        FontFamily::Serif | FontFamily::Fantasy => Some(SystemFamilyFiles {
387            regular: &["NotoSerif-Regular.ttf", "DroidSerif-Regular.ttf"],
388            weighted: &[(700, "NotoSerif-Bold.ttf"), (700, "DroidSerif-Bold.ttf")],
389        }),
390        FontFamily::Monospace => Some(SystemFamilyFiles {
391            regular: &[
392                "DroidSansMono.ttf",
393                "RobotoMono-Regular.ttf",
394                "CutiveMono-Regular.ttf",
395            ],
396            weighted: &[(700, "RobotoMono-Bold.ttf")],
397        }),
398        FontFamily::Cursive => Some(SystemFamilyFiles {
399            regular: &["DancingScript-Regular.ttf"],
400            weighted: &[(700, "DancingScript-Bold.ttf")],
401        }),
402        FontFamily::Named(_) | FontFamily::FileBacked(_) | FontFamily::LoadedTypeface(_) => None,
403    }
404}
405
406fn font_files_for(family: &FontFamily) -> Result<Vec<FontFile>, FontLoadError> {
407    match family {
408        FontFamily::FileBacked(file_backed) => Ok(file_backed.fonts.clone()),
409        FontFamily::LoadedTypeface(typeface) => Ok(vec![FontFile::new(typeface.path.clone())]),
410        _ => Err(FontLoadError::NotFileBacked),
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use cranpose_ui::text::{SpanStyle, TextStyle};
418    use std::io::Cursor;
419
420    const REGULAR: &[u8] = include_bytes!("../assets/NotoSansMerged.ttf");
421    const BOLD: &[u8] = include_bytes!("../assets/NotoSansBold.ttf");
422
423    fn style_for(family: &FontFamily, weight: FontWeight) -> TextStyle {
424        TextStyle {
425            span_style: SpanStyle {
426                font_family: Some(family.clone()),
427                font_weight: Some(weight),
428                ..Default::default()
429            },
430            ..Default::default()
431        }
432    }
433
434    /// A directory that removes itself, so font-loading tests can exercise real
435    /// filesystem failures instead of a stubbed reader. Lives under the
436    /// workspace `target/test-output`, never tmpfs.
437    struct ScratchDir(PathBuf);
438
439    impl ScratchDir {
440        fn new(name: &str) -> Self {
441            let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
442                .join("../../../target/test-output/cranpose-font-source")
443                .join(name);
444            let _ = std::fs::remove_dir_all(&path);
445            std::fs::create_dir_all(&path).expect("scratch directory");
446            Self(path)
447        }
448
449        fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
450            let path = self.0.join(name);
451            std::fs::write(&path, bytes).expect("scratch font file");
452            path
453        }
454
455        fn path(&self) -> &Path {
456            &self.0
457        }
458    }
459
460    impl Drop for ScratchDir {
461        fn drop(&mut self) {
462            let _ = std::fs::remove_dir_all(&self.0);
463        }
464    }
465
466    #[test]
467    fn register_family_picks_the_face_matching_the_requested_weight() {
468        let dir = ScratchDir::new("weights");
469        let regular = dir.write("Test-Regular.ttf", REGULAR);
470        let bold = dir.write("Test-Bold.ttf", BOLD);
471        let family = FontFamily::file_backed(vec![
472            FontFile::new(regular.to_string_lossy().into_owned()),
473            FontFile::new(bold.to_string_lossy().into_owned()).with_weight(FontWeight::BOLD),
474        ])
475        .expect("file-backed family");
476
477        let mut registry = SoftwareTextFontRegistry::new();
478        registry.register_family(&family).expect("family loads");
479        let fonts = registry.into_font_set_or_default(&[]);
480
481        let resolved_regular = fonts
482            .resolve(&style_for(&family, FontWeight::NORMAL))
483            .expect("regular face");
484        let resolved_bold = fonts
485            .resolve(&style_for(&family, FontWeight::BOLD))
486            .expect("bold face");
487
488        assert_eq!(resolved_regular.weight(), FontWeight::NORMAL);
489        assert_eq!(resolved_bold.weight(), FontWeight::BOLD);
490        assert_ne!(
491            resolved_regular.content_hash(),
492            resolved_bold.content_hash(),
493            "distinct faces must key the glyph atlas distinctly"
494        );
495    }
496
497    #[test]
498    fn register_family_honours_a_declared_weight_over_the_face_header() {
499        let dir = ScratchDir::new("declared");
500        let path = dir.write("Test-Regular.ttf", REGULAR);
501        let family =
502            FontFamily::file_backed(vec![
503                FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight::MEDIUM)
504            ])
505            .expect("file-backed family");
506
507        let mut registry = SoftwareTextFontRegistry::new();
508        registry.register_family(&family).expect("family loads");
509        let fonts = registry.into_font_set_or_default(&[]);
510
511        let resolved = fonts
512            .resolve(&style_for(&family, FontWeight::MEDIUM))
513            .expect("declared face");
514        assert_eq!(resolved.weight(), FontWeight::MEDIUM);
515    }
516
517    #[test]
518    fn register_family_reports_a_missing_file_without_panicking() {
519        let dir = ScratchDir::new("missing");
520        let family = FontFamily::file_backed(vec![FontFile::new(
521            dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
522        )])
523        .expect("file-backed family");
524
525        let mut registry = SoftwareTextFontRegistry::new();
526        let error = registry
527            .register_family(&family)
528            .expect_err("a missing file cannot register");
529        assert!(matches!(error, FontLoadError::Read { .. }), "{error}");
530        assert!(registry.is_empty());
531    }
532
533    #[test]
534    fn register_family_reports_a_corrupt_file_without_panicking() {
535        let dir = ScratchDir::new("corrupt");
536        let path = dir.write("Corrupt.ttf", b"this is not a font");
537        let family =
538            FontFamily::file_backed(vec![FontFile::new(path.to_string_lossy().into_owned())])
539                .expect("file-backed family");
540
541        let mut registry = SoftwareTextFontRegistry::new();
542        let error = registry
543            .register_family(&family)
544            .expect_err("a corrupt file cannot register");
545        assert!(matches!(error, FontLoadError::Parse { .. }), "{error}");
546        assert!(registry.is_empty());
547    }
548
549    #[test]
550    #[cfg(feature = "embedded-default-font")]
551    fn a_family_that_failed_to_load_falls_back_to_the_default_face() {
552        let dir = ScratchDir::new("fallback");
553        let family = FontFamily::file_backed(vec![FontFile::new(
554            dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
555        )])
556        .expect("file-backed family");
557
558        let mut registry = SoftwareTextFontRegistry::new();
559        let _ = registry.register_family(&family);
560        let fonts = registry.into_font_set_or_default(&[]);
561
562        let resolved = fonts
563            .resolve(&style_for(&family, FontWeight::NORMAL))
564            .expect("fallback face");
565        assert_eq!(
566            resolved.content_hash(),
567            fonts.default_font().expect("default face").content_hash()
568        );
569    }
570
571    #[test]
572    fn a_partly_loadable_family_keeps_the_faces_that_did_load() {
573        let dir = ScratchDir::new("partial");
574        let regular = dir.write("Test-Regular.ttf", REGULAR);
575        let family = FontFamily::file_backed(vec![
576            FontFile::new(regular.to_string_lossy().into_owned()),
577            FontFile::new(dir.path().join("Absent.ttf").to_string_lossy().into_owned())
578                .with_weight(FontWeight::BOLD),
579        ])
580        .expect("file-backed family");
581
582        let mut registry = SoftwareTextFontRegistry::new();
583        registry
584            .register_family(&family)
585            .expect("one readable face is enough");
586        assert_eq!(registry.faces().len(), 1);
587    }
588
589    #[test]
590    fn register_face_reader_accepts_a_font_that_is_not_a_file() {
591        let family = FontFamily::named("Bundled");
592        let mut registry = SoftwareTextFontRegistry::new();
593        registry
594            .register_face_reader(
595                &family,
596                FontWeight::NORMAL,
597                FontStyle::Normal,
598                &mut Cursor::new(REGULAR.to_vec()),
599            )
600            .expect("streamed face loads");
601
602        let fonts = registry.into_font_set_or_default(&[]);
603        let resolved = fonts
604            .resolve(&style_for(&family, FontWeight::NORMAL))
605            .expect("streamed face");
606        assert_eq!(resolved.registered_family(), {
607            let mut expected = SoftwareTextFontRegistry::new();
608            expected
609                .register_face_bytes(
610                    &family,
611                    FontWeight::NORMAL,
612                    FontStyle::Normal,
613                    REGULAR.to_vec(),
614                )
615                .expect("face loads");
616            expected.faces()[0].registered_family()
617        });
618    }
619
620    #[test]
621    #[cfg(feature = "embedded-default-font")]
622    fn an_empty_registry_falls_back_to_the_embedded_default_face() {
623        let fonts = SoftwareTextFontRegistry::new().into_font_set_or_default(&[]);
624        assert!(
625            fonts.default_font().is_some(),
626            "the embedded default font must still serve apps that supply nothing"
627        );
628        assert!(fonts
629            .resolve(&TextStyle::default())
630            .is_some_and(|font| font.registered_family().is_none()));
631    }
632
633    #[test]
634    fn system_font_file_prefers_a_weight_specific_static_face() {
635        let dir = ScratchDir::new("system-static");
636        dir.write("Roboto-Regular.ttf", REGULAR);
637        let medium = dir.write("Roboto-Medium.ttf", BOLD);
638
639        assert_eq!(
640            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
641            Some(medium)
642        );
643    }
644
645    #[test]
646    fn system_font_file_falls_back_to_the_regular_face_for_other_weights() {
647        let dir = ScratchDir::new("system-regular");
648        let regular = dir.write("Roboto-Regular.ttf", REGULAR);
649
650        assert_eq!(
651            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
652            Some(regular)
653        );
654    }
655
656    #[test]
657    fn system_font_file_reports_nothing_when_the_directory_is_empty() {
658        let dir = ScratchDir::new("system-empty");
659        assert_eq!(
660            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::NORMAL),
661            None
662        );
663        assert_eq!(
664            system_font_file(dir.path(), &FontFamily::named("Roboto"), FontWeight::NORMAL),
665            None
666        );
667    }
668
669    #[test]
670    fn register_system_family_binds_the_generic_alias_to_the_platform_face() {
671        let dir = ScratchDir::new("system-family");
672        dir.write("Roboto-Regular.ttf", REGULAR);
673        dir.write("Roboto-Bold.ttf", BOLD);
674
675        let mut registry = SoftwareTextFontRegistry::new();
676        registry
677            .register_system_family(
678                dir.path(),
679                &FontFamily::SansSerif,
680                DEFAULT_SYSTEM_FAMILY_WEIGHTS,
681            )
682            .expect("system family loads");
683        let fonts = registry.into_font_set_or_default(&[]);
684
685        assert!(fonts.has_registered_family(&FontFamily::SansSerif));
686        let bold = fonts
687            .resolve(&style_for(&FontFamily::SansSerif, FontWeight::BOLD))
688            .expect("bold system face");
689        assert_eq!(bold.weight(), FontWeight::BOLD);
690        assert_eq!(bold.registered_family(), {
691            let key = fonts
692                .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
693                .expect("regular system face")
694                .registered_family();
695            key
696        });
697    }
698
699    #[test]
700    fn register_system_family_reports_an_absent_font_directory() {
701        let mut registry = SoftwareTextFontRegistry::new();
702        let error = registry
703            .register_system_family(
704                Path::new("/definitely/not/a/font/directory"),
705                &FontFamily::SansSerif,
706                DEFAULT_SYSTEM_FAMILY_WEIGHTS,
707            )
708            .expect_err("an absent directory cannot register");
709        assert!(
710            matches!(error, FontLoadError::NoSystemFontFile { .. }),
711            "{error}"
712        );
713        assert!(registry.is_empty());
714    }
715
716    #[test]
717    fn register_family_rejects_a_family_that_names_no_files() {
718        let mut registry = SoftwareTextFontRegistry::new();
719        let error = registry
720            .register_family(&FontFamily::named("Roboto"))
721            .expect_err("a named family has nothing to read");
722        assert!(matches!(error, FontLoadError::NotFileBacked), "{error}");
723    }
724
725    #[test]
726    fn loaded_typeface_families_register_their_single_file() {
727        let dir = ScratchDir::new("typeface");
728        let path = dir.write("Test-Regular.ttf", REGULAR);
729        let family = FontFamily::loaded_typeface_path(path.to_string_lossy().into_owned());
730
731        let mut registry = SoftwareTextFontRegistry::new();
732        registry.register_family(&family).expect("typeface loads");
733        let fonts = registry.into_font_set_or_default(&[]);
734
735        assert!(fonts.has_registered_family(&family));
736        assert!(fonts
737            .resolve(&style_for(&family, FontWeight::NORMAL))
738            .is_some_and(|font| font.registered_family().is_some()));
739    }
740}