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)]
532#[path = "tests/font_source_tests.rs"]
533mod tests;