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;...@@ -9,6 +9,32 @@ mod render;
9#[cfg(feature = "render")]9#[cfg(feature = "render")]
10pub use render::*;10pub 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
12/// sRGB's encoding of a linear channel.38/// sRGB's encoding of a linear channel.
13fn encode(linear: f32) -> f32 {39fn encode(linear: f32) -> f32 {
14 if linear <= 0.003_130_8 {40 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> {...@@ -1161,6 +1161,11 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
1161 ))1161 ))
1162}1162}
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
1164/// No icon theme lookup; the page draws a blank page for the file.1169/// No icon theme lookup; the page draws a blank page for the file.
1165pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {1170pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {
1166 None1171 None
crates/snowbound/src/macos.rs+74
...@@ -857,6 +857,80 @@ pub fn clip_screen(proxy: EventLoopProxy<crate::UserEvent>) {...@@ -857,6 +857,80 @@ pub fn clip_screen(proxy: EventLoopProxy<crate::UserEvent>) {
857 });857 });
858}858}
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
860/// Tells the user something they asked for could not be done: `message`, then what to do.934/// Tells the user something they asked for could not be done: `message`, then what to do.
861pub fn alert(message: &str, detail: &str) {935pub fn alert(message: &str, detail: &str) {
862 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");936 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
crates/snowbound/src/main.rs+3
...@@ -886,6 +886,8 @@ impl State {...@@ -886,6 +886,8 @@ impl State {
886 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;886 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;
887 let size = window.inner_size();887 let size = window.inner_size();
888 let mut engine = TextEngine::default();888 let mut engine = TextEngine::default();
889 let fallbacks = platform::symbol_fonts();
890 draw::fall_back_to(&mut engine.fonts.collection, &fallbacks);
889 for path in substitutes {891 for path in substitutes {
890 let target = engine892 let target = engine
891 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;893 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
...@@ -1019,6 +1021,7 @@ impl State {...@@ -1019,6 +1021,7 @@ impl State {
1019 // A window shown but never focused hears no focus event; a hidden one draws as focused.1021 // A window shown but never focused hears no focus event; a hidden one draws as focused.
1020 ui.window_focused = !visible || window.has_focus();1022 ui.window_focused = !visible || window.has_focus();
1021 platform::system_interface(&mut ui);1023 platform::system_interface(&mut ui);
1024 ui.fall_back_to(&fallbacks);
1022 ui.set_focus(Some(page()));1025 ui.set_focus(Some(page()));
1023 for family in FONTS {1026 for family in FONTS {
1024 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {1027 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) {...@@ -1159,6 +1159,47 @@ pub fn open_file(path: &std::path::Path) {
1159 reveal(path);1159 reveal(path);
1160}1160}
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
1162/// No shell icon lookup yet; the page draws a blank page for the file.1203/// No shell icon lookup yet; the page draws a blank page for the file.
1163pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {1204pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {
1164 None1205 None
crates/ui/src/lib.rs+6
...@@ -1297,6 +1297,12 @@ impl Ui {...@@ -1297,6 +1297,12 @@ impl Ui {
1297 self.texts.preview_font(data, family);1297 self.texts.preview_font(data, family);
1298 }1298 }
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
1300 /// Whether the interface's fonts draw every glyph of `text`.1306 /// Whether the interface's fonts draw every glyph of `text`.
1301 pub fn shows(&mut self, text: &str) -> bool {1307 pub fn shows(&mut self, text: &str) -> bool {
1302 self.texts.shows(text, self.frame)1308 self.texts.shows(text, self.frame)
crates/ui/src/text.rs+27-12
...@@ -21,8 +21,8 @@ pub(crate) struct Texts {...@@ -21,8 +21,8 @@ pub(crate) struct Texts {
21 context: LayoutContext<()>,21 context: LayoutContext<()>,
22 /// By text, size, weight and preferred family, with the frame each was last used.22 /// By text, size, weight and preferred family, with the frame each was last used.
23 cache: HashMap<LabelKey, (Rc<Label>, u64)>,23 cache: HashMap<LabelKey, (Rc<Label>, u64)>,
24 /// The Last Resort font's data, once looked for.24 /// The Last Resort font, once looked for.
25 last_resort: Option<Option<u64>>,25 last_resort: Option<Option<Blob<u8>>>,
26}26}
2727
28impl Texts {28impl Texts {
...@@ -106,24 +106,34 @@ impl Texts {...@@ -106,24 +106,34 @@ impl Texts {
106 self.styled(&cut, size, *bold, *italic, font, frame)106 self.styled(&cut, size, *bold, *italic, font, frame)
107 }107 }
108108
109 /// Whether the interface's fonts or their fallbacks draw every glyph of `text`, not the109 /// Whether the interface's fonts or their fallbacks draw `text`: some glyph, and none the
110 /// missing glyph nor the placeholders of macOS's Last Resort font.110 /// missing glyph or a placeholder of macOS's Last Resort font.
111 pub fn shows(&mut self, text: &str, frame: u64) -> bool {111 pub fn shows(&mut self, text: &str, frame: u64) -> bool {
112 let last_resort = *self.last_resort.get_or_insert_with(|| {112 let last_resort = self
113 let family = self.fonts.collection.family_by_name("LastResort")?;113 .last_resort
114 let font = family.fonts().first()?;114 .get_or_insert_with(|| {
115 Some(font.load(Some(&mut self.fonts.source_cache))?.id())115 let family = self.fonts.collection.family_by_name(".LastResort")?;
116 });116 family
117 .fonts()
118 .first()?
119 .load(Some(&mut self.fonts.source_cache))
120 })
121 .clone();
117 let label = self.styled(text, 16.0, false, false, None, frame);122 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| {
119 line.items().all(|item| match item {125 line.items().all(|item| match item {
120 PositionedLayoutItem::GlyphRun(run) => {126 PositionedLayoutItem::GlyphRun(run) => {
121 Some(run.run().font().font.data.id()) != last_resort127 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)
122 && run.glyphs().all(|glyph| glyph.id != 0)131 && run.glyphs().all(|glyph| glyph.id != 0)
123 }132 }
124 PositionedLayoutItem::InlineBox(_) => true,133 PositionedLayoutItem::InlineBox(_) => true,
125 })134 })
126 })135 });
136 drawn && glyphs > 0
127 }137 }
128138
129 /// Makes `family` the interface's font, where fontique doesn't know the system's.139 /// Makes `family` the interface's font, where fontique doesn't know the system's.
...@@ -135,6 +145,11 @@ impl Texts {...@@ -135,6 +145,11 @@ impl Texts {
135 }145 }
136 }146 }
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
138 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {153 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {
139 self.fonts.collection.register_fonts(154 self.fonts.collection.register_fonts(
140 data,155 data,