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