diff --git a/Cargo.lock b/Cargo.lock index f772f83..398c656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1555,6 +1555,7 @@ dependencies = [ "log", "serde", "serde_json", + "skrifa", "sys-locale", "toml", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index d7fad0f..15aa9ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..39fa740 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a6a2da2..4f44fd9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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"] } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 219e2d2..a414139 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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 { 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 { 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 = 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 { 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); + } }