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