From 17a124c2584691c108abddd1570f750a0553b9a9 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 1 Oct 2026 13:33:42 -0700 Subject: [PATCH] fix: text falls back to the fonts the system draws missing characters with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snowbound took one fallback family per script from fontique, so a character that neither the text's font nor that family has drew as the missing glyph or a Last Resort placeholder: ⇐ and most of Arrows, Mathematical Operators, Letterlike Symbols and Miscellaneous Technical on macOS, and on Windows 7, whose DirectWrite has no fallback by script, everything outside Segoe UI. The page and the interface now fall back, after that family, through the platform's own: CoreText's cascade for the system font and the system fonts past it on macOS, the symbol fonts, Segoe UI's linked fonts and the scripts' fonts on Windows. Linux needs none; fontconfig's sort already reaches every installed font. More Symbols offers what that fallback draws: 25,943 named characters on macOS (was 21,215 drawn, plus 2,701 Last Resort placeholders that it failed to recognise), 13,602 on Windows 7 (was 2,897; Character Map reaches 13,664 across every font), 28,541 on Debian with Noto. Assisted-by: claude-opus-5.5 --- crates/draw/src/lib.rs | 26 ++++++++++++ crates/snowbound/src/linux.rs | 5 +++ crates/snowbound/src/macos.rs | 74 +++++++++++++++++++++++++++++++++ crates/snowbound/src/main.rs | 3 ++ crates/snowbound/src/windows.rs | 41 ++++++++++++++++++ crates/ui/src/lib.rs | 6 +++ crates/ui/src/text.rs | 39 +++++++++++------ 7 files changed, 182 insertions(+), 12 deletions(-) diff --git a/crates/draw/src/lib.rs b/crates/draw/src/lib.rs index 2cc498a09b6799d0ce85faff1185301d778a3dba..6159cce35631f97f9795089a3d480bd9ad8cb24f 100644 --- a/crates/draw/src/lib.rs +++ b/crates/draw/src/lib.rs @@ -9,6 +9,32 @@ mod render; #[cfg(feature = "render")] pub use render::*; +/// Appends the installed `families` to every script's fallback in `collection`, after the +/// system's, so a character that neither the text's fonts nor its script's fallback draws +/// comes from the first of them that has it, as the system's own text does. +pub fn fall_back_to(collection: &mut parley::fontique::Collection, families: &[String]) { + use parley::fontique::{Script, ScriptExt}; + let extra: Vec<_> = families + .iter() + .filter_map(|name| collection.family_id(name)) + .collect(); + // Common, inherited and unknown characters keep runs of their own where no script's + // text surrounds them. + let scripts = Script::all_samples() + .iter() + .map(|(script, _)| *script) + .chain([*b"Zyyy", *b"Zinh", *b"Zzzz"].map(Script::from_bytes)); + for script in scripts { + let mut chain: Vec<_> = collection.fallback_families(script).collect(); + for family in &extra { + if !chain.contains(family) { + chain.push(*family); + } + } + collection.set_fallbacks(script, chain.into_iter()); + } +} + /// sRGB's encoding of a linear channel. fn encode(linear: f32) -> f32 { if linear <= 0.003_130_8 { diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index d0d9b2109a096e3df1840b32a099634a26ffce71..e7d2f730a6dc85fef9093bf87952e70ae63229ce 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -1161,6 +1161,11 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option { )) } +/// None: fontique falls back through fontconfig's coverage, which reaches every installed font. +pub fn symbol_fonts() -> Vec { + Vec::new() +} + /// No icon theme lookup; the page draws a blank page for the file. pub fn file_icon(_: &std::path::Path) -> Option> { None diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 99092e6b2f00c296b9e0e3d8e39c152435f70d6e..dbc5d172edc68060847a36279c237eb784860562 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -857,6 +857,80 @@ pub fn clip_screen(proxy: EventLoopProxy) { }); } +/// The families CoreText's cascade falls back to from the interface font, as the system's +/// text and Character Viewer draw what a font lacks, then the system fonts that hold nearly +/// all CoreText finds past it by searching every font. 10.6 and 10.7, which can't list the +/// cascade, have their symbol fonts in its place. +pub fn symbol_fonts() -> Vec { + use std::ffi::c_void; + type Cascade = unsafe extern "C" fn(*const c_void, *const c_void) -> *const c_void; + #[link(name = "CoreText", kind = "framework")] + unsafe extern "C" { + static kCTFontFamilyNameAttribute: *const c_void; + fn CTFontCreateUIFontForLanguage( + kind: u32, + size: f64, + language: *const c_void, + ) -> *const c_void; + fn CTFontDescriptorCopyAttribute( + descriptor: *const c_void, + attribute: *const c_void, + ) -> *const c_void; + } + unsafe extern "C" { + fn CFArrayGetCount(array: *const c_void) -> isize; + fn CFArrayGetValueAtIndex(array: *const c_void, index: isize) -> *const c_void; + fn CFRelease(object: *const c_void); + } + const SYSTEM: u32 = 2; + let cascade = || unsafe { + // 10.8's. + let copy = libc::dlsym( + libc::RTLD_DEFAULT, + c"CTFontCopyDefaultCascadeListForLanguages".as_ptr(), + ); + if copy.is_null() { + return None; + } + let copy = std::mem::transmute::<*mut c_void, Cascade>(copy); + let font = CTFontCreateUIFontForLanguage(SYSTEM, 13.0, std::ptr::null()); + if font.is_null() { + return None; + } + let list = copy(font, std::ptr::null()); + CFRelease(font); + if list.is_null() { + return None; + } + let mut families = Vec::new(); + for index in 0..CFArrayGetCount(list) { + let descriptor = CFArrayGetValueAtIndex(list, index); + let name = CTFontDescriptorCopyAttribute(descriptor, kCTFontFamilyNameAttribute); + if !name.is_null() { + families.push((*name.cast::()).to_string()); + CFRelease(name); + } + } + CFRelease(list); + Some(families) + }; + let mut families = cascade().unwrap_or_else(|| { + ["Lucida Grande", "Apple Symbols", "STIXGeneral", "Menlo"] + .map(String::from) + .into() + }); + families.extend( + [ + "Arial Unicode MS", + "Geneva", + "Noto Sans Coptic", + "Hiragino Sans", + ] + .map(String::from), + ); + families +} + /// Tells the user something they asked for could not be done: `message`, then what to do. pub fn alert(message: &str, detail: &str) { let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index c1f51af01b0c1cb21d08b4922eae11f2769f61f2..7816868a9e5254c598f0fe818237b42781ebfc4a 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -886,6 +886,8 @@ impl State { let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?; let size = window.inner_size(); let mut engine = TextEngine::default(); + let fallbacks = platform::symbol_fonts(); + draw::fall_back_to(&mut engine.fonts.collection, &fallbacks); for path in substitutes { let target = engine .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; @@ -1019,6 +1021,7 @@ impl State { // A window shown but never focused hears no focus event; a hidden one draws as focused. ui.window_focused = !visible || window.has_focus(); platform::system_interface(&mut ui); + ui.fall_back_to(&fallbacks); ui.set_focus(Some(page())); for family in FONTS { for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) { diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index e3085d1d67cf030a95f7b58fba226e61fd384a9a..9c0965e64a2476cd268070d42640568fdbafa7c2 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -1159,6 +1159,47 @@ pub fn open_file(path: &std::path::Path) { reveal(path); } +/// What text falls back to past its fonts and DirectWrite's fallback for its script: the +/// symbol fonts, then Segoe UI's linked fonts and the scripts' fonts of Windows 7, whose +/// DirectWrite has no fallback by script. +pub fn symbol_fonts() -> Vec { + [ + "Segoe UI Symbol", + "Cambria Math", + "Segoe UI Emoji", + // Segoe UI's FontLink\SystemLink in Windows 7's registry. + "Tahoma", + "Meiryo", + "MS UI Gothic", + "Microsoft JhengHei", + "Microsoft YaHei", + "Malgun Gothic", + "PMingLiU", + "SimSun", + "Gulim", + // Windows 7's fonts of the scripts those leave out, then Office's catch-all. + "Microsoft Yi Baiti", + "Euphemia", + "Nyala", + "Ebrima", + "Mongolian Baiti", + "DaunPenh", + "Plantagenet Cherokee", + "Microsoft New Tai Lue", + "Iskoola Pota", + "Estrangelo Edessa", + "Microsoft PhagsPa", + "MV Boli", + "Microsoft Tai Le", + "Microsoft Himalaya", + "Mangal", + "Latha", + "Arial Unicode MS", + ] + .map(String::from) + .into() +} + /// No shell icon lookup yet; the page draws a blank page for the file. pub fn file_icon(_: &std::path::Path) -> Option> { None diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 6e4ecb694e116ec4eb06c565b143a0cec246f9c8..c2f8d4c6969b13f8ab528be0d2e3cd605bda9c81 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1297,6 +1297,12 @@ impl Ui { self.texts.preview_font(data, family); } + /// Draws what the interface's fonts and their scripts' fallbacks lack with the first of + /// `families` that has it; see `draw::fall_back_to`. + pub fn fall_back_to(&mut self, families: &[String]) { + self.texts.fall_back_to(families); + } + /// Whether the interface's fonts draw every glyph of `text`. pub fn shows(&mut self, text: &str) -> bool { self.texts.shows(text, self.frame) diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index ee4a72ea06162fc6407f7bd89ef0e490a3f7505c..2c86375435a77acf3b4f241d07751afeefed3599 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -21,8 +21,8 @@ pub(crate) struct Texts { context: LayoutContext<()>, /// By text, size, weight and preferred family, with the frame each was last used. cache: HashMap, u64)>, - /// The Last Resort font's data, once looked for. - last_resort: Option>, + /// The Last Resort font, once looked for. + last_resort: Option>>, } impl Texts { @@ -106,24 +106,34 @@ impl Texts { self.styled(&cut, size, *bold, *italic, font, frame) } - /// Whether the interface's fonts or their fallbacks draw every glyph of `text`, not the - /// missing glyph nor the placeholders of macOS's Last Resort font. + /// Whether the interface's fonts or their fallbacks draw `text`: some glyph, and none the + /// missing glyph or a placeholder of macOS's Last Resort font. pub fn shows(&mut self, text: &str, frame: u64) -> bool { - let last_resort = *self.last_resort.get_or_insert_with(|| { - let family = self.fonts.collection.family_by_name("LastResort")?; - let font = family.fonts().first()?; - Some(font.load(Some(&mut self.fonts.source_cache))?.id()) - }); + let last_resort = self + .last_resort + .get_or_insert_with(|| { + let family = self.fonts.collection.family_by_name(".LastResort")?; + family + .fonts() + .first()? + .load(Some(&mut self.fonts.source_cache)) + }) + .clone(); let label = self.styled(text, 16.0, false, false, None, frame); - label.layout.lines().all(|line| { + let mut glyphs = 0; + let drawn = label.layout.lines().all(|line| { line.items().all(|item| match item { PositionedLayoutItem::GlyphRun(run) => { - Some(run.run().font().font.data.id()) != last_resort + glyphs += run.glyphs().count(); + // By contents: the source cache may load the font afresh. + let data = run.run().font().font.data.data(); + last_resort.as_ref().is_none_or(|font| font.data() != data) && run.glyphs().all(|glyph| glyph.id != 0) } PositionedLayoutItem::InlineBox(_) => true, }) - }) + }); + drawn && glyphs > 0 } /// Makes `family` the interface's font, where fontique doesn't know the system's. @@ -135,6 +145,11 @@ impl Texts { } } + pub fn fall_back_to(&mut self, families: &[String]) { + draw::fall_back_to(&mut self.fonts.collection, families); + self.cache.clear(); + } + pub fn preview_font(&mut self, data: Blob, family: &str) { self.fonts.collection.register_fonts( data, -- 2.54.0