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