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, FontFamilyKey, SoftwareTextFont, SoftwareTextFontError,
28    SoftwareTextFontSet,
29};
30
31/// Directory Android keeps its system font files in.
32pub const ANDROID_SYSTEM_FONT_DIR: &str = "/system/fonts";
33
34/// The weights [`SoftwareTextFontRegistry::register_system_family`] registers
35/// when an app does not name its own: Compose's Regular/Medium/Bold set.
36pub const DEFAULT_SYSTEM_FAMILY_WEIGHTS: &[FontWeight] =
37    &[FontWeight::NORMAL, FontWeight::MEDIUM, FontWeight::BOLD];
38
39/// Why an app-supplied face could not be registered.
40///
41/// Every variant is recoverable: the caller logs it and keeps whatever faces
42/// did load, and resolution falls back to the default face for families that
43/// ended up with none.
44#[derive(Debug, thiserror::Error)]
45pub enum FontLoadError {
46    #[error("font family declares no faces")]
47    EmptyFamily,
48    #[error("font family is not backed by files, so it has nothing to load")]
49    NotFileBacked,
50    #[error("no system font file for this family under {directory}")]
51    NoSystemFontFile { directory: PathBuf },
52    #[error("failed to read font file {path}: {source}")]
53    Read {
54        path: PathBuf,
55        #[source]
56        source: std::io::Error,
57    },
58    #[error("failed to parse font file {path}: {source}")]
59    Parse {
60        path: PathBuf,
61        #[source]
62        source: SoftwareTextFontError,
63    },
64    #[error("failed to parse font bytes: {source}")]
65    ParseBytes {
66        #[source]
67        source: SoftwareTextFontError,
68    },
69}
70
71/// Parsed app-supplied faces, on their way to a [`SoftwareTextFontSet`].
72///
73/// Register everything an app needs once at startup, then call
74/// [`SoftwareTextFontRegistry::into_font_set_or_default`]. Registration is
75/// where files are read and faces parsed; nothing after it touches the disk.
76#[derive(Clone, Default)]
77pub struct SoftwareTextFontRegistry {
78    faces: Vec<SoftwareTextFont>,
79    /// The declared entries the system-font path has already loaded, so a
80    /// second request resolving to the same one does not copy the file again.
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        // Resolve first, register second: what lands in the set is a weight the
273        // platform's font config declares, instanced at the axis position that
274        // config pins for it. Asking for 450 registers Android's 400 face, so
275        // no caller can reach a face the platform cannot draw.
276        let weight = system_declared_weight(family, weight);
277        if self.has_system_face(family, weight, style) {
278            // Two requests that resolve to one declared entry are one face, as
279            // they are one `<font>` element to Android. Registering it twice
280            // would copy the file's bytes again — 2.3 MiB for Wear's Roboto —
281            // for a face byte-identical to the one already here.
282            return Ok(());
283        }
284        let path = system_font_file(directory, family, weight).ok_or_else(|| {
285            FontLoadError::NoSystemFontFile {
286                directory: directory.to_path_buf(),
287            }
288        })?;
289        self.register_read_face(reads, family, weight, style, &path)?;
290        self.system_faces
291            .push((FontFamilyKey::of(family), weight, style));
292        Ok(())
293    }
294
295    /// Whether the system path already registered this declared entry.
296    ///
297    /// Only faces this registry loaded from the platform's font directory
298    /// count. A face an app registered under the same generic family is its own
299    /// font and must not be shadowed by one of ours.
300    fn has_system_face(&self, family: &FontFamily, weight: FontWeight, style: FontStyle) -> bool {
301        self.system_faces
302            .contains(&(FontFamilyKey::of(family), weight, style))
303    }
304
305    /// The faces registered so far.
306    pub fn faces(&self) -> &[SoftwareTextFont] {
307        &self.faces
308    }
309
310    pub fn is_empty(&self) -> bool {
311        self.faces.is_empty()
312    }
313
314    /// Finish, folding in the static byte slices from `AppLauncher::with_fonts`
315    /// as unregistered fallbacks and the embedded default face when nothing
316    /// else loaded.
317    ///
318    /// Registered faces come first, so when a request names no family and the
319    /// scores tie, a face the app declared wins over one it merely handed over
320    /// as bytes.
321    pub fn into_font_set_or_default(mut self, fonts: &[&[u8]]) -> SoftwareTextFontSet {
322        for bytes in fonts {
323            // Unparseable app bytes have always been skipped rather than fatal.
324            let _ = self.register_fallback_bytes((*bytes).to_vec());
325        }
326        if self.faces.is_empty() {
327            if let Some(default_font) = default_software_text_font() {
328                self.faces.push(default_font);
329            }
330        }
331        SoftwareTextFontSet::from_faces(self.faces)
332    }
333}
334
335/// Font files read during one registration call.
336///
337/// A family that instances a single variable file at several weights names that
338/// file once per weight, and on Wear the file backing `sans-serif` is 2.3 MiB —
339/// worth reading once. Each face still needs its own copy of the bytes, because
340/// `ab_glyph` bakes the axis values into the parsed face.
341#[derive(Default)]
342struct FontFileReads {
343    entries: Vec<(PathBuf, Arc<[u8]>)>,
344}
345
346impl FontFileReads {
347    fn read(&mut self, path: &Path) -> Result<Arc<[u8]>, FontLoadError> {
348        if let Some((_, bytes)) = self.entries.iter().find(|(read, _)| read == path) {
349            return Ok(Arc::clone(bytes));
350        }
351        let bytes: Arc<[u8]> = std::fs::read(path)
352            .map_err(|source| FontLoadError::Read {
353                path: path.to_path_buf(),
354                source,
355            })?
356            .into();
357        self.entries.push((path.to_path_buf(), Arc::clone(&bytes)));
358        Ok(bytes)
359    }
360}
361
362/// The weight a platform's own matcher resolves `weight` to for a generic
363/// family alias — never a value that family's font config does not declare.
364///
365/// Android's `sans-serif` is one variable `Roboto-Regular.ttf` described by
366/// `/system/etc/fonts.xml` as nine `<font weight="…">` entries, one per hundred,
367/// each pinning `wght` to that hundred. The `wght` axis is therefore reachable
368/// to the *font config*, not to a caller: an app asking `sans-serif` for 450
369/// gets whichever declared entry Minikin's matcher picks, and Minikin has no
370/// way to express a weight between two entries. Instancing the axis at 450
371/// anyway draws a face the platform cannot draw — text that measures and lays
372/// out perfectly and is simply heavier than every other app on the device.
373///
374/// The rule is `computeMatch` in `frameworks/minikin/libs/minikin/FontFamily.cpp`:
375///
376/// ```text
377/// int score = abs(style1.weight() / 100 - style2.weight() / 100);
378/// if (style1.slant() != style2.slant()) score += 2;
379/// ```
380///
381/// picked by `getClosestMatch`, which keeps a candidate only on `match <
382/// bestMatch`. Two consequences carry the behaviour, and both are load-bearing:
383///
384/// * The division is **integer**, so the request is truncated to its hundred
385///   before anything is compared. Against a full hundreds grid the nearest
386///   declared entry is therefore always the hundred *below* — 450 resolves to
387///   400 and 550 to 500, not by a tie-break but outright, and 599 resolves to
388///   500 too.
389/// * Where a request does tie between two declared entries — 500 against a
390///   `serif` declaring only 400 and 700 scores 1 and 2, but 600 scores 2 and 1,
391///   and a family declaring 300 and 500 ties at 400 — the strict `<` keeps the
392///   entry declared **first**. Android declares ascending, so a tie goes to the
393///   lighter face.
394///
395/// A slant mismatch costs 2, i.e. 200 weight units, which is why `style` never
396/// competes with `weight` here: this resolves within one slant, as
397/// [`SoftwareTextFontRegistry::register_system_face`] registers one.
398///
399/// Families this crate knows no system files for resolve to `weight` unchanged;
400/// there is no declared set to honour, and nothing will register for them.
401///
402/// This applies to the system-font path alone. A face an app supplies is its
403/// own font, not an entry in the platform's config, and stays instanceable at
404/// any axis value — that is what a variable font is for.
405pub fn system_declared_weight(family: &FontFamily, weight: FontWeight) -> FontWeight {
406    let Some(files) = system_family_files(family) else {
407        return weight;
408    };
409    closest_declared_weight(files.declared, weight).unwrap_or(weight)
410}
411
412/// `getClosestMatch` over a declared weight set, at one slant.
413fn closest_declared_weight(declared: &[u16], requested: FontWeight) -> Option<FontWeight> {
414    let mut best: Option<(u16, u16)> = None;
415    for candidate in declared {
416        let score = weight_match_score(*candidate, requested.value());
417        // Strictly better only, so the first-declared entry keeps a tie.
418        if best.is_none_or(|(_, best_score)| score < best_score) {
419            best = Some((*candidate, score));
420        }
421    }
422    best.map(|(candidate, _)| FontWeight(candidate))
423}
424
425/// Minikin's `computeMatch`, weight half — integer hundreds, then distance.
426fn weight_match_score(declared: u16, requested: u16) -> u16 {
427    (declared / 100).abs_diff(requested / 100)
428}
429
430/// The file a platform backs `family` with at `weight`, if one is present.
431///
432/// Weight-specific static files win when the build ships them; otherwise the
433/// family's regular file is returned and instanced on its `wght` axis at
434/// registration. `weight` is expected to be one the family declares — callers
435/// on the system path run it through [`system_declared_weight`] first.
436pub fn system_font_file(
437    directory: &Path,
438    family: &FontFamily,
439    weight: FontWeight,
440) -> Option<PathBuf> {
441    let files = system_family_files(family)?;
442    files
443        .weighted
444        .iter()
445        .filter(|(candidate_weight, _)| *candidate_weight == weight.value())
446        .map(|(_, name)| directory.join(name))
447        .chain(files.regular.iter().map(|name| directory.join(name)))
448        .find(|path| path.is_file())
449}
450
451/// Files a platform is known to back a generic family with, best first, and the
452/// weights its font config declares for that alias.
453struct SystemFamilyFiles {
454    regular: &'static [&'static str],
455    weighted: &'static [(u16, &'static str)],
456    /// Every `weight` an Android `<family>` element declares for this alias, in
457    /// declaration order — which is what
458    /// [`system_declared_weight`] matches against, and the order its
459    /// tie-break depends on.
460    declared: &'static [u16],
461}
462
463/// The weights Android declares for an alias its font config backs with one
464/// variable file: an entry per hundred, each naming the same file at a
465/// different `wght` axis position.
466const DECLARED_HUNDREDS: &[u16] = &[100, 200, 300, 400, 500, 600, 700, 800, 900];
467
468fn system_family_files(family: &FontFamily) -> Option<SystemFamilyFiles> {
469    // Names come from Android's `/system/fonts`; the alias-to-file mapping is
470    // the one `/system/etc/fonts.xml` describes, which that file itself warns
471    // third parties not to parse.
472    match family {
473        FontFamily::Default | FontFamily::SansSerif => Some(SystemFamilyFiles {
474            regular: &[
475                "Roboto-Regular.ttf",
476                "RobotoStatic-Regular.ttf",
477                "NotoSans-Regular.ttf",
478                "DroidSans.ttf",
479            ],
480            weighted: &[
481                (300, "Roboto-Light.ttf"),
482                (500, "Roboto-Medium.ttf"),
483                (700, "Roboto-Bold.ttf"),
484                (900, "Roboto-Black.ttf"),
485            ],
486            declared: DECLARED_HUNDREDS,
487        }),
488        // Android aliases `fantasy` to `serif`.
489        FontFamily::Serif | FontFamily::Fantasy => Some(SystemFamilyFiles {
490            regular: &["NotoSerif-Regular.ttf", "DroidSerif-Regular.ttf"],
491            weighted: &[(700, "NotoSerif-Bold.ttf"), (700, "DroidSerif-Bold.ttf")],
492            declared: &[400, 700],
493        }),
494        FontFamily::Monospace => Some(SystemFamilyFiles {
495            regular: &[
496                "DroidSansMono.ttf",
497                "RobotoMono-Regular.ttf",
498                "CutiveMono-Regular.ttf",
499            ],
500            weighted: &[(700, "RobotoMono-Bold.ttf")],
501            declared: &[400, 700],
502        }),
503        FontFamily::Cursive => Some(SystemFamilyFiles {
504            regular: &["DancingScript-Regular.ttf"],
505            weighted: &[(700, "DancingScript-Bold.ttf")],
506            declared: &[400, 700],
507        }),
508        FontFamily::Named(_) | FontFamily::FileBacked(_) | FontFamily::LoadedTypeface(_) => None,
509    }
510}
511
512fn font_files_for(family: &FontFamily) -> Result<Vec<FontFile>, FontLoadError> {
513    match family {
514        FontFamily::FileBacked(file_backed) => Ok(file_backed.fonts.clone()),
515        FontFamily::LoadedTypeface(typeface) => Ok(vec![FontFile::new(typeface.path.clone())]),
516        _ => Err(FontLoadError::NotFileBacked),
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use cranpose_ui::text::{SpanStyle, TextStyle};
524    use std::io::Cursor;
525
526    const REGULAR: &[u8] = include_bytes!("../assets/NotoSansMerged.ttf");
527    const BOLD: &[u8] = include_bytes!("../assets/NotoSansBold.ttf");
528
529    fn style_for(family: &FontFamily, weight: FontWeight) -> TextStyle {
530        TextStyle {
531            span_style: SpanStyle {
532                font_family: Some(family.clone()),
533                font_weight: Some(weight),
534                ..Default::default()
535            },
536            ..Default::default()
537        }
538    }
539
540    /// A directory that removes itself, so font-loading tests can exercise real
541    /// filesystem failures instead of a stubbed reader. Lives under the
542    /// workspace `target/test-output`, never tmpfs.
543    struct ScratchDir(PathBuf);
544
545    impl ScratchDir {
546        fn new(name: &str) -> Self {
547            let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
548                .join("../../../target/test-output/cranpose-font-source")
549                .join(name);
550            let _ = std::fs::remove_dir_all(&path);
551            std::fs::create_dir_all(&path).expect("scratch directory");
552            Self(path)
553        }
554
555        fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
556            let path = self.0.join(name);
557            std::fs::write(&path, bytes).expect("scratch font file");
558            path
559        }
560
561        fn path(&self) -> &Path {
562            &self.0
563        }
564    }
565
566    impl Drop for ScratchDir {
567        fn drop(&mut self) {
568            let _ = std::fs::remove_dir_all(&self.0);
569        }
570    }
571
572    #[test]
573    fn register_family_picks_the_face_matching_the_requested_weight() {
574        let dir = ScratchDir::new("weights");
575        let regular = dir.write("Test-Regular.ttf", REGULAR);
576        let bold = dir.write("Test-Bold.ttf", BOLD);
577        let family = FontFamily::file_backed(vec![
578            FontFile::new(regular.to_string_lossy().into_owned()),
579            FontFile::new(bold.to_string_lossy().into_owned()).with_weight(FontWeight::BOLD),
580        ])
581        .expect("file-backed family");
582
583        let mut registry = SoftwareTextFontRegistry::new();
584        registry.register_family(&family).expect("family loads");
585        let fonts = registry.into_font_set_or_default(&[]);
586
587        let resolved_regular = fonts
588            .resolve(&style_for(&family, FontWeight::NORMAL))
589            .expect("regular face");
590        let resolved_bold = fonts
591            .resolve(&style_for(&family, FontWeight::BOLD))
592            .expect("bold face");
593
594        assert_eq!(resolved_regular.weight(), FontWeight::NORMAL);
595        assert_eq!(resolved_bold.weight(), FontWeight::BOLD);
596        assert_ne!(
597            resolved_regular.content_hash(),
598            resolved_bold.content_hash(),
599            "distinct faces must key the glyph atlas distinctly"
600        );
601    }
602
603    #[test]
604    fn register_family_honours_a_declared_weight_over_the_face_header() {
605        let dir = ScratchDir::new("declared");
606        let path = dir.write("Test-Regular.ttf", REGULAR);
607        let family =
608            FontFamily::file_backed(vec![
609                FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight::MEDIUM)
610            ])
611            .expect("file-backed family");
612
613        let mut registry = SoftwareTextFontRegistry::new();
614        registry.register_family(&family).expect("family loads");
615        let fonts = registry.into_font_set_or_default(&[]);
616
617        let resolved = fonts
618            .resolve(&style_for(&family, FontWeight::MEDIUM))
619            .expect("declared face");
620        assert_eq!(resolved.weight(), FontWeight::MEDIUM);
621    }
622
623    #[test]
624    fn register_family_reports_a_missing_file_without_panicking() {
625        let dir = ScratchDir::new("missing");
626        let family = FontFamily::file_backed(vec![FontFile::new(
627            dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
628        )])
629        .expect("file-backed family");
630
631        let mut registry = SoftwareTextFontRegistry::new();
632        let error = registry
633            .register_family(&family)
634            .expect_err("a missing file cannot register");
635        assert!(matches!(error, FontLoadError::Read { .. }), "{error}");
636        assert!(registry.is_empty());
637    }
638
639    #[test]
640    fn register_family_reports_a_corrupt_file_without_panicking() {
641        let dir = ScratchDir::new("corrupt");
642        let path = dir.write("Corrupt.ttf", b"this is not a font");
643        let family =
644            FontFamily::file_backed(vec![FontFile::new(path.to_string_lossy().into_owned())])
645                .expect("file-backed family");
646
647        let mut registry = SoftwareTextFontRegistry::new();
648        let error = registry
649            .register_family(&family)
650            .expect_err("a corrupt file cannot register");
651        assert!(matches!(error, FontLoadError::Parse { .. }), "{error}");
652        assert!(registry.is_empty());
653    }
654
655    #[test]
656    #[cfg(feature = "embedded-default-font")]
657    fn a_family_that_failed_to_load_falls_back_to_the_default_face() {
658        let dir = ScratchDir::new("fallback");
659        let family = FontFamily::file_backed(vec![FontFile::new(
660            dir.path().join("Absent.ttf").to_string_lossy().into_owned(),
661        )])
662        .expect("file-backed family");
663
664        let mut registry = SoftwareTextFontRegistry::new();
665        let _ = registry.register_family(&family);
666        let fonts = registry.into_font_set_or_default(&[]);
667
668        let resolved = fonts
669            .resolve(&style_for(&family, FontWeight::NORMAL))
670            .expect("fallback face");
671        assert_eq!(
672            resolved.content_hash(),
673            fonts.default_font().expect("default face").content_hash()
674        );
675    }
676
677    #[test]
678    fn a_partly_loadable_family_keeps_the_faces_that_did_load() {
679        let dir = ScratchDir::new("partial");
680        let regular = dir.write("Test-Regular.ttf", REGULAR);
681        let family = FontFamily::file_backed(vec![
682            FontFile::new(regular.to_string_lossy().into_owned()),
683            FontFile::new(dir.path().join("Absent.ttf").to_string_lossy().into_owned())
684                .with_weight(FontWeight::BOLD),
685        ])
686        .expect("file-backed family");
687
688        let mut registry = SoftwareTextFontRegistry::new();
689        registry
690            .register_family(&family)
691            .expect("one readable face is enough");
692        assert_eq!(registry.faces().len(), 1);
693    }
694
695    #[test]
696    fn register_face_reader_accepts_a_font_that_is_not_a_file() {
697        let family = FontFamily::named("Bundled");
698        let mut registry = SoftwareTextFontRegistry::new();
699        registry
700            .register_face_reader(
701                &family,
702                FontWeight::NORMAL,
703                FontStyle::Normal,
704                &mut Cursor::new(REGULAR.to_vec()),
705            )
706            .expect("streamed face loads");
707
708        let fonts = registry.into_font_set_or_default(&[]);
709        let resolved = fonts
710            .resolve(&style_for(&family, FontWeight::NORMAL))
711            .expect("streamed face");
712        assert_eq!(resolved.registered_family(), {
713            let mut expected = SoftwareTextFontRegistry::new();
714            expected
715                .register_face_bytes(
716                    &family,
717                    FontWeight::NORMAL,
718                    FontStyle::Normal,
719                    REGULAR.to_vec(),
720                )
721                .expect("face loads");
722            expected.faces()[0].registered_family()
723        });
724    }
725
726    #[test]
727    #[cfg(feature = "embedded-default-font")]
728    fn an_empty_registry_falls_back_to_the_embedded_default_face() {
729        let fonts = SoftwareTextFontRegistry::new().into_font_set_or_default(&[]);
730        assert!(
731            fonts.default_font().is_some(),
732            "the embedded default font must still serve apps that supply nothing"
733        );
734        assert!(fonts
735            .resolve(&TextStyle::default())
736            .is_some_and(|font| font.registered_family().is_none()));
737    }
738
739    #[test]
740    fn system_font_file_prefers_a_weight_specific_static_face() {
741        let dir = ScratchDir::new("system-static");
742        dir.write("Roboto-Regular.ttf", REGULAR);
743        let medium = dir.write("Roboto-Medium.ttf", BOLD);
744
745        assert_eq!(
746            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
747            Some(medium)
748        );
749    }
750
751    #[test]
752    fn system_font_file_falls_back_to_the_regular_face_for_other_weights() {
753        let dir = ScratchDir::new("system-regular");
754        let regular = dir.write("Roboto-Regular.ttf", REGULAR);
755
756        assert_eq!(
757            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::MEDIUM),
758            Some(regular)
759        );
760    }
761
762    #[test]
763    fn system_font_file_reports_nothing_when_the_directory_is_empty() {
764        let dir = ScratchDir::new("system-empty");
765        assert_eq!(
766            system_font_file(dir.path(), &FontFamily::SansSerif, FontWeight::NORMAL),
767            None
768        );
769        assert_eq!(
770            system_font_file(dir.path(), &FontFamily::named("Roboto"), FontWeight::NORMAL),
771            None
772        );
773    }
774
775    #[test]
776    fn register_system_family_binds_the_generic_alias_to_the_platform_face() {
777        let dir = ScratchDir::new("system-family");
778        dir.write("Roboto-Regular.ttf", REGULAR);
779        dir.write("Roboto-Bold.ttf", BOLD);
780
781        let mut registry = SoftwareTextFontRegistry::new();
782        registry
783            .register_system_family(
784                dir.path(),
785                &FontFamily::SansSerif,
786                DEFAULT_SYSTEM_FAMILY_WEIGHTS,
787            )
788            .expect("system family loads");
789        let fonts = registry.into_font_set_or_default(&[]);
790
791        assert!(fonts.has_registered_family(&FontFamily::SansSerif));
792        let bold = fonts
793            .resolve(&style_for(&FontFamily::SansSerif, FontWeight::BOLD))
794            .expect("bold system face");
795        assert_eq!(bold.weight(), FontWeight::BOLD);
796        assert_eq!(bold.registered_family(), {
797            let key = fonts
798                .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
799                .expect("regular system face")
800                .registered_family();
801            key
802        });
803    }
804
805    #[test]
806    fn register_system_family_reports_an_absent_font_directory() {
807        let mut registry = SoftwareTextFontRegistry::new();
808        let error = registry
809            .register_system_family(
810                Path::new("/definitely/not/a/font/directory"),
811                &FontFamily::SansSerif,
812                DEFAULT_SYSTEM_FAMILY_WEIGHTS,
813            )
814            .expect_err("an absent directory cannot register");
815        assert!(
816            matches!(error, FontLoadError::NoSystemFontFile { .. }),
817            "{error}"
818        );
819        assert!(registry.is_empty());
820    }
821
822    #[test]
823    fn a_system_weight_the_font_config_does_not_declare_resolves_to_the_declared_one() {
824        // Minikin truncates to the hundred before it compares, so against a
825        // full hundreds grid the entry below always wins outright.
826        for (requested, expected) in [(450u16, 400u16), (550, 500), (401, 400), (599, 500)] {
827            assert_eq!(
828                system_declared_weight(&FontFamily::SansSerif, FontWeight(requested)),
829                FontWeight(expected),
830                "sans-serif {requested}"
831            );
832        }
833        // A weight the config does declare is returned untouched.
834        for weight in DECLARED_HUNDREDS {
835            assert_eq!(
836                system_declared_weight(&FontFamily::SansSerif, FontWeight(*weight)),
837                FontWeight(*weight)
838            );
839        }
840        // Off the ends there is nothing below to fall to.
841        assert_eq!(
842            system_declared_weight(&FontFamily::SansSerif, FontWeight(50)),
843            FontWeight(100)
844        );
845        assert_eq!(
846            system_declared_weight(&FontFamily::SansSerif, FontWeight(1000)),
847            FontWeight(900)
848        );
849    }
850
851    #[test]
852    fn a_sparse_system_family_resolves_by_distance_and_keeps_the_lighter_face_on_a_tie() {
853        // `serif` declares 400 and 700 only. 500 is one hundred from 400 and
854        // two from 700; 600 is the other way round; 550 truncates to 5 and so
855        // is not the tie it looks like.
856        for (requested, expected) in [(500u16, 400u16), (550, 400), (600, 700), (650, 700)] {
857            assert_eq!(
858                system_declared_weight(&FontFamily::Serif, FontWeight(requested)),
859                FontWeight(expected),
860                "serif {requested}"
861            );
862        }
863        // The tie-break proper: equal scores keep the entry declared first,
864        // which for Android's ascending declarations is the lighter face.
865        assert_eq!(
866            closest_declared_weight(&[300, 500], FontWeight(400)),
867            Some(FontWeight(300))
868        );
869        assert_eq!(
870            closest_declared_weight(&[500, 300], FontWeight(400)),
871            Some(FontWeight(500)),
872            "declaration order, not magnitude, is what breaks the tie"
873        );
874    }
875
876    #[test]
877    fn a_family_with_no_system_files_keeps_the_weight_it_was_given() {
878        // Nothing declares a weight set for these, and nothing will register
879        // for them either, so there is no rule to apply.
880        assert_eq!(
881            system_declared_weight(&FontFamily::named("Roboto"), FontWeight(450)),
882            FontWeight(450)
883        );
884    }
885
886    #[test]
887    fn register_system_face_registers_the_declared_weight_not_the_requested_one() {
888        let dir = ScratchDir::new("system-undeclared-weight");
889        dir.write("Roboto-Regular.ttf", REGULAR);
890
891        let mut registry = SoftwareTextFontRegistry::new();
892        registry
893            .register_system_face(
894                dir.path(),
895                &FontFamily::SansSerif,
896                FontWeight(450),
897                FontStyle::Normal,
898            )
899            .expect("system face loads");
900        let fonts = registry.into_font_set_or_default(&[]);
901
902        // The assertion is on the resolved face, not on the constant that was
903        // asked for: what matters is which face text actually draws with.
904        let resolved = fonts
905            .resolve(&style_for(&FontFamily::SansSerif, FontWeight(450)))
906            .expect("a face for the off-grid request");
907        assert_eq!(
908            resolved.weight(),
909            FontWeight::NORMAL,
910            "450 must land on the 400 entry Android's matcher returns"
911        );
912
913        let mut declared = SoftwareTextFontRegistry::new();
914        declared
915            .register_system_face(
916                dir.path(),
917                &FontFamily::SansSerif,
918                FontWeight::NORMAL,
919                FontStyle::Normal,
920            )
921            .expect("system face loads");
922        let declared = declared.into_font_set_or_default(&[]);
923        assert_eq!(
924            resolved.content_hash(),
925            declared
926                .resolve(&style_for(&FontFamily::SansSerif, FontWeight::NORMAL))
927                .expect("the 400 face")
928                .content_hash(),
929            "the off-grid request must produce the very same face, glyph masks included"
930        );
931    }
932
933    #[test]
934    fn system_requests_that_resolve_alike_register_one_face() {
935        let dir = ScratchDir::new("system-dedupe");
936        dir.write("Roboto-Regular.ttf", REGULAR);
937
938        let mut registry = SoftwareTextFontRegistry::new();
939        for weight in [400u16, 450, 499] {
940            registry
941                .register_system_face(
942                    dir.path(),
943                    &FontFamily::SansSerif,
944                    FontWeight(weight),
945                    FontStyle::Normal,
946                )
947                .expect("system face loads");
948        }
949
950        assert_eq!(
951            registry.faces().len(),
952            1,
953            "three requests for one declared entry are one face"
954        );
955    }
956
957    #[test]
958    fn an_app_registered_face_keeps_the_weight_it_declares() {
959        // The counterpart to the system rule: an app's own file is not an entry
960        // in the platform's config, so an arbitrary axis position is legitimate
961        // and must survive registration untouched.
962        let dir = ScratchDir::new("app-off-grid");
963        let path = dir.write("Test-Regular.ttf", REGULAR);
964        let family =
965            FontFamily::file_backed(vec![
966                FontFile::new(path.to_string_lossy().into_owned()).with_weight(FontWeight(450))
967            ])
968            .expect("file-backed family");
969
970        let mut registry = SoftwareTextFontRegistry::new();
971        registry.register_family(&family).expect("family loads");
972        let fonts = registry.into_font_set_or_default(&[]);
973
974        assert_eq!(
975            fonts
976                .resolve(&style_for(&family, FontWeight(450)))
977                .expect("app face")
978                .weight(),
979            FontWeight(450)
980        );
981    }
982
983    #[test]
984    fn register_family_rejects_a_family_that_names_no_files() {
985        let mut registry = SoftwareTextFontRegistry::new();
986        let error = registry
987            .register_family(&FontFamily::named("Roboto"))
988            .expect_err("a named family has nothing to read");
989        assert!(matches!(error, FontLoadError::NotFileBacked), "{error}");
990    }
991
992    #[test]
993    fn loaded_typeface_families_register_their_single_file() {
994        let dir = ScratchDir::new("typeface");
995        let path = dir.write("Test-Regular.ttf", REGULAR);
996        let family = FontFamily::loaded_typeface_path(path.to_string_lossy().into_owned());
997
998        let mut registry = SoftwareTextFontRegistry::new();
999        registry.register_family(&family).expect("typeface loads");
1000        let fonts = registry.into_font_set_or_default(&[]);
1001
1002        assert!(fonts.has_registered_family(&family));
1003        assert!(fonts
1004            .resolve(&style_for(&family, FontWeight::NORMAL))
1005            .is_some_and(|font| font.registered_family().is_some()));
1006    }
1007}