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;