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