authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 13:33:42-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 13:54:54-07:00
log17a124c2584691c108abddd1570f750a0553b9a9
tree27340471a0e339d66c3ebad36685337039a8b337
parent931a2dbdbbff49d90d4b2cca30f1bd7488a2859b
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: text falls back to the fonts the system draws missing characters with

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

7 files changed, 182 insertions(+), 12 deletions(-)

crates/draw/src/lib.rs+26
......@@ -9,6 +9,32 @@ mod render;
99#[cfg(feature = "render")]
1010pub use render::*;
1111
12/// Appends the installed `families` to every script's fallback in `collection`, after the
13/// system's, so a character that neither the text's fonts nor its script's fallback draws
14/// comes from the first of them that has it, as the system's own text does.
15pub fn fall_back_to(collection: &mut parley::fontique::Collection, families: &[String]) {
16 use parley::fontique::{Script, ScriptExt};
17 let extra: Vec<_> = families
18 .iter()
19 .filter_map(|name| collection.family_id(name))
20 .collect();
21 // Common, inherited and unknown characters keep runs of their own where no script's
22 // text surrounds them.
23 let scripts = Script::all_samples()
24 .iter()
25 .map(|(script, _)| *script)
26 .chain([*b"Zyyy", *b"Zinh", *b"Zzzz"].map(Script::from_bytes));
27 for script in scripts {
28 let mut chain: Vec<_> = collection.fallback_families(script).collect();
29 for family in &extra {
30 if !chain.contains(family) {
31 chain.push(*family);
32 }
33 }
34 collection.set_fallbacks(script, chain.into_iter());
35 }
36}
37
1238/// sRGB's encoding of a linear channel.
1339fn encode(linear: f32) -> f32 {
1440 if linear <= 0.003_130_8 {
crates/snowbound/src/linux.rs+5
......@@ -1161,6 +1161,11 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
11611161 ))
11621162}
11631163
1164/// None: fontique falls back through fontconfig's coverage, which reaches every installed font.
1165pub fn symbol_fonts() -> Vec<String> {
1166 Vec::new()
1167}
1168
11641169/// No icon theme lookup; the page draws a blank page for the file.
11651170pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {
11661171 None
crates/snowbound/src/macos.rs+74
......@@ -857,6 +857,80 @@ pub fn clip_screen(proxy: EventLoopProxy<crate::UserEvent>) {
857857 });
858858}
859859
860/// The families CoreText's cascade falls back to from the interface font, as the system's
861/// text and Character Viewer draw what a font lacks, then the system fonts that hold nearly
862/// all CoreText finds past it by searching every font. 10.6 and 10.7, which can't list the
863/// cascade, have their symbol fonts in its place.
864pub fn symbol_fonts() -> Vec<String> {
865 use std::ffi::c_void;
866 type Cascade = unsafe extern "C" fn(*const c_void, *const c_void) -> *const c_void;
867 #[link(name = "CoreText", kind = "framework")]
868 unsafe extern "C" {
869 static kCTFontFamilyNameAttribute: *const c_void;
870 fn CTFontCreateUIFontForLanguage(
871 kind: u32,
872 size: f64,
873 language: *const c_void,
874 ) -> *const c_void;
875 fn CTFontDescriptorCopyAttribute(
876 descriptor: *const c_void,
877 attribute: *const c_void,
878 ) -> *const c_void;
879 }
880 unsafe extern "C" {
881 fn CFArrayGetCount(array: *const c_void) -> isize;
882 fn CFArrayGetValueAtIndex(array: *const c_void, index: isize) -> *const c_void;
883 fn CFRelease(object: *const c_void);
884 }
885 const SYSTEM: u32 = 2;
886 let cascade = || unsafe {
887 // 10.8's.
888 let copy = libc::dlsym(
889 libc::RTLD_DEFAULT,
890 c"CTFontCopyDefaultCascadeListForLanguages".as_ptr(),
891 );
892 if copy.is_null() {
893 return None;
894 }
895 let copy = std::mem::transmute::<*mut c_void, Cascade>(copy);
896 let font = CTFontCreateUIFontForLanguage(SYSTEM, 13.0, std::ptr::null());
897 if font.is_null() {
898 return None;
899 }
900 let list = copy(font, std::ptr::null());
901 CFRelease(font);
902 if list.is_null() {
903 return None;
904 }
905 let mut families = Vec::new();
906 for index in 0..CFArrayGetCount(list) {
907 let descriptor = CFArrayGetValueAtIndex(list, index);
908 let name = CTFontDescriptorCopyAttribute(descriptor, kCTFontFamilyNameAttribute);
909 if !name.is_null() {
910 families.push((*name.cast::<NSString>()).to_string());
911 CFRelease(name);
912 }
913 }
914 CFRelease(list);
915 Some(families)
916 };
917 let mut families = cascade().unwrap_or_else(|| {
918 ["Lucida Grande", "Apple Symbols", "STIXGeneral", "Menlo"]
919 .map(String::from)
920 .into()
921 });
922 families.extend(
923 [
924 "Arial Unicode MS",
925 "Geneva",
926 "Noto Sans Coptic",
927 "Hiragino Sans",
928 ]
929 .map(String::from),
930 );
931 families
932}
933
860934/// Tells the user something they asked for could not be done: `message`, then what to do.
861935pub fn alert(message: &str, detail: &str) {
862936 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
crates/snowbound/src/main.rs+3
......@@ -886,6 +886,8 @@ impl State {
886886 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;
887887 let size = window.inner_size();
888888 let mut engine = TextEngine::default();
889 let fallbacks = platform::symbol_fonts();
890 draw::fall_back_to(&mut engine.fonts.collection, &fallbacks);
889891 for path in substitutes {
890892 let target = engine
891893 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
......@@ -1019,6 +1021,7 @@ impl State {
10191021 // A window shown but never focused hears no focus event; a hidden one draws as focused.
10201022 ui.window_focused = !visible || window.has_focus();
10211023 platform::system_interface(&mut ui);
1024 ui.fall_back_to(&fallbacks);
10221025 ui.set_focus(Some(page()));
10231026 for family in FONTS {
10241027 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {
crates/snowbound/src/windows.rs+41
......@@ -1159,6 +1159,47 @@ pub fn open_file(path: &std::path::Path) {
11591159 reveal(path);
11601160}
11611161
1162/// What text falls back to past its fonts and DirectWrite's fallback for its script: the
1163/// symbol fonts, then Segoe UI's linked fonts and the scripts' fonts of Windows 7, whose
1164/// DirectWrite has no fallback by script.
1165pub fn symbol_fonts() -> Vec<String> {
1166 [
1167 "Segoe UI Symbol",
1168 "Cambria Math",
1169 "Segoe UI Emoji",
1170 // Segoe UI's FontLink\SystemLink in Windows 7's registry.
1171 "Tahoma",
1172 "Meiryo",
1173 "MS UI Gothic",
1174 "Microsoft JhengHei",
1175 "Microsoft YaHei",
1176 "Malgun Gothic",
1177 "PMingLiU",
1178 "SimSun",
1179 "Gulim",
1180 // Windows 7's fonts of the scripts those leave out, then Office's catch-all.
1181 "Microsoft Yi Baiti",
1182 "Euphemia",
1183 "Nyala",
1184 "Ebrima",
1185 "Mongolian Baiti",
1186 "DaunPenh",
1187 "Plantagenet Cherokee",
1188 "Microsoft New Tai Lue",
1189 "Iskoola Pota",
1190 "Estrangelo Edessa",
1191 "Microsoft PhagsPa",
1192 "MV Boli",
1193 "Microsoft Tai Le",
1194 "Microsoft Himalaya",
1195 "Mangal",
1196 "Latha",
1197 "Arial Unicode MS",
1198 ]
1199 .map(String::from)
1200 .into()
1201}
1202
11621203/// No shell icon lookup yet; the page draws a blank page for the file.
11631204pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {
11641205 None
crates/ui/src/lib.rs+6
......@@ -1297,6 +1297,12 @@ impl Ui {
12971297 self.texts.preview_font(data, family);
12981298 }
12991299
1300 /// Draws what the interface's fonts and their scripts' fallbacks lack with the first of
1301 /// `families` that has it; see `draw::fall_back_to`.
1302 pub fn fall_back_to(&mut self, families: &[String]) {
1303 self.texts.fall_back_to(families);
1304 }
1305
13001306 /// Whether the interface's fonts draw every glyph of `text`.
13011307 pub fn shows(&mut self, text: &str) -> bool {
13021308 self.texts.shows(text, self.frame)
crates/ui/src/text.rs+27-12
......@@ -21,8 +21,8 @@ pub(crate) struct Texts {
2121 context: LayoutContext<()>,
2222 /// By text, size, weight and preferred family, with the frame each was last used.
2323 cache: HashMap<LabelKey, (Rc<Label>, u64)>,
24 /// The Last Resort font's data, once looked for.
25 last_resort: Option<Option<u64>>,
24 /// The Last Resort font, once looked for.
25 last_resort: Option<Option<Blob<u8>>>,
2626}
2727
2828impl Texts {
......@@ -106,24 +106,34 @@ impl Texts {
106106 self.styled(&cut, size, *bold, *italic, font, frame)
107107 }
108108
109 /// Whether the interface's fonts or their fallbacks draw every glyph of `text`, not the
110 /// missing glyph nor the placeholders of macOS's Last Resort font.
109 /// Whether the interface's fonts or their fallbacks draw `text`: some glyph, and none the
110 /// missing glyph or a placeholder of macOS's Last Resort font.
111111 pub fn shows(&mut self, text: &str, frame: u64) -> bool {
112 let last_resort = *self.last_resort.get_or_insert_with(|| {
113 let family = self.fonts.collection.family_by_name("LastResort")?;
114 let font = family.fonts().first()?;
115 Some(font.load(Some(&mut self.fonts.source_cache))?.id())
116 });
112 let last_resort = self
113 .last_resort
114 .get_or_insert_with(|| {
115 let family = self.fonts.collection.family_by_name(".LastResort")?;
116 family
117 .fonts()
118 .first()?
119 .load(Some(&mut self.fonts.source_cache))
120 })
121 .clone();
117122 let label = self.styled(text, 16.0, false, false, None, frame);
118 label.layout.lines().all(|line| {
123 let mut glyphs = 0;
124 let drawn = label.layout.lines().all(|line| {
119125 line.items().all(|item| match item {
120126 PositionedLayoutItem::GlyphRun(run) => {
121 Some(run.run().font().font.data.id()) != last_resort
127 glyphs += run.glyphs().count();
128 // By contents: the source cache may load the font afresh.
129 let data = run.run().font().font.data.data();
130 last_resort.as_ref().is_none_or(|font| font.data() != data)
122131 && run.glyphs().all(|glyph| glyph.id != 0)
123132 }
124133 PositionedLayoutItem::InlineBox(_) => true,
125134 })
126 })
135 });
136 drawn && glyphs > 0
127137 }
128138
129139 /// Makes `family` the interface's font, where fontique doesn't know the system's.
......@@ -135,6 +145,11 @@ impl Texts {
135145 }
136146 }
137147
148 pub fn fall_back_to(&mut self, families: &[String]) {
149 draw::fall_back_to(&mut self.fonts.collection, families);
150 self.cache.clear();
151 }
152
138153 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {
139154 self.fonts.collection.register_fonts(
140155 data,