From 7e4e26a33575b291531cf515abb826dbf7a330eb Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 16:25:27 -0400 Subject: [PATCH] iris: resolve fontique's Android monospace generic family ourselves fontique 0.11.1's Android backend never resolves GenericFamily::Monospace (mono=None in the startup diagnostic, RUST.md's 2026-09-07 "Platform fonts" gap): DEFAULT_GENERIC_FAMILIES looks up "monospace" against name_map before fonts.xml is parsed into it, and even after parsing, AOSP's fonts.xml names it with a element whose children the backend's own parser never reads (a TODO left in place) -- so the name gets a FamilyId with no font data behind it, and family_by_name("monospace") comes back empty too. Confirmed still present on linebender/parley's main branch, so there is no newer release to bump to. TextData::patch_android_monospace (Android-only, called from TextData::default) reads fonts.xml's own "monospace" declaration for the font filename it names, then finds which of fontique's actually-scanned families owns a font file with that name and registers it as the Monospace generic directly -- the same authority Compose's Typeface.MONOSPACE resolves through, without pinning an OEM-specific family name. Verified on this checkout's emulator: mono=Some("Droid Sans Mono") in the startup log, and a screenshot showing the bench-fixture's code block and tool-card values in a visibly monospaced face beside sans body/heading text. Desktop's fontconfig backend is unaffected. Co-Authored-By: Claude Fable 5.1 --- docs/DECISIONS.md | 37 +++++++----- iris/core/src/primitive/text.rs | 101 +++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b8ac0db..6cdc135 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -23,21 +23,28 @@ marked **DEFERRED** are ones the agent chose not to decide alone. tofu for a codepoint no resolved face has (checked with CJK + emoji on desktop) rather than blank space, so the UI_RULES unknown-glyph rule still holds. -- **Flagging for Iris, not blocking on it**: this fontique version's - Android backend never resolves the `Monospace` generic family at all - (confirmed on this checkout's emulator, `mono=None` in the startup - diagnostic) -- a pre-existing ordering bug in fontique's own - `fonts.xml` parsing, not something this change introduced, but this - change is what stopped masking it (the bundled mono font used to be - registered ahead of the broken platform lookup, so it always won). - Effect: code blocks and the tool-card chevron marks fall through to - the same face as body text on Android instead of a genuinely - monospaced one -- still visible, not blank, just not monospaced. - Compose does not share this gap; it resolves `FontFamily.Monospace` - through Android's own `Typeface.MONOSPACE`, not through fontique. - docs/RUST.md's "Platform fonts (2026-09-07)" has the full account and - why a fix (an OEM-specific named-family pin, or patching around - fontique) is left as a follow-up rather than done in this pass. +- **Gap found, then closed same day**: this fontique version's Android + backend never resolved the `Monospace` generic family at all (confirmed + on this checkout's emulator, `mono=None` in the startup diagnostic) -- + two pre-existing bugs in fontique's own `fonts.xml` parsing stacked (an + ordering bug, and a `` declaration whose + `` children the backend's parser never reads), not something this + change introduced, but this change is what stopped masking it (the + bundled mono font used to be registered ahead of the broken platform + lookup, so it always won). Checked `linebender/parley`'s `main` branch + on GitHub: neither bug is fixed there, so there was no newer release to + bump to. Fixed instead in `iris-core` itself + (`TextData::patch_android_monospace`, Android-only): reads + `/system/etc/fonts.xml`'s own `"monospace"` declaration for the font + filename it names, then registers whichever of fontique's actually- + scanned families owns that file as the `Monospace` generic -- the same + authority Compose's `Typeface.MONOSPACE` resolves through, without + pinning an OEM-specific family name. Verified on this checkout's + emulator: `mono=Some("Droid Sans Mono")`, and a screenshot showing the + bench-fixture's code block and tool-card values in a visibly monospaced + face beside sans body text; the desktop `fontconfig` backend is + unaffected (still resolves monospace correctly, confirmed unchanged). + docs/RUST.md's "Platform fonts (2026-09-07)" has the full account. ## 2026-09-07 (how a phone log reaches Iris) diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs index 4810c19..588eff9 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -74,8 +74,10 @@ impl Default for TextData { /// and its platform monospace face, and ships no text font of its own, /// only its committed Nerd Fonts icon subset for fixed glyphs). fn default() -> Self { + let mut font_cx = FontContext::new(); + patch_android_monospace(&mut font_cx); Self { - font_cx: FontContext::new(), + font_cx, layout_cx: LayoutContext::new(), scale_cx: ScaleContext::new(), atlas: GlyphAtlas::default(), @@ -84,6 +86,103 @@ impl Default for TextData { } } +/// Works around `fontique` 0.11.1's Android backend never resolving +/// `GenericFamily::Monospace` (confirmed against +/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still +/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no +/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry, +/// "Platform fonts," for the full account). Two bugs stack, not one: +/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before* +/// `fonts.xml` is parsed into that same name map, and even after parsing, +/// AOSP's `fonts.xml` names it with a `` element +/// (not an ``) whose `` children the backend's own parser +/// does not read (a `TODO` in that match arm) -- so the name gets a +/// `FamilyId` with no font data behind it, and `family_by_name("monospace")` +/// comes back empty too. Confirmed on this checkout's emulator: `adb pull +/// /system/etc/fonts.xml` shows +/// `DroidSansMono.ttf` with no matching +/// alias. +/// +/// So this reads `fonts.xml` itself (already on-device, already the +/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the +/// filename that declaration names, then finds which of fontique's +/// *actually* scanned families (from `/system/fonts`, which do carry real +/// font data, just under whatever name the font's own metadata gives it -- +/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font +/// file with that name, and registers that family as the `Monospace` +/// generic the way the backend itself would have if its parser had reified +/// the declaration. A no-op if the family is somehow already resolved +/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless +/// test, or a device that names it some other way). +#[cfg(target_os = "android")] +fn patch_android_monospace(font_cx: &mut FontContext) { + use parley::fontique::SourceKind; + + let already_resolved = font_cx + .collection + .generic_families(GenericFamily::Monospace) + .next() + .is_some(); + if already_resolved { + return; + } + let Some(target_file) = android_monospace_font_filename() else { + return; + }; + let names: Vec = font_cx + .collection + .family_names() + .map(str::to_string) + .collect(); + for name in names { + let Some(id) = font_cx.collection.family_id(&name) else { + continue; + }; + let Some(info) = font_cx.collection.family(id) else { + continue; + }; + let Some(font) = info.default_font() else { + continue; + }; + let SourceKind::Path(path) = font.source().kind() else { + continue; + }; + if path.file_name().and_then(|f| f.to_str()) == Some(target_file.as_str()) { + font_cx + .collection + .append_generic_families(GenericFamily::Monospace, std::iter::once(id)); + return; + } + } +} + +/// Reads the font filename `fonts.xml` names for its `"monospace"` family +/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a +/// real XML parser -- a new dependency for one well-known, stable AOSP file +/// whose structure fontique itself already parses with a full parser one +/// module over. Not a general XML reader; assumes the file has exactly one +/// `` element with at least one `` child, +/// which is the format on every AOSP `fonts.xml` this was checked against. +#[cfg(target_os = "android")] +fn android_monospace_font_filename() -> Option { + let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string()); + let xml = + std::fs::read_to_string(std::path::Path::new(&android_root).join("etc/fonts.xml")).ok()?; + let family_start = xml.find("")?; + let block = &xml[family_start..]; + let block = &block[..block.find("")?]; + let font_tag = block.find("')? + 1; + let content = &after_tag[content_start..]; + let filename = content[..content.find('<')?].trim(); + (!filename.is_empty()).then(|| filename.to_string()) +} + +#[cfg(not(target_os = "android"))] +fn patch_android_monospace(_font_cx: &mut FontContext) {} + impl TextData { /// Builds the startup report -- see `FontDiagnostics`. Queries the /// collection directly (`fontique::Query`) rather than shaping a real