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;