UI: skip system fonts egui can't parse instead of panicking at startup (#226)

epaint panics in set_fonts on a face skrifa can't open. Every system font
file is now checked with the same skrifa call (plus a readable cmap)
before it reaches FontDefinitions; a bad candidate is skipped with a
warning and the next one is tried, and a final pass drops any remaining
unparseable face from the definitions and every family.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 11:55:32 +02:00
parent 9f896c1249
commit 573cf92745
5 changed files with 87 additions and 0 deletions

1
Cargo.lock generated
View File

@ -1555,6 +1555,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"skrifa",
"sys-locale",
"toml",
"web-sys",

View File

@ -45,6 +45,9 @@ rfd = "0.17"
arboard = { version = "3.6", default-features = false }
# The desktop's preferred language, for the first-run interface language (native only).
sys-locale = "0.3"
# Font parser egui's epaint uses; the UI validates system font files with it before handing them
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
[workspace.lints.rust]
unsafe_code = "forbid"

View File

@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) |
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |

View File

@ -16,6 +16,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
image = { workspace = true }
skrifa = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }

View File

@ -166,10 +166,28 @@ pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily {
)
}
/// Whether epaint can open face `index` of this font file and it maps characters to glyphs.
/// epaint panics (in `Context::set_fonts`, i.e. on startup) on a face it can't open, so every font
/// file we did not ship is checked first with the same parser and the same call epaint makes
/// (`skrifa::FontRef::from_index`): a corrupt file, something that is not an sfnt at all, or a
/// collection index past the end is rejected instead of crashing the app (#226). A face whose
/// character map is unreadable (a truncated file) would open but draw nothing, so it is refused
/// too and the next candidate gets its turn.
pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool {
use skrifa::MetadataProvider;
skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().has_map())
}
/// Loads the first candidate that exists and parses. A file that exists but can't be parsed is
/// skipped with a warning and the next candidate is tried.
#[cfg(not(target_arch = "wasm32"))]
fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
for (p, index) in paths {
if let Ok(bytes) = std::fs::read(p) {
if !font_parses(&bytes, *index) {
log::warn!("skipping font {p} (face {index}): not a font file egui can read");
continue;
}
let mut fd = FontData::from_owned(bytes);
fd.index = *index;
return Some(fd);
@ -178,6 +196,21 @@ fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
None
}
/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not
/// parse (and its key from every family), so a bad font costs a fallback, never a crash.
pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
let bad: Vec<String> = fonts.font_data.iter().filter(|(_, fd)| !font_parses(&fd.font, fd.index)).map(|(k, _)| k.clone()).collect();
for key in &bad {
log::warn!("dropping font {key:?}: egui can't parse it");
fonts.font_data.remove(key);
}
if !bad.is_empty() {
for chain in fonts.families.values_mut() {
chain.retain(|k| !bad.contains(k));
}
}
}
#[cfg(target_arch = "wasm32")]
fn try_load(_paths: &[(&str, u32)]) -> Option<FontData> {
None
@ -340,6 +373,7 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
fonts.families.insert(FontFamily::Proportional, ui);
}
drop_unparseable_fonts(&mut fonts);
fonts
}
@ -650,4 +684,51 @@ mod tests {
}
}
}
#[test]
fn unparseable_font_files_are_rejected_not_fatal() {
// #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of
// bounds"). Garbage, a truncated real font and a bad collection index must all be refused.
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
assert!(font_parses(&real, 0), "egui's own font must parse");
assert!(!font_parses(b"", 0));
assert!(!font_parses(&[0xAB; 64], 0));
assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused");
assert!(!font_parses(&real, 3), "a single-face file has no face 3");
// A bad face that slipped into the definitions is dropped (key and family entries), and the
// context builds its fonts without panicking.
let mut fonts = font_definitions();
fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0])));
for chain in fonts.families.values_mut() {
chain.push("sys-hebrew".into());
}
drop_unparseable_fonts(&mut fonts);
assert!(!fonts.font_data.contains_key("sys-hebrew"));
assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew")));
let ctx = egui::Context::default();
ctx.set_fonts(fonts);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label("Hebrew עברית");
});
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn try_load_skips_a_broken_candidate() {
// A candidate that exists but is not a readable font must not win over a good one behind it.
let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let bad = dir.join("broken.ttf");
let good = dir.join("good.ttf");
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default());
let _ = std::fs::write(&good, &real);
let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned());
assert!(try_load(&[(bad_s.as_str(), 0)]).is_none());
let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]);
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
let _ = std::fs::remove_dir_all(&dir);
}
}