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