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 text engine. That is this module: it
6//! parses each face exactly once, at registration, into the typefaces text
7//! lays out and draws with. The process keeps one copy of each font file's
8//! contents, shared by every face instanced from it and by later reads that
9//! find the file unchanged.
10//!
11//! Nothing here runs per frame or per string. A registry is built at startup
12//! and consumed into typefaces, which are `Arc`-backed.
13//!
14//! Fonts that are not files on disk come in through
15//! [`FontRegistry::register_face_reader`] or
16//! [`FontRegistry::register_face_bytes`]: an APK asset opened with
17//! `AndroidApp::asset_manager()`, or anything `cranpose-assets` resolved out of
18//! a desktop bundle. `cranpose-assets` is a filesystem path resolver, so it
19//! covers bundles but not APK entries, which are not filesystem paths.
20
21use std::{
22    io::Read,
23    path::{Path, PathBuf},
24    sync::{Mutex, PoisonError},
25};
26
27use cranpose_text::{FontBytes, FontFamilyKey, Typeface, TypefaceError};
28use cranpose_ui::text::{FontFamily, FontFile, FontStyle, FontWeight};
29
30/// Directory Android keeps its system font files in.
31pub const ANDROID_SYSTEM_FONT_DIR: &str = "/system/fonts";
32
33/// The weights [`FontRegistry::register_system_family`] registers
34/// when an app does not name its own: Compose's Regular/Medium/Bold set.
35pub const DEFAULT_SYSTEM_FAMILY_WEIGHTS: &[FontWeight] =
36    &[FontWeight::NORMAL, FontWeight::MEDIUM, FontWeight::BOLD];
37
38/// Why an app-supplied face could not be registered.
39///
40/// Every variant is recoverable: the caller logs it and keeps whatever faces
41/// did load, and resolution falls back to the default face for families that
42/// ended up with none.
43#[derive(Debug, thiserror::Error)]
44pub enum FontLoadError {
45    /// The family declares an empty list of faces.
46    #[error("font family declares no faces")]
47    EmptyFamily,
48    /// The family is not backed by font files, so there is nothing to read from disk.
49    #[error("font family is not backed by files, so it has nothing to load")]
50    NotFileBacked,
51    /// No file for this family exists in the system font directory.
52    #[error("no system font file for this family under {directory}")]
53    NoSystemFontFile {
54        /// The system font directory that was searched.
55        directory: PathBuf,
56    },
57    /// A font file could not be read from the filesystem.
58    #[error("failed to read font file {path}: {source}")]
59    Read {
60        /// Path of the file that failed to read.
61        path: PathBuf,
62        /// The operating system error.
63        #[source]
64        source: std::io::Error,
65    },
66    /// A font file was read but its contents are not a parsable font.
67    #[error("failed to parse font file {path}: {source}")]
68    Parse {
69        /// Path of the file that failed to parse.
70        path: PathBuf,
71        /// The parse failure from the font parser.
72        #[source]
73        source: TypefaceError,
74    },
75    /// The supplied bytes are not a parsable font, or the variation axes are invalid.
76    #[error("failed to parse font bytes: {source}")]
77    ParseBytes {
78        /// The parse failure from the font parser.
79        #[source]
80        source: TypefaceError,
81    },
82}
83
84/// Parsed app-supplied faces, on their way to the typefaces text lays out
85/// with.
86///
87/// Register everything an app needs once at startup, then call
88/// [`FontRegistry::into_typefaces`]. Registration is where files are read
89/// and faces parsed; nothing after it touches the disk.
90#[derive(Clone, Default)]
91pub struct FontRegistry {
92    faces: Vec<Typeface>,
93    system_faces: Vec<(FontFamilyKey, FontWeight, FontStyle)>,
94}
95
96#[derive(Default)]
97struct TolerantLoad {
98    first_error: Option<FontLoadError>,
99    loaded: usize,
100}
101
102impl TolerantLoad {
103    fn record(&mut self, result: Result<(), FontLoadError>) {
104        match result {
105            Ok(()) => self.loaded += 1,
106            Err(error) => self.first_error = self.first_error.take().or(Some(error)),
107        }
108    }
109
110    fn finish(self) -> Result<(), FontLoadError> {
111        match self.first_error {
112            Some(error) if self.loaded == 0 => Err(error),
113            _ => Ok(()),
114        }
115    }
116}
117
118impl FontRegistry {
119    /// Creates an empty registry.
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Register every face of a file-backed family, reading each file from the
125    /// filesystem.
126    ///
127    /// Each [`FontFile`]'s declared weight and style are what resolution
128    /// matches on, so one family can carry Regular/Medium/Bold/Italic faces and
129    /// a `FontWeight`/`FontStyle` picks between them. A family whose files all
130    /// fail to load registers nothing and reports the first failure; text
131    /// asking for it then falls back to the default face rather than
132    /// disappearing.
133    pub fn register_family(&mut self, family: &FontFamily) -> Result<(), FontLoadError> {
134        let files = font_files_for(family)?;
135        if files.is_empty() {
136            return Err(FontLoadError::EmptyFamily);
137        }
138
139        let mut load = TolerantLoad::default();
140        for file in &files {
141            load.record(self.register_read_face(
142                family,
143                file.weight,
144                file.style,
145                Path::new(&file.path),
146                &[],
147            ));
148        }
149        load.finish()
150    }
151
152    fn register_read_face(
153        &mut self,
154        family: &FontFamily,
155        weight: FontWeight,
156        style: FontStyle,
157        path: &Path,
158        variations: &[([u8; 4], f32)],
159    ) -> Result<(), FontLoadError> {
160        let bytes = read_font_file(path)?;
161        let face = Typeface::from_registered(family, weight, style, bytes, variations).map_err(
162            |source| FontLoadError::Parse {
163                path: path.to_path_buf(),
164                source,
165            },
166        )?;
167        self.faces.push(face);
168        Ok(())
169    }
170
171    /// Register one face read from an arbitrary stream.
172    ///
173    /// This is the seam for fonts that are not files on disk — an APK asset
174    /// opened through `AndroidApp::asset_manager()`, an archive entry, a
175    /// download cache.
176    pub fn register_face_reader(
177        &mut self,
178        family: &FontFamily,
179        weight: FontWeight,
180        style: FontStyle,
181        reader: &mut impl Read,
182    ) -> Result<(), FontLoadError> {
183        let mut bytes = Vec::new();
184        reader
185            .read_to_end(&mut bytes)
186            .map_err(|source| FontLoadError::Read {
187                path: PathBuf::new(),
188                source,
189            })?;
190        self.register_face_bytes(family, weight, style, bytes)
191    }
192
193    /// Register one face from bytes the app already holds.
194    pub fn register_face_bytes(
195        &mut self,
196        family: &FontFamily,
197        weight: FontWeight,
198        style: FontStyle,
199        bytes: impl Into<FontBytes>,
200    ) -> Result<(), FontLoadError> {
201        self.register_face_bytes_with_variations(family, weight, style, bytes, &[])
202    }
203
204    /// Register bytes with explicit OpenType axes, overriding the declared weight/style
205    /// coordinates. Invalid axes fail without registering a face.
206    pub fn register_face_bytes_with_variations(
207        &mut self,
208        family: &FontFamily,
209        weight: FontWeight,
210        style: FontStyle,
211        bytes: impl Into<FontBytes>,
212        variations: &[([u8; 4], f32)],
213    ) -> Result<(), FontLoadError> {
214        let face = Typeface::from_registered(family, weight, style, bytes, variations)
215            .map_err(|source| FontLoadError::ParseBytes { source })?;
216        self.faces.push(face);
217        Ok(())
218    }
219
220    /// Register a face that belongs to no declared family.
221    ///
222    /// These are the fallbacks: they are eligible for any request that names no
223    /// family, and for a `Named` request their own `name` table decides.
224    pub fn register_fallback_bytes(
225        &mut self,
226        bytes: impl Into<FontBytes>,
227    ) -> Result<(), FontLoadError> {
228        let face =
229            Typeface::from_bytes(bytes).map_err(|source| FontLoadError::ParseBytes { source })?;
230        self.faces.push(face);
231        Ok(())
232    }
233
234    /// Register the platform's own face for a generic family alias, at each of
235    /// `weights`, so styles keep naming `FontFamily::SansSerif` and get the
236    /// real system typeface.
237    ///
238    /// Android backs `sans-serif` with a single variable `Roboto-Regular.ttf`
239    /// and describes each weight as a `wght` axis position on it, so most
240    /// devices resolve every weight to one file instanced several ways. Where a
241    /// build does ship weight-specific static files (`Roboto-Medium.ttf`), they
242    /// are preferred. Each weight is first resolved through
243    /// [`system_declared_weight`], so a request the platform's font config does
244    /// not declare registers the face Android would have returned for it rather
245    /// than a `wght` position Android cannot reach. Faces are registered in
246    /// `FontStyle::Normal`; an app that
247    /// wants a real italic rather than a synthesized slant should call
248    /// [`FontRegistry::register_system_face`] for it.
249    pub fn register_system_family(
250        &mut self,
251        directory: impl AsRef<Path>,
252        family: &FontFamily,
253        weights: &[FontWeight],
254    ) -> Result<(), FontLoadError> {
255        let directory = directory.as_ref();
256        let mut load = TolerantLoad::default();
257        for weight in weights {
258            load.record(self.register_read_system_face(
259                directory,
260                family,
261                *weight,
262                FontStyle::Normal,
263                &[],
264            ));
265        }
266        load.finish()
267    }
268
269    /// Register one weight/style of a generic family alias from the platform's
270    /// font directory.
271    ///
272    /// `weight` is resolved through [`system_declared_weight`] before anything
273    /// is read, so the registered face is one the platform's font config
274    /// declares. Use [`Self::register_system_face_with_variations`] when matching
275    /// explicit coordinates supplied by the platform's text API.
276    pub fn register_system_face(
277        &mut self,
278        directory: impl AsRef<Path>,
279        family: &FontFamily,
280        weight: FontWeight,
281        style: FontStyle,
282    ) -> Result<(), FontLoadError> {
283        self.register_system_face_with_variations(directory, family, weight, style, &[])
284    }
285
286    /// Register a system face at explicit OpenType coordinates, such as the optical
287    /// size and weight returned by a platform text API. The first registration of a
288    /// family/weight/style wins, as with [`Self::register_system_face`].
289    pub fn register_system_face_with_variations(
290        &mut self,
291        directory: impl AsRef<Path>,
292        family: &FontFamily,
293        weight: FontWeight,
294        style: FontStyle,
295        variations: &[([u8; 4], f32)],
296    ) -> Result<(), FontLoadError> {
297        self.register_read_system_face(directory.as_ref(), family, weight, style, variations)
298    }
299
300    fn register_read_system_face(
301        &mut self,
302        directory: &Path,
303        family: &FontFamily,
304        weight: FontWeight,
305        style: FontStyle,
306        variations: &[([u8; 4], f32)],
307    ) -> Result<(), FontLoadError> {
308        let weight = system_declared_weight(family, weight);
309        if self.has_system_face(family, weight, style) {
310            return Ok(());
311        }
312        let path = system_font_file(directory, family, weight).ok_or_else(|| {
313            FontLoadError::NoSystemFontFile {
314                directory: directory.to_path_buf(),
315            }
316        })?;
317        self.register_read_face(family, weight, style, &path, variations)?;
318        self.system_faces
319            .push((FontFamilyKey::of(family), weight, style));
320        Ok(())
321    }
322
323    fn has_system_face(&self, family: &FontFamily, weight: FontWeight, style: FontStyle) -> bool {
324        self.system_faces
325            .contains(&(FontFamilyKey::of(family), weight, style))
326    }
327
328    /// The faces registered so far.
329    pub fn faces(&self) -> &[Typeface] {
330        &self.faces
331    }
332
333    /// Whether no face has been registered yet.
334    pub fn is_empty(&self) -> bool {
335        self.faces.is_empty()
336    }
337
338    /// Finish, folding in the static byte slices from `AppLauncher::with_fonts`
339    /// as unregistered fallbacks.
340    ///
341    /// Registered faces come first, so when a request names no family, a
342    /// face the app declared wins over one it merely handed over as bytes.
343    /// The typefaces are only what the app supplied; whether the embedded
344    /// face serves an app that supplied nothing is the launcher's decision,
345    /// made where the binary can leave the face out.
346    pub fn into_typefaces(mut self, fonts: &[&'static [u8]]) -> Vec<Typeface> {
347        for bytes in fonts {
348            let _ = self.register_fallback_bytes(*bytes);
349        }
350        self.faces
351    }
352}
353
354struct ReadFontFile {
355    path: PathBuf,
356    bytes: &'static [u8],
357}
358
359static READ_FONT_FILES: Mutex<Vec<ReadFontFile>> = Mutex::new(Vec::new());
360
361pub(crate) fn read_font_file(path: &Path) -> Result<&'static [u8], FontLoadError> {
362    let read = std::fs::read(path).map_err(|source| FontLoadError::Read {
363        path: path.to_path_buf(),
364        source,
365    })?;
366    let mut files = READ_FONT_FILES
367        .lock()
368        .unwrap_or_else(PoisonError::into_inner);
369    if let Some(file) = files
370        .iter()
371        .find(|file| file.path == path && file.bytes == read.as_slice())
372    {
373        return Ok(file.bytes);
374    }
375    let bytes: &'static [u8] = Box::leak(read.into_boxed_slice());
376    files.push(ReadFontFile {
377        path: path.to_path_buf(),
378        bytes,
379    });
380    Ok(bytes)
381}
382
383/// The weight a platform's own matcher resolves `weight` to for a generic
384/// family alias — never a value that family's font config does not declare.
385///
386/// Android's `sans-serif` is one variable `Roboto-Regular.ttf` described by
387/// `/system/etc/fonts.xml` as nine `<font weight="…">` entries, one per hundred,
388/// each pinning `wght` to that hundred. The `wght` axis is therefore reachable
389/// to the *font config*, not to a caller: an app asking `sans-serif` for 450
390/// gets whichever declared entry Minikin's matcher picks, and Minikin has no
391/// way to express a weight between two entries. Instancing the axis at 450
392/// anyway draws a face the platform cannot draw — text that measures and lays
393/// out perfectly and is simply heavier than every other app on the device.
394///
395/// The rule is `computeMatch` in `frameworks/minikin/libs/minikin/FontFamily.cpp`:
396///
397/// ```text
398/// int score = abs(style1.weight() / 100 - style2.weight() / 100);
399/// if (style1.slant() != style2.slant()) score += 2;
400/// ```
401///
402/// picked by `getClosestMatch`, which keeps a candidate only on `match <
403/// bestMatch`. Two consequences carry the behaviour, and both are load-bearing:
404///
405/// * The division is **integer**, so the request is truncated to its hundred
406///   before anything is compared. Against a full hundreds grid the nearest
407///   declared entry is therefore always the hundred *below* — 450 resolves to
408///   400 and 550 to 500, not by a tie-break but outright, and 599 resolves to
409///   500 too.
410/// * Where a request does tie between two declared entries — 500 against a
411///   `serif` declaring only 400 and 700 scores 1 and 2, but 600 scores 2 and 1,
412///   and a family declaring 300 and 500 ties at 400 — the strict `<` keeps the
413///   entry declared **first**. Android declares ascending, so a tie goes to the
414///   lighter face.
415///
416/// A slant mismatch costs 2, i.e. 200 weight units, which is why `style` never
417/// competes with `weight` here: this resolves within one slant, as
418/// [`FontRegistry::register_system_face`] registers one.
419///
420/// Families this crate knows no system files for resolve to `weight` unchanged;
421/// there is no declared set to honour, and nothing will register for them.
422///
423/// This applies to the system-font path alone. A face an app supplies is its
424/// own font, not an entry in the platform's config, and stays instanceable at
425/// any axis value — that is what a variable font is for.
426pub fn system_declared_weight(family: &FontFamily, weight: FontWeight) -> FontWeight {
427    let Some(files) = system_family_files(family) else {
428        return weight;
429    };
430    closest_declared_weight(files.declared, weight).unwrap_or(weight)
431}
432
433fn closest_declared_weight(declared: &[u16], requested: FontWeight) -> Option<FontWeight> {
434    let mut best: Option<(u16, u16)> = None;
435    for candidate in declared {
436        let score = weight_match_score(*candidate, requested.value());
437        if best.is_none_or(|(_, best_score)| score < best_score) {
438            best = Some((*candidate, score));
439        }
440    }
441    best.map(|(candidate, _)| FontWeight(candidate))
442}
443
444fn weight_match_score(declared: u16, requested: u16) -> u16 {
445    (declared / 100).abs_diff(requested / 100)
446}
447
448/// The file a platform backs `family` with at `weight`, if one is present.
449///
450/// Weight-specific static files win when the build ships them; otherwise the
451/// family's regular file is returned and instanced on its `wght` axis at
452/// registration. `weight` is expected to be one the family declares — callers
453/// on the system path run it through [`system_declared_weight`] first.
454pub fn system_font_file(
455    directory: &Path,
456    family: &FontFamily,
457    weight: FontWeight,
458) -> Option<PathBuf> {
459    let files = system_family_files(family)?;
460    files
461        .weighted
462        .iter()
463        .filter(|(candidate_weight, _)| *candidate_weight == weight.value())
464        .map(|(_, name)| directory.join(name))
465        .chain(files.regular.iter().map(|name| directory.join(name)))
466        .find(|path| path.is_file())
467}
468
469struct SystemFamilyFiles {
470    regular: &'static [&'static str],
471    weighted: &'static [(u16, &'static str)],
472    declared: &'static [u16],
473}
474
475const DECLARED_HUNDREDS: &[u16] = &[100, 200, 300, 400, 500, 600, 700, 800, 900];
476
477fn system_family_files(family: &FontFamily) -> Option<SystemFamilyFiles> {
478    match family {
479        FontFamily::Default | FontFamily::SansSerif => Some(SystemFamilyFiles {
480            regular: &[
481                "Roboto-Regular.ttf",
482                "RobotoStatic-Regular.ttf",
483                "NotoSans-Regular.ttf",
484                "DroidSans.ttf",
485                "Core/SFUI.ttf",
486                "SFNS.ttf",
487            ],
488            weighted: &[
489                (300, "Roboto-Light.ttf"),
490                (500, "Roboto-Medium.ttf"),
491                (700, "Roboto-Bold.ttf"),
492                (900, "Roboto-Black.ttf"),
493            ],
494            declared: DECLARED_HUNDREDS,
495        }),
496        FontFamily::Serif | FontFamily::Fantasy => Some(SystemFamilyFiles {
497            regular: &["NotoSerif-Regular.ttf", "DroidSerif-Regular.ttf"],
498            weighted: &[(700, "NotoSerif-Bold.ttf"), (700, "DroidSerif-Bold.ttf")],
499            declared: &[400, 700],
500        }),
501        FontFamily::Monospace => Some(SystemFamilyFiles {
502            regular: &[
503                "DroidSansMono.ttf",
504                "RobotoMono-Regular.ttf",
505                "CutiveMono-Regular.ttf",
506            ],
507            weighted: &[(700, "RobotoMono-Bold.ttf")],
508            declared: &[400, 700],
509        }),
510        FontFamily::Cursive => Some(SystemFamilyFiles {
511            regular: &["DancingScript-Regular.ttf"],
512            weighted: &[(700, "DancingScript-Bold.ttf")],
513            declared: &[400, 700],
514        }),
515        FontFamily::Named(_) | FontFamily::FileBacked(_) | FontFamily::LoadedTypeface(_) => None,
516    }
517}
518
519fn font_files_for(family: &FontFamily) -> Result<Vec<FontFile>, FontLoadError> {
520    match family {
521        FontFamily::FileBacked(file_backed) => Ok(file_backed.fonts.clone()),
522        FontFamily::LoadedTypeface(typeface) => Ok(vec![FontFile::new(typeface.path.clone())]),
523        _ => Err(FontLoadError::NotFileBacked),
524    }
525}
526
527#[cfg(test)]
528#[path = "tests/font_source_tests.rs"]
529mod tests;