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