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;