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::{
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
33/// Directory Android keeps its system font files in.
34pub const ANDROID_SYSTEM_FONT_DIR: &str = "/system/fonts";
35
36/// The weights [`SoftwareTextFontRegistry::register_system_family`] registers
37/// when an app does not name its own: Compose's Regular/Medium/Bold set.
38pub const DEFAULT_SYSTEM_FAMILY_WEIGHTS: &[FontWeight] =
39    &[FontWeight::NORMAL, FontWeight::MEDIUM, FontWeight::BOLD];
40
41/// Why an app-supplied face could not be registered.
42///
43/// Every variant is recoverable: the caller logs it and keeps whatever faces
44/// did load, and resolution falls back to the default face for families that
45/// ended up with none.
46#[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/// Parsed app-supplied faces, on their way to a [`SoftwareTextFontSet`].
74///
75/// Register everything an app needs once at startup, then call
76/// [`SoftwareTextFontRegistry::into_font_set_or_default`]. Registration is
77/// where files are read and faces parsed; nothing after it touches the disk.
78#[derive(Clone, Default)]
79pub struct SoftwareTextFontRegistry {
80    faces: Vec<SoftwareTextFont>,
81    system_faces: Vec<(FontFamilyKey, FontWeight, FontStyle)>,
82}
83
84impl SoftwareTextFontRegistry {
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Register every face of a file-backed family, reading each file from the
90    /// filesystem.
91    ///
92    /// Each [`FontFile`]'s declared weight and style are what resolution
93    /// matches on, so one family can carry Regular/Medium/Bold/Italic faces and
94    /// a `FontWeight`/`FontStyle` picks between them. A family whose files all
95    /// fail to load registers nothing and reports the first failure; text
96    /// asking for it then falls back to the default face rather than
97    /// disappearing.
98    pub fn register_family(&mut self, family: &FontFamily) -> Result<(), FontLoadError> {
99        let files = font_files_for(family)?;
100        if files.is_empty() {
101            return Err(FontLoadError::EmptyFamily);
102        }
103
104        let mut reads = FontFileReads::default();
105        let mut first_error = None;
106        let mut loaded = 0usize;
107        for file in &files {
108            match self.register_read_face(
109                &mut reads,
110                family,
111                file.weight,
112                file.style,
113                Path::new(&file.path),
114            ) {
115                Ok(()) => loaded += 1,
116                Err(error) => first_error = first_error.or(Some(error)),
117            }
118        }
119
120        match first_error {
121            Some(error) if loaded == 0 => Err(error),
122            _ => Ok(()),
123        }
124    }
125
126    fn register_read_face(
127        &mut self,
128        reads: &mut FontFileReads,
129        family: &FontFamily,
130        weight: FontWeight,
131        style: FontStyle,
132        path: &Path,
133    ) -> Result<(), FontLoadError> {
134        let bytes = reads.read(path)?;
135        let face = SoftwareTextFont::from_registered_bytes(family, weight, style, bytes.to_vec())
136            .map_err(|source| FontLoadError::Parse {
137            path: path.to_path_buf(),
138            source,
139        })?;
140        self.faces.push(face);
141        Ok(())
142    }
143
144    /// Register one face read from an arbitrary stream.
145    ///
146    /// This is the seam for fonts that are not files on disk — an APK asset
147    /// opened through `AndroidApp::asset_manager()`, an archive entry, a
148    /// download cache. It mirrors
149    /// `SoftwareTextMeasurer::register_hyphenation_dictionary_reader`.
150    pub fn register_face_reader(
151        &mut self,
152        family: &FontFamily,
153        weight: FontWeight,
154        style: FontStyle,
155        reader: &mut impl Read,
156    ) -> Result<(), FontLoadError> {
157        let mut bytes = Vec::new();
158        reader
159            .read_to_end(&mut bytes)
160            .map_err(|source| FontLoadError::Read {
161                path: PathBuf::new(),
162                source,
163            })?;
164        self.register_face_bytes(family, weight, style, bytes)
165    }
166
167    /// Register one face from bytes the app already holds.
168    pub fn register_face_bytes(
169        &mut self,
170        family: &FontFamily,
171        weight: FontWeight,
172        style: FontStyle,
173        bytes: impl Into<Vec<u8>>,
174    ) -> Result<(), FontLoadError> {
175        let face = SoftwareTextFont::from_registered_bytes(family, weight, style, bytes)
176            .map_err(|source| FontLoadError::ParseBytes { source })?;
177        self.faces.push(face);
178        Ok(())
179    }
180
181    /// Register a face that belongs to no declared family.
182    ///
183    /// These are the fallbacks: they are eligible for any request that names no
184    /// family, and for a `Named` request their own `name` table decides.
185    pub fn register_fallback_bytes(
186        &mut self,
187        bytes: impl Into<Vec<u8>>,
188    ) -> Result<(), FontLoadError> {
189        let face = SoftwareTextFont::from_bytes(bytes)
190            .map_err(|source| FontLoadError::ParseBytes { source })?;
191        self.faces.push(face);
192        Ok(())
193    }
194
195    /// Register the platform's own face for a generic family alias, at each of
196    /// `weights`, so styles keep naming `FontFamily::SansSerif` and get the
197    /// real system typeface.
198    ///
199    /// Android backs `sans-serif` with a single variable `Roboto-Regular.ttf`
200    /// and describes each weight as a `wght` axis position on it, so most
201    /// devices resolve every weight to one file instanced several ways. Where a
202    /// build does ship weight-specific static files (`Roboto-Medium.ttf`), they
203    /// are preferred. Each weight is first resolved through
204    /// [`system_declared_weight`], so a request the platform's font config does
205    /// not declare registers the face Android would have returned for it rather
206    /// than a `wght` position Android cannot reach. Faces are registered in
207    /// `FontStyle::Normal`; an app that
208    /// wants a real italic rather than a synthesized slant should call
209    /// [`SoftwareTextFontRegistry::register_system_face`] for it, because each
210    /// extra face is another copy of the file's bytes.
211    pub fn register_system_family(
212        &mut self,
213        directory: impl AsRef<Path>,
214        family: &FontFamily,
215        weights: &[FontWeight],
216    ) -> Result<(), FontLoadError> {
217        let directory = directory.as_ref();
218        let mut reads = FontFileReads::default();
219        let mut first_error = None;
220        let mut loaded = 0usize;
221        for weight in weights {
222            match self.register_read_system_face(
223                &mut reads,
224                directory,
225                family,
226                *weight,
227                FontStyle::Normal,
228            ) {
229                Ok(()) => loaded += 1,
230                Err(error) => first_error = first_error.or(Some(error)),
231            }
232        }
233
234        match first_error {
235            Some(error) if loaded == 0 => Err(error),
236            _ => Ok(()),
237        }
238    }
239
240    /// Register one weight/style of a generic family alias from the platform's
241    /// font directory.
242    ///
243    /// `weight` is resolved through [`system_declared_weight`] before anything
244    /// is read, so the registered face is one the platform's font config
245    /// declares. A caller that wants an arbitrary `wght` position must supply
246    /// its own font file and register it as its own family — the system aliases
247    /// are the platform's, and only the platform's entries exist in them.
248    pub fn register_system_face(
249        &mut self,
250        directory: impl AsRef<Path>,
251        family: &FontFamily,
252        weight: FontWeight,
253        style: FontStyle,
254    ) -> Result<(), FontLoadError> {
255        self.register_read_system_face(
256            &mut FontFileReads::default(),
257            directory.as_ref(),
258            family,
259            weight,
260            style,
261        )
262    }
263
264    fn register_read_system_face(
265        &mut self,
266        reads: &mut FontFileReads,
267        directory: &Path,
268        family: &FontFamily,
269        weight: FontWeight,
270        style: FontStyle,
271    ) -> Result<(), FontLoadError> {
272        let weight = system_declared_weight(family, weight);
273        if self.has_system_face(family, weight, style) {
274            return Ok(());
275        }
276        let path = system_font_file(directory, family, weight).ok_or_else(|| {
277            FontLoadError::NoSystemFontFile {
278                directory: directory.to_path_buf(),
279            }
280        })?;
281        self.register_read_face(reads, family, weight, style, &path)?;
282        self.system_faces
283            .push((FontFamilyKey::of(family), weight, style));
284        Ok(())
285    }
286
287    fn has_system_face(&self, family: &FontFamily, weight: FontWeight, style: FontStyle) -> bool {
288        self.system_faces
289            .contains(&(FontFamilyKey::of(family), weight, style))
290    }
291
292    /// The faces registered so far.
293    pub fn faces(&self) -> &[SoftwareTextFont] {
294        &self.faces
295    }
296
297    pub fn is_empty(&self) -> bool {
298        self.faces.is_empty()
299    }
300
301    /// Finish, folding in the static byte slices from `AppLauncher::with_fonts`
302    /// as unregistered fallbacks and the embedded default face when nothing
303    /// else loaded.
304    ///
305    /// Registered faces come first, so when a request names no family and the
306    /// scores tie, a face the app declared wins over one it merely handed over
307    /// as bytes.
308    pub fn into_font_set_or_default(mut self, fonts: &[&[u8]]) -> SoftwareTextFontSet {
309        for bytes in fonts {
310            let _ = self.register_fallback_bytes((*bytes).to_vec());
311        }
312        if self.faces.is_empty()
313            && let Some(default_font) = default_software_text_font()
314        {
315            self.faces.push(default_font);
316        }
317        SoftwareTextFontSet::from_faces(self.faces)
318    }
319}
320
321#[derive(Default)]
322struct FontFileReads {
323    entries: Vec<(PathBuf, Arc<[u8]>)>,
324}
325
326impl FontFileReads {
327    fn read(&mut self, path: &Path) -> Result<Arc<[u8]>, FontLoadError> {
328        if let Some((_, bytes)) = self.entries.iter().find(|(read, _)| read == path) {
329            return Ok(Arc::clone(bytes));
330        }
331        let bytes: Arc<[u8]> = std::fs::read(path)
332            .map_err(|source| FontLoadError::Read {
333                path: path.to_path_buf(),
334                source,
335            })?
336            .into();
337        self.entries.push((path.to_path_buf(), Arc::clone(&bytes)));
338        Ok(bytes)
339    }
340}
341
342/// The weight a platform's own matcher resolves `weight` to for a generic
343/// family alias — never a value that family's font config does not declare.
344///
345/// Android's `sans-serif` is one variable `Roboto-Regular.ttf` described by
346/// `/system/etc/fonts.xml` as nine `<font weight="…">` entries, one per hundred,
347/// each pinning `wght` to that hundred. The `wght` axis is therefore reachable
348/// to the *font config*, not to a caller: an app asking `sans-serif` for 450
349/// gets whichever declared entry Minikin's matcher picks, and Minikin has no
350/// way to express a weight between two entries. Instancing the axis at 450
351/// anyway draws a face the platform cannot draw — text that measures and lays
352/// out perfectly and is simply heavier than every other app on the device.
353///
354/// The rule is `computeMatch` in `frameworks/minikin/libs/minikin/FontFamily.cpp`:
355///
356/// ```text
357/// int score = abs(style1.weight() / 100 - style2.weight() / 100);
358/// if (style1.slant() != style2.slant()) score += 2;
359/// ```
360///
361/// picked by `getClosestMatch`, which keeps a candidate only on `match <
362/// bestMatch`. Two consequences carry the behaviour, and both are load-bearing:
363///
364/// * The division is **integer**, so the request is truncated to its hundred
365///   before anything is compared. Against a full hundreds grid the nearest
366///   declared entry is therefore always the hundred *below* — 450 resolves to
367///   400 and 550 to 500, not by a tie-break but outright, and 599 resolves to
368///   500 too.
369/// * Where a request does tie between two declared entries — 500 against a
370///   `serif` declaring only 400 and 700 scores 1 and 2, but 600 scores 2 and 1,
371///   and a family declaring 300 and 500 ties at 400 — the strict `<` keeps the
372///   entry declared **first**. Android declares ascending, so a tie goes to the
373///   lighter face.
374///
375/// A slant mismatch costs 2, i.e. 200 weight units, which is why `style` never
376/// competes with `weight` here: this resolves within one slant, as
377/// [`SoftwareTextFontRegistry::register_system_face`] registers one.
378///
379/// Families this crate knows no system files for resolve to `weight` unchanged;
380/// there is no declared set to honour, and nothing will register for them.
381///
382/// This applies to the system-font path alone. A face an app supplies is its
383/// own font, not an entry in the platform's config, and stays instanceable at
384/// any axis value — that is what a variable font is for.
385pub fn system_declared_weight(family: &FontFamily, weight: FontWeight) -> FontWeight {
386    let Some(files) = system_family_files(family) else {
387        return weight;
388    };
389    closest_declared_weight(files.declared, weight).unwrap_or(weight)
390}
391
392fn closest_declared_weight(declared: &[u16], requested: FontWeight) -> Option<FontWeight> {
393    let mut best: Option<(u16, u16)> = None;
394    for candidate in declared {
395        let score = weight_match_score(*candidate, requested.value());
396        if best.is_none_or(|(_, best_score)| score < best_score) {
397            best = Some((*candidate, score));
398        }
399    }
400    best.map(|(candidate, _)| FontWeight(candidate))
401}
402
403fn weight_match_score(declared: u16, requested: u16) -> u16 {
404    (declared / 100).abs_diff(requested / 100)
405}
406
407/// The file a platform backs `family` with at `weight`, if one is present.
408///
409/// Weight-specific static files win when the build ships them; otherwise the
410/// family's regular file is returned and instanced on its `wght` axis at
411/// registration. `weight` is expected to be one the family declares — callers
412/// on the system path run it through [`system_declared_weight`] first.
413pub fn system_font_file(
414    directory: &Path,
415    family: &FontFamily,
416    weight: FontWeight,
417) -> Option<PathBuf> {
418    let files = system_family_files(family)?;
419    files
420        .weighted
421        .iter()
422        .filter(|(candidate_weight, _)| *candidate_weight == weight.value())
423        .map(|(_, name)| directory.join(name))
424        .chain(files.regular.iter().map(|name| directory.join(name)))
425        .find(|path| path.is_file())
426}
427
428struct SystemFamilyFiles {
429    regular: &'static [&'static str],
430    weighted: &'static [(u16, &'static str)],
431    declared: &'static [u16],
432}
433
434const DECLARED_HUNDREDS: &[u16] = &[100, 200, 300, 400, 500, 600, 700, 800, 900];
435
436fn system_family_files(family: &FontFamily) -> Option<SystemFamilyFiles> {
437    match family {
438        FontFamily::Default | FontFamily::SansSerif => Some(SystemFamilyFiles {
439            regular: &[
440                "Roboto-Regular.ttf",
441                "RobotoStatic-Regular.ttf",
442                "NotoSans-Regular.ttf",
443                "DroidSans.ttf",
444            ],
445            weighted: &[
446                (300, "Roboto-Light.ttf"),
447                (500, "Roboto-Medium.ttf"),
448                (700, "Roboto-Bold.ttf"),
449                (900, "Roboto-Black.ttf"),
450            ],
451            declared: DECLARED_HUNDREDS,
452        }),
453        FontFamily::Serif | FontFamily::Fantasy => Some(SystemFamilyFiles {
454            regular: &["NotoSerif-Regular.ttf", "DroidSerif-Regular.ttf"],
455            weighted: &[(700, "NotoSerif-Bold.ttf"), (700, "DroidSerif-Bold.ttf")],
456            declared: &[400, 700],
457        }),
458        FontFamily::Monospace => Some(SystemFamilyFiles {
459            regular: &[
460                "DroidSansMono.ttf",
461                "RobotoMono-Regular.ttf",
462                "CutiveMono-Regular.ttf",
463            ],
464            weighted: &[(700, "RobotoMono-Bold.ttf")],
465            declared: &[400, 700],
466        }),
467        FontFamily::Cursive => Some(SystemFamilyFiles {
468            regular: &["DancingScript-Regular.ttf"],
469            weighted: &[(700, "DancingScript-Bold.ttf")],
470            declared: &[400, 700],
471        }),
472        FontFamily::Named(_) | FontFamily::FileBacked(_) | FontFamily::LoadedTypeface(_) => None,
473    }
474}
475
476fn font_files_for(family: &FontFamily) -> Result<Vec<FontFile>, FontLoadError> {
477    match family {
478        FontFamily::FileBacked(file_backed) => Ok(file_backed.fonts.clone()),
479        FontFamily::LoadedTypeface(typeface) => Ok(vec![FontFile::new(typeface.path.clone())]),
480        _ => Err(FontLoadError::NotFileBacked),
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use std::io::Cursor;
487
488    use cranpose_ui::text::{SpanStyle, TextStyle};
489
490    use super::*;
491
492    const REGULAR: &[u8] = include_bytes!("../assets/NotoSansMerged.ttf");
493    const BOLD: &[u8] = include_bytes!("../assets/NotoSansBold.ttf");
494
495    fn style_for(family: &FontFamily, weight: FontWeight) -> TextStyle {
496        TextStyle {
497            span_style: SpanStyle {
498                font_family: Some(family.clone()),
499                font_weight: Some(weight),
500                ..Default::default()
501            },
502            ..Default::default()
503        }
504    }
505
506    struct ScratchDir(PathBuf);
507
508    impl ScratchDir {
509        fn new(name: &str) -> Self {
510            let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
511                .join("../../../target/test-output/cranpose-font-source")
512                .join(name);
513            let _ = std::fs::remove_dir_all(&path);
514            std::fs::create_dir_all(&path).expect("scratch directory");
515            Self(path)
516        }
517
518        fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
519            let path = self.0.join(name);
520            std::fs::write(&path, bytes).expect("scratch font file");
521            path
522        }
523
524        fn path(&self) -> &Path {
525            &self.0
526        }
527    }
528
529    impl Drop for ScratchDir {
530        fn drop(&mut self) {
531            let _ = std::fs::remove_dir_all(&self.0);
532        }
533    }
534
535    #[test]
536    fn register_family_picks_the_face_matching_the_requested_weight() {
537        let dir = ScratchDir::new("weights");
538        let regular = dir.write("Test-Regular.ttf", REGULAR);
539        let bold = dir.write("Test-Bold.ttf", BOLD);
540        let family = FontFamily::file_backed(vec![
541            FontFile::new(regular.to_string_lossy().into_owned()),
542            FontFile::new(bold.to_string_lossy().into_owned()).with_weight(FontWeight::BOLD),
543        ])
544        .expect("file-backed family");
545
546        let mut registry = SoftwareTextFontRegistry::new();
547        registry.register_family(&family).expect("family loads");
548        let fonts = registry.into_font_set_or_default(&[]);
549
550        let resolved_regular = fonts
551            .resolve(&style_for(&family, FontWeight::NORMAL))
552            .expect("regular face");
553        let resolved_bold = fonts
554            .resolve(&style_for(&family, FontWeight::BOLD))
555            .expect("bold face");
556
557        assert_eq!(resolved_regular.weight(), FontWeight::NORMAL);
558        assert_eq!(resolved_bold.weight(), FontWeight::BOLD);
559        assert_ne!(
560            resolved_regular.content_hash(),
561            resolved_bold.content_hash(),
562            "distinct faces must key the glyph atlas distinctly"
563        );
564    }
565
566    #[test]
567    fn register_family_honours_a_declared_weight_over_the_face_header() {
568        let dir = ScratchDir::new("declared");
569        let path = dir.write("Test-Regular.ttf", REGULAR);
570        let family = FontFamily::file_backed(vec![
571            FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight::MEDIUM),
572        ])
573        .expect("file-backed family");
574
575        let mut registry = SoftwareTextFontRegistry::new();
576        registry.register_family(&family).expect("family loads");
577        let fonts = registry.into_font_set_or_default(&[]);
578
579        let resolved = fonts
580            .resolve(&style_for(&family, FontWeight::MEDIUM))
581            .expect("declared face");
582        assert_eq!(resolved.weight(), FontWeight::MEDIUM);
583    }
584
585    #[test]
586    fn register_family_reports_a_missing_file_without_panicking() {
587        let dir = ScratchDir::new("missing");
588        let family = FontFamily::file_backed(vec![FontFile::new(
589            dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
590        )])
591        .expect("file-backed family");
592
593        let mut registry = SoftwareTextFontRegistry::new();
594        let error = registry
595            .register_family(&family)
596            .expect_err("a missing file cannot register");
597        assert!(matches!(error, FontLoadError::Read { .. }), "{error}");
598        assert!(registry.is_empty());
599    }
600
601    #[test]
602    fn register_family_reports_a_corrupt_file_without_panicking() {
603        let dir = ScratchDir::new("corrupt");
604        let path = dir.write("Corrupt.ttf", b"this is not a font");
605        let family =
606            FontFamily::file_backed(vec![FontFile::new(path.to_string_lossy().into_owned())])
607                .expect("file-backed family");
608
609        let mut registry = SoftwareTextFontRegistry::new();
610        let error = registry
611            .register_family(&family)
612            .expect_err("a corrupt file cannot register");
613        assert!(matches!(error, FontLoadError::Parse { .. }), "{error}");
614        assert!(registry.is_empty());
615    }
616
617    #[test]
618    #[cfg(feature = "embedded-default-font")]
619    fn a_family_that_failed_to_load_falls_back_to_the_default_face() {
620        let dir = ScratchDir::new("fallback");
621        let family = FontFamily::file_backed(vec![FontFile::new(
622            dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
623        )])
624        .expect("file-backed family");
625
626        let mut registry = SoftwareTextFontRegistry::new();
627        let _ = registry.register_family(&family);
628        let fonts = registry.into_font_set_or_default(&[]);
629
630        let resolved = fonts
631            .resolve(&style_for(&family, FontWeight::NORMAL))
632            .expect("fallback face");
633        assert_eq!(
634            resolved.content_hash(),
635            fonts.default_font().expect("default face").content_hash()
636        );
637    }
638
639    #[test]
640    fn a_partly_loadable_family_keeps_the_faces_that_did_load() {
641        let dir = ScratchDir::new("partial");
642        let regular = dir.write("Test-Regular.ttf", REGULAR);
643        let family = FontFamily::file_backed(vec![
644            FontFile::new(regular.to_string_lossy().into_owned()),
645            FontFile::new(dir.path().join("Absent.ttf").to_string_lossy().into_owned())
646                .with_weight(FontWeight::BOLD),
647        ])
648        .expect("file-backed family");
649
650        let mut registry = SoftwareTextFontRegistry::new();
651        registry
652            .register_family(&family)
653            .expect("one readable face is enough");
654        assert_eq!(registry.faces().len(), 1);
655    }
656
657    #[test]
658    fn register_face_reader_accepts_a_font_that_is_not_a_file() {
659        let family = FontFamily::named("Bundled");
660        let mut registry = SoftwareTextFontRegistry::new();
661        registry
662            .register_face_reader(
663                &family,
664                FontWeight::NORMAL,
665                FontStyle::Normal,
666                &mut Cursor::new(REGULAR.to_vec()),
667            )
668            .expect("streamed face loads");
669
670        let fonts = registry.into_font_set_or_default(&[]);
671        let resolved = fonts
672            .resolve(&style_for(&family, FontWeight::NORMAL))
673            .expect("streamed face");
674        assert_eq!(resolved.registered_family(), {
675            let mut expected = SoftwareTextFontRegistry::new();
676            expected
677                .register_face_bytes(
678                    &family,
679                    FontWeight::NORMAL,
680                    FontStyle::Normal,
681                    REGULAR.to_vec(),
682                )
683                .expect("face loads");
684            expected.faces()[0].registered_family()
685        });
686    }
687
688    #[test]
689    #[cfg(feature = "embedded-default-font")]
690    fn an_empty_registry_falls_back_to_the_embedded_default_face() {
691        let fonts = SoftwareTextFontRegistry::new().into_font_set_or_default(&[]);
692        assert!(
693            fonts.default_font().is_some(),
694            "the embedded default font must still serve apps that supply nothing"
695        );
696        assert!(
697            fonts
698                .resolve(&TextStyle::default())
699                .is_some_and(|font| font.registered_family().is_none())
700        );
701    }
702
703    #[test]
704    fn system_font_file_prefers_a_weight_specific_static_face() {
705        let dir = ScratchDir::new("system-static");
706        dir.write("Roboto-Regular.ttf", REGULAR);
707        let medium = dir.write("Roboto-Medium.ttf", BOLD);
708
709        assert_eq!(
710            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
711            Some(medium)
712        );
713    }
714
715    #[test]
716    fn system_font_file_falls_back_to_the_regular_face_for_other_weights() {
717        let dir = ScratchDir::new("system-regular");
718        let regular = dir.write("Roboto-Regular.ttf", REGULAR);
719
720        assert_eq!(
721            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
722            Some(regular)
723        );
724    }
725
726    #[test]
727    fn system_font_file_reports_nothing_when_the_directory_is_empty() {
728        let dir = ScratchDir::new("system-empty");
729        assert_eq!(
730            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::NORMAL),
731            None
732        );
733        assert_eq!(
734            system_font_file(dir.path(), &FontFamily::named("Roboto"), FontWeight::NORMAL),
735            None
736        );
737    }
738
739    #[test]
740    fn register_system_family_binds_the_generic_alias_to_the_platform_face() {
741        let dir = ScratchDir::new("system-family");
742        dir.write("Roboto-Regular.ttf", REGULAR);
743        dir.write("Roboto-Bold.ttf", BOLD);
744
745        let mut registry = SoftwareTextFontRegistry::new();
746        registry
747            .register_system_family(
748                dir.path(),
749                &FontFamily::SansSerif,
750                DEFAULT_SYSTEM_FAMILY_WEIGHTS,
751            )
752            .expect("system family loads");
753        let fonts = registry.into_font_set_or_default(&[]);
754
755        assert!(fonts.has_registered_family(&FontFamily::SansSerif));
756        let bold = fonts
757            .resolve(&style_for(&FontFamily::SansSerif, FontWeight::BOLD))
758            .expect("bold system face");
759        assert_eq!(bold.weight(), FontWeight::BOLD);
760        assert_eq!(bold.registered_family(), {
761            fonts
762                .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
763                .expect("regular system face")
764                .registered_family()
765        });
766    }
767
768    #[test]
769    fn register_system_family_reports_an_absent_font_directory() {
770        let mut registry = SoftwareTextFontRegistry::new();
771        let error = registry
772            .register_system_family(
773                Path::new("/definitely/not/a/font/directory"),
774                &FontFamily::SansSerif,
775                DEFAULT_SYSTEM_FAMILY_WEIGHTS,
776            )
777            .expect_err("an absent directory cannot register");
778        assert!(
779            matches!(error, FontLoadError::NoSystemFontFile { .. }),
780            "{error}"
781        );
782        assert!(registry.is_empty());
783    }
784
785    #[test]
786    fn a_system_weight_the_font_config_does_not_declare_resolves_to_the_declared_one() {
787        for (requested, expected) in [(450u16, 400u16), (550, 500), (401, 400), (599, 500)] {
788            assert_eq!(
789                system_declared_weight(&FontFamily::SansSerif, FontWeight(requested)),
790                FontWeight(expected),
791                "sans-serif {requested}"
792            );
793        }
794        for weight in DECLARED_HUNDREDS {
795            assert_eq!(
796                system_declared_weight(&FontFamily::SansSerif, FontWeight(*weight)),
797                FontWeight(*weight)
798            );
799        }
800        assert_eq!(
801            system_declared_weight(&FontFamily::SansSerif, FontWeight(50)),
802            FontWeight(100)
803        );
804        assert_eq!(
805            system_declared_weight(&FontFamily::SansSerif, FontWeight(1000)),
806            FontWeight(900)
807        );
808    }
809
810    #[test]
811    fn a_sparse_system_family_resolves_by_distance_and_keeps_the_lighter_face_on_a_tie() {
812        for (requested, expected) in [(500u16, 400u16), (550, 400), (600, 700), (650, 700)] {
813            assert_eq!(
814                system_declared_weight(&FontFamily::Serif, FontWeight(requested)),
815                FontWeight(expected),
816                "serif {requested}"
817            );
818        }
819        assert_eq!(
820            closest_declared_weight(&[300, 500], FontWeight(400)),
821            Some(FontWeight(300))
822        );
823        assert_eq!(
824            closest_declared_weight(&[500, 300], FontWeight(400)),
825            Some(FontWeight(500)),
826            "declaration order, not magnitude, is what breaks the tie"
827        );
828    }
829
830    #[test]
831    fn a_family_with_no_system_files_keeps_the_weight_it_was_given() {
832        assert_eq!(
833            system_declared_weight(&FontFamily::named("Roboto"), FontWeight(450)),
834            FontWeight(450)
835        );
836    }
837
838    #[test]
839    fn register_system_face_registers_the_declared_weight_not_the_requested_one() {
840        let dir = ScratchDir::new("system-undeclared-weight");
841        dir.write("Roboto-Regular.ttf", REGULAR);
842
843        let mut registry = SoftwareTextFontRegistry::new();
844        registry
845            .register_system_face(
846                dir.path(),
847                &FontFamily::SansSerif,
848                FontWeight(450),
849                FontStyle::Normal,
850            )
851            .expect("system face loads");
852        let fonts = registry.into_font_set_or_default(&[]);
853
854        let resolved = fonts
855            .resolve(&style_for(&FontFamily::SansSerif, FontWeight(450)))
856            .expect("a face for the off-grid request");
857        assert_eq!(
858            resolved.weight(),
859            FontWeight::NORMAL,
860            "450 must land on the 400 entry Android's matcher returns"
861        );
862
863        let mut declared = SoftwareTextFontRegistry::new();
864        declared
865            .register_system_face(
866                dir.path(),
867                &FontFamily::SansSerif,
868                FontWeight::NORMAL,
869                FontStyle::Normal,
870            )
871            .expect("system face loads");
872        let declared = declared.into_font_set_or_default(&[]);
873        assert_eq!(
874            resolved.content_hash(),
875            declared
876                .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
877                .expect("the 400 face")
878                .content_hash(),
879            "the off-grid request must produce the very same face, glyph masks included"
880        );
881    }
882
883    #[test]
884    fn system_requests_that_resolve_alike_register_one_face() {
885        let dir = ScratchDir::new("system-dedupe");
886        dir.write("Roboto-Regular.ttf", REGULAR);
887
888        let mut registry = SoftwareTextFontRegistry::new();
889        for weight in [400u16, 450, 499] {
890            registry
891                .register_system_face(
892                    dir.path(),
893                    &FontFamily::SansSerif,
894                    FontWeight(weight),
895                    FontStyle::Normal,
896                )
897                .expect("system face loads");
898        }
899
900        assert_eq!(
901            registry.faces().len(),
902            1,
903            "three requests for one declared entry are one face"
904        );
905    }
906
907    #[test]
908    fn an_app_registered_face_keeps_the_weight_it_declares() {
909        let dir = ScratchDir::new("app-off-grid");
910        let path = dir.write("Test-Regular.ttf", REGULAR);
911        let family = FontFamily::file_backed(vec![
912            FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight(450)),
913        ])
914        .expect("file-backed family");
915
916        let mut registry = SoftwareTextFontRegistry::new();
917        registry.register_family(&family).expect("family loads");
918        let fonts = registry.into_font_set_or_default(&[]);
919
920        assert_eq!(
921            fonts
922                .resolve(&style_for(&family, FontWeight(450)))
923                .expect("app face")
924                .weight(),
925            FontWeight(450)
926        );
927    }
928
929    #[test]
930    fn register_family_rejects_a_family_that_names_no_files() {
931        let mut registry = SoftwareTextFontRegistry::new();
932        let error = registry
933            .register_family(&FontFamily::named("Roboto"))
934            .expect_err("a named family has nothing to read");
935        assert!(matches!(error, FontLoadError::NotFileBacked), "{error}");
936    }
937
938    #[test]
939    fn loaded_typeface_families_register_their_single_file() {
940        let dir = ScratchDir::new("typeface");
941        let path = dir.write("Test-Regular.ttf", REGULAR);
942        let family = FontFamily::loaded_typeface_path(path.to_string_lossy().into_owned());
943
944        let mut registry = SoftwareTextFontRegistry::new();
945        registry.register_family(&family).expect("typeface loads");
946        let fonts = registry.into_font_set_or_default(&[]);
947
948        assert!(fonts.has_registered_family(&family));
949        assert!(
950            fonts
951                .resolve(&style_for(&family, FontWeight::NORMAL))
952                .is_some_and(|font| font.registered_family().is_some())
953        );
954    }
955}