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
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    /// Register every face of a file-backed family, reading each file from the
112    /// filesystem.
113    ///
114    /// Each [`FontFile`]'s declared weight and style are what resolution
115    /// matches on, so one family can carry Regular/Medium/Bold/Italic faces and
116    /// a `FontWeight`/`FontStyle` picks between them. A family whose files all
117    /// fail to load registers nothing and reports the first failure; text
118    /// asking for it then falls back to the default face rather than
119    /// disappearing.
120    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    /// Register one face read from an arbitrary stream.
159    ///
160    /// This is the seam for fonts that are not files on disk — an APK asset
161    /// opened through `AndroidApp::asset_manager()`, an archive entry, a
162    /// download cache. It mirrors
163    /// `SoftwareTextMeasurer::register_hyphenation_dictionary_reader`.
164    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    /// Register one face from bytes the app already holds.
182    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    /// Register a face that belongs to no declared family.
196    ///
197    /// These are the fallbacks: they are eligible for any request that names no
198    /// family, and for a `Named` request their own `name` table decides.
199    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    /// Register the platform's own face for a generic family alias, at each of
210    /// `weights`, so styles keep naming `FontFamily::SansSerif` and get the
211    /// real system typeface.
212    ///
213    /// Android backs `sans-serif` with a single variable `Roboto-Regular.ttf`
214    /// and describes each weight as a `wght` axis position on it, so most
215    /// devices resolve every weight to one file instanced several ways. Where a
216    /// build does ship weight-specific static files (`Roboto-Medium.ttf`), they
217    /// are preferred. Each weight is first resolved through
218    /// [`system_declared_weight`], so a request the platform's font config does
219    /// not declare registers the face Android would have returned for it rather
220    /// than a `wght` position Android cannot reach. Faces are registered in
221    /// `FontStyle::Normal`; an app that
222    /// wants a real italic rather than a synthesized slant should call
223    /// [`SoftwareTextFontRegistry::register_system_face`] for it, because each
224    /// extra face is another copy of the file's bytes.
225    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    /// Register one weight/style of a generic family alias from the platform's
247    /// font directory.
248    ///
249    /// `weight` is resolved through [`system_declared_weight`] before anything
250    /// is read, so the registered face is one the platform's font config
251    /// declares. A caller that wants an arbitrary `wght` position must supply
252    /// its own font file and register it as its own family — the system aliases
253    /// are the platform's, and only the platform's entries exist in them.
254    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    /// The faces registered so far.
299    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    /// Finish, folding in the static byte slices from `AppLauncher::with_fonts`
308    /// as unregistered fallbacks and the embedded default face when nothing
309    /// else loaded.
310    ///
311    /// Registered faces come first, so when a request names no family and the
312    /// scores tie, a face the app declared wins over one it merely handed over
313    /// as bytes.
314    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
348/// The weight a platform's own matcher resolves `weight` to for a generic
349/// family alias — never a value that family's font config does not declare.
350///
351/// Android's `sans-serif` is one variable `Roboto-Regular.ttf` described by
352/// `/system/etc/fonts.xml` as nine `<font weight="…">` entries, one per hundred,
353/// each pinning `wght` to that hundred. The `wght` axis is therefore reachable
354/// to the *font config*, not to a caller: an app asking `sans-serif` for 450
355/// gets whichever declared entry Minikin's matcher picks, and Minikin has no
356/// way to express a weight between two entries. Instancing the axis at 450
357/// anyway draws a face the platform cannot draw — text that measures and lays
358/// out perfectly and is simply heavier than every other app on the device.
359///
360/// The rule is `computeMatch` in `frameworks/minikin/libs/minikin/FontFamily.cpp`:
361///
362/// ```text
363/// int score = abs(style1.weight() / 100 - style2.weight() / 100);
364/// if (style1.slant() != style2.slant()) score += 2;
365/// ```
366///
367/// picked by `getClosestMatch`, which keeps a candidate only on `match <
368/// bestMatch`. Two consequences carry the behaviour, and both are load-bearing:
369///
370/// * The division is **integer**, so the request is truncated to its hundred
371///   before anything is compared. Against a full hundreds grid the nearest
372///   declared entry is therefore always the hundred *below* — 450 resolves to
373///   400 and 550 to 500, not by a tie-break but outright, and 599 resolves to
374///   500 too.
375/// * Where a request does tie between two declared entries — 500 against a
376///   `serif` declaring only 400 and 700 scores 1 and 2, but 600 scores 2 and 1,
377///   and a family declaring 300 and 500 ties at 400 — the strict `<` keeps the
378///   entry declared **first**. Android declares ascending, so a tie goes to the
379///   lighter face.
380///
381/// A slant mismatch costs 2, i.e. 200 weight units, which is why `style` never
382/// competes with `weight` here: this resolves within one slant, as
383/// [`SoftwareTextFontRegistry::register_system_face`] registers one.
384///
385/// Families this crate knows no system files for resolve to `weight` unchanged;
386/// there is no declared set to honour, and nothing will register for them.
387///
388/// This applies to the system-font path alone. A face an app supplies is its
389/// own font, not an entry in the platform's config, and stays instanceable at
390/// any axis value — that is what a variable font is for.
391pub 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
413/// The file a platform backs `family` with at `weight`, if one is present.
414///
415/// Weight-specific static files win when the build ships them; otherwise the
416/// family's regular file is returned and instanced on its `wght` axis at
417/// registration. `weight` is expected to be one the family declares — callers
418/// on the system path run it through [`system_declared_weight`] first.
419pub 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}