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:
parent
9f896c1249
commit
573cf92745
|
|
@ -1555,6 +1555,7 @@ dependencies = [
|
|||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"web-sys",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue