Merge pull request #9 from justinjohn0306/fix/system-fonts-in-list

Fix: list and render installed system fonts (#4)
This commit is contained in:
Brandon Thomas 2026-10-11 15:38:44 -04:00
commit 4dca02c89b
8 changed files with 556 additions and 7 deletions

1
Cargo.lock generated
View File

@ -1589,6 +1589,7 @@ version = "0.5.0"
dependencies = [
"egui",
"egui_kittest",
"epaint_default_fonts",
"gridcraft-chart",
"gridcraft-engine",
"image",

View File

@ -49,6 +49,8 @@ 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"] }
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
epaint_default_fonts = "0.36"
[workspace.lints.rust]
unsafe_code = "forbid"

View File

@ -26,6 +26,7 @@ zbus = "=5.19.0"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
epaint_default_fonts = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The desktop's preferred language for the first-run interface language.

View File

@ -1414,8 +1414,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
}
"Font" => {
egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| {
for f in crate::ribbon::FONTS {
ui.selectable_value(&mut st.font.name, f.to_string(), *f);
for f in crate::fonts::families() {
ui.selectable_value(&mut st.font.name, f.clone(), f.clone());
}
});
ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size"));

423
crates/ui-egui/src/fonts.rs Normal file
View File

@ -0,0 +1,423 @@
//! The fonts installed on the machine, so the font list can offer them and cells can render with
//! them (#4).
//!
//! egui has no font discovery of its own and panics when asked for a family that was never bound
//! to font data. So we do both halves: scan the platform's font folders for family names (a read
//! of each font file's `name` table — damaged or unreadable files yield nothing, never a panic),
//! and bind the files a workbook actually uses into the families
//! [`theme::font_definitions`](crate::theme::font_definitions) assembles.
//!
//! Native only: on wasm there are no system fonts, every function here reports "nothing", and the
//! font list is the standard [`crate::ribbon::FONTS`] set.
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::OnceLock;
/// A font file is skipped by the scan past this size: a catalog only needs each face's name, and a
/// damaged or enormous file must not turn startup into a long read.
#[cfg(not(target_arch = "wasm32"))]
const MAX_FONT_FILE: u64 = 64 << 20;
/// One face of one installed font file.
#[derive(Clone, Debug)]
pub struct Face {
/// Typographic family name, e.g. `"Segoe UI"`.
pub family: String,
/// The file the face lives in.
pub path: PathBuf,
/// Face index within the file (collections hold several, e.g. a family's Regular and Bold).
pub index: u32,
/// Weight implied by the face's style name (400 = regular).
pub weight: f32,
/// Whether the style name reads as italic or oblique.
pub italic: bool,
}
/// The font folders of the platform (the system's and the user's). Empty on wasm.
pub fn system_font_dirs() -> Vec<PathBuf> {
#[cfg(target_arch = "wasm32")]
{
Vec::new()
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut dirs: Vec<PathBuf> = Vec::new();
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join("Library/Fonts"));
}
} else if cfg!(windows) {
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows"));
dirs.push(root.join("Fonts"));
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
} else {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
dirs.push(h.join(".local/share/fonts"));
}
}
dirs
}
}
/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300).
#[cfg(not(target_arch = "wasm32"))]
fn style_weight(style: &str) -> f32 {
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
const TABLE: &[(&str, f32)] = &[
("hairline", 100.0),
("thin", 100.0),
("extralight", 200.0),
("ultralight", 200.0),
("light", 300.0),
("medium", 500.0),
("demibold", 600.0),
("semibold", 600.0),
("extrabold", 800.0),
("ultrabold", 800.0),
("bold", 700.0),
("black", 900.0),
("heavy", 900.0),
];
TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0)
}
/// Whether a style name reads as italic or oblique.
#[cfg(not(target_arch = "wasm32"))]
fn style_italic(style: &str) -> bool {
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
s.contains("italic") || s.contains("oblique")
}
/// Every face in `data` as `(index, family, style)`. Damaged data yields nothing, never a panic.
#[cfg(not(target_arch = "wasm32"))]
fn file_faces(data: &[u8]) -> Vec<(u32, String, String)> {
use skrifa::raw::FileRef;
use skrifa::{MetadataProvider, string::StringId};
let count = match FileRef::new(data) {
Ok(FileRef::Font(_)) => 1,
Ok(FileRef::Collection(c)) => c.len(),
Err(_) => return Vec::new(),
};
let name = |f: &skrifa::FontRef<'_>, ids: &[StringId]| -> Option<String> {
ids.iter().find_map(|id| f.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
};
(0..count)
.filter_map(|i| {
let f = skrifa::FontRef::from_index(data, i).ok()?;
let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
Some((i, family, style))
})
.collect()
}
/// Catalog every face under `dirs`, recursing into subfolders. A folder reached twice (through a
/// symlink) is read once, so a link back to a parent ends the walk.
#[cfg(not(target_arch = "wasm32"))]
pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
let mut out: Vec<Face> = Vec::new();
let mut stack: Vec<PathBuf> = dirs.to_vec();
let mut seen_dirs: HashSet<PathBuf> = HashSet::new();
while let Some(dir) = stack.pop() {
let key = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
if !seen_dirs.insert(key) {
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let is_font =
path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
continue;
}
let Ok(data) = std::fs::read(&path) else { continue };
for (index, family, style) in file_faces(&data) {
out.push(Face { family, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) });
}
}
}
out
}
#[cfg(target_arch = "wasm32")]
pub fn scan_dirs(_dirs: &[PathBuf]) -> Vec<Face> {
Vec::new()
}
static CATALOG: OnceLock<Vec<Face>> = OnceLock::new();
/// The catalog of installed faces, scanned on first use. Blocks until the scan finishes.
pub fn catalog() -> &'static [Face] {
CATALOG.get_or_init(|| scan_dirs(&system_font_dirs()))
}
/// The catalog if the scan has finished, else `None` — never blocks, for use while painting.
fn catalog_now() -> Option<&'static [Face]> {
CATALOG.get().map(Vec::as_slice)
}
/// Start cataloging the installed fonts on a background thread, so the first frame doesn't wait for
/// it. A no-op on wasm and after the scan has started.
pub fn start_scan() {
static STARTED: OnceLock<()> = OnceLock::new();
STARTED.get_or_init(|| {
#[cfg(not(target_arch = "wasm32"))]
if CATALOG.get().is_none() {
// A failed spawn leaves the scan to the first blocking lookup.
let _ = std::thread::Builder::new().name("font-scan".into()).spawn(|| {
catalog();
});
}
});
}
/// Whether the background scan has finished.
pub fn scan_done() -> bool {
CATALOG.get().is_some()
}
/// The faces of one installed family, regular-weight first (the order roles pick from).
pub fn faces_of(family: &str) -> Vec<Face> {
let Some(cat) = catalog_now() else { return Vec::new() };
let mut v: Vec<Face> = cat.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).cloned().collect();
v.sort_by(|a, b| a.weight.total_cmp(&b.weight).then(a.italic.cmp(&b.italic)).then(a.index.cmp(&b.index)));
v
}
/// The subset of `names` that is installed, sorted and deduped. These are the families whose files
/// get bound for a workbook.
pub fn installed_among(names: &[String]) -> Vec<String> {
let Some(cat) = catalog_now() else { return Vec::new() };
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for wanted in names {
if !seen.insert(wanted.to_lowercase()) {
continue;
}
if let Some(f) = cat.iter().find(|f| f.family.eq_ignore_ascii_case(wanted)) {
out.push(f.family.clone());
}
}
out.sort_by_key(|a| a.to_lowercase());
out
}
/// The font list a combo box shows: the standard [`crate::ribbon::FONTS`] names first (what Excel
/// offers, and what we substitute), then every other installed family A–Z. Never blocks: until the
/// background scan finishes it returns just the standard names.
pub fn families() -> Vec<String> {
let mut out: Vec<String> = crate::ribbon::FONTS.iter().map(|s| s.to_string()).collect();
let Some(cat) = catalog_now() else { return out };
let mut seen: HashSet<String> = out.iter().map(|s| s.to_lowercase()).collect();
let mut installed: Vec<String> = cat.iter().map(|f| f.family.clone()).collect();
installed.sort_by_key(|a| a.to_lowercase());
installed.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
for f in installed {
if seen.insert(f.to_lowercase()) {
out.push(f);
}
}
out
}
/// The egui family name for one role of one family: `fnt:{family}:{b|n}{i|n}`.
pub fn role_key(family: &str, bold: bool, italic: bool) -> String {
format!("fnt:{family}:{}{}", if bold { 'b' } else { 'n' }, if italic { 'i' } else { 'n' })
}
/// The face to use for a role, or `None` when the family has no faces. Missing bold/italic faces
/// fall back to the family's regular face, so every bound role has data behind it.
fn pick_face(faces: &[Face], bold: bool, italic: bool) -> Option<&Face> {
let regular = || faces.iter().find(|f| !f.italic && (f.weight - 400.0).abs() < 1.0);
let exact = |wanted_bold: bool, wanted_italic: bool| {
faces.iter().find(|f| f.italic == wanted_italic && if wanted_bold { f.weight >= 600.0 } else { f.weight < 600.0 })
};
(match (bold, italic) {
(true, true) => exact(true, true).or_else(|| exact(true, false)).or_else(|| exact(false, true)),
(true, false) => exact(true, false),
(false, true) => exact(false, true),
(false, false) => None,
})
.or_else(regular)
.or_else(|| faces.first())
}
/// A stable key for a face's font data, unique per file/index within the process.
fn face_key(family: &str, face: &Face) -> String {
let file = face.path.file_name().and_then(|s| s.to_str()).unwrap_or("font");
format!("fnt-src:{family}:{file}:{}", face.index)
}
/// Bind one installed family's faces into `fonts`, one egui family per cell role, and return the
/// role keys that are now bound. A role with no exact face chains to the family's regular face, so
/// no role is ever left pointing at missing data (egui panics on that). A family with no faces
/// binds nothing.
///
/// Each role's chain ends with egui's default proportional fonts, so glyphs the installed font
/// lacks (emoji, a stray script) still render.
pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face]) -> Vec<String> {
if faces.is_empty() {
return Vec::new();
}
let base: Vec<String> = fonts.families.get(&egui::FontFamily::Proportional).cloned().unwrap_or_default();
let mut bound: Vec<String> = Vec::new();
for (bold, italic) in [(false, false), (true, false), (false, true), (true, true)] {
let Some(primary) = pick_face(faces, bold, italic) else { continue };
let mut chain: Vec<String> = Vec::new();
for face in [Some(primary), pick_face(faces, false, false)] {
let Some(face) = face else { continue };
let key = face_key(family, face);
if chain.contains(&key) {
continue;
}
let Ok(bytes) = std::fs::read(&face.path) else { continue };
let mut fd = egui::FontData::from_owned(bytes);
fd.index = face.index;
fonts.font_data.entry(key.clone()).or_insert_with(|| std::sync::Arc::new(fd));
chain.push(key);
}
if chain.is_empty() {
continue;
}
chain.extend(base.iter().cloned());
let role = role_key(family, bold, italic);
fonts.families.insert(egui::FontFamily::Name(role.clone().into()), chain);
bound.push(role);
}
bound
}
/// Bind every installed face of `family` (see [`bind_faces`]).
pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec<String> {
bind_faces(fonts, family, &faces_of(family))
}
/// The role families currently bound to font data. `cell_family` consults this, so it never names a
/// family egui hasn't been given.
static BOUND: OnceLock<std::sync::RwLock<HashSet<String>>> = OnceLock::new();
fn bound() -> &'static std::sync::RwLock<HashSet<String>> {
BOUND.get_or_init(|| std::sync::RwLock::new(HashSet::new()))
}
/// Record the role families a freshly built [`egui::FontDefinitions`] bound.
pub fn set_bound(keys: impl IntoIterator<Item = String>) {
*bound().write().unwrap_or_else(|e| e.into_inner()) = keys.into_iter().collect();
}
/// Is this exact role family bound to font data?
pub fn is_bound(role: &str) -> bool {
bound().read().unwrap_or_else(|e| e.into_inner()).contains(role)
}
/// Serializes tests that mutate the process-wide bound-family registry.
#[cfg(test)]
pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn style_names_map_to_weight_and_slant() {
assert_eq!(style_weight("Bold"), 700.0);
assert_eq!(style_weight("SemiBold Italic"), 600.0);
assert_eq!(style_weight("Regular"), 400.0);
assert_eq!(style_weight("Light Oblique"), 300.0);
assert!(style_italic("Bold Italic") && style_italic("Oblique"));
assert!(!style_italic("Bold"));
// The weight table is checked in order, so "extralight" wins over "light".
assert_eq!(style_weight("ExtraLight"), 200.0);
}
#[test]
fn role_keys_are_stable_and_distinct() {
assert_eq!(role_key("Segoe UI", false, false), "fnt:Segoe UI:nn");
assert_eq!(role_key("Segoe UI", true, false), "fnt:Segoe UI:bn");
assert_eq!(role_key("Segoe UI", false, true), "fnt:Segoe UI:ni");
assert_eq!(role_key("Segoe UI", true, true), "fnt:Segoe UI:bi");
}
#[test]
fn nothing_is_bound_until_font_definitions_says_so() {
let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// A role key is only safe to name after font_definitions bound it.
let k = role_key("Some Installed Font", true, false);
assert!(!is_bound(&k));
set_bound([k.clone()]);
assert!(is_bound(&k));
set_bound([]);
assert!(!is_bound(&k));
}
#[test]
fn damaged_or_non_font_files_contribute_nothing() {
assert!(file_faces(b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff").is_empty());
assert!(file_faces(b"not a font at all").is_empty());
assert!(file_faces(&[]).is_empty());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_scan_catalogs_fonts_and_reads_each_folder_once() {
let dir = std::env::temp_dir().join(format!("gridcraft-fonts-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Sub")).expect("temp dir");
std::fs::write(dir.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).expect("write");
std::fs::write(dir.join("damaged.ttf"), b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff").expect("write");
std::fs::write(dir.join("readme.txt"), b"not a font").expect("write");
// The same folder listed twice (once through `Sub/..`) is read once.
let faces = scan_dirs(&[dir.clone(), dir.join("Sub/..")]);
let families: Vec<&str> = faces.iter().map(|f| f.family.as_str()).collect();
assert_eq!(families, ["Ubuntu"], "one face, family read from the name table");
assert_eq!(faces[0].weight, 300.0, "“Light” reads as weight 300");
assert!(!faces[0].italic);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_link_back_to_a_parent_folder_ends_the_scan() {
let dir = std::env::temp_dir().join(format!("gridcraft-fonts-loop-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Sub")).expect("temp dir");
std::fs::write(dir.join("Sub/Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).expect("write");
std::os::unix::fs::symlink(&dir, dir.join("Sub/loop")).expect("symlink");
assert_eq!(scan_dirs(std::slice::from_ref(&dir)).len(), 1, "the loop doesn't multiply faces");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn binding_a_family_binds_all_four_roles_and_never_stores_missing_data() {
let dir = std::env::temp_dir().join(format!("gridcraft-fonts-bind-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("Ubuntu-Light.ttf");
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write");
let face = Face { family: "Ubuntu".into(), path, index: 0, weight: 300.0, italic: false };
let mut fonts = egui::FontDefinitions::default();
let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face));
assert_eq!(bound.len(), 4, "regular, bold, italic and bold-italic roles");
for key in &bound {
let chain = fonts.families.get(&egui::FontFamily::Name(key.as_str().into())).expect("role family");
assert!(chain.iter().all(|k| fonts.font_data.contains_key(k)), "every chained key has data");
}
// A family with no faces binds nothing and never touches the map.
assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -14,6 +14,7 @@ pub mod control;
pub mod credits;
pub mod dialogs;
pub mod editor;
pub mod fonts;
pub mod formula_bar;
mod formula_locale;
pub mod grid;
@ -210,6 +211,14 @@ pub struct SheetApp {
pub perf: Perf,
pub fonts_ready: bool,
fonts_set: bool,
/// Workbook fonts bound into the current font set; a change rebinds (installed fonts only).
fonts_bound: Vec<String>,
/// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only
/// when the workbook changes.
fonts_seen: (u64, u64),
/// Set while a font-set change is pending activation: skip this frame's paint, like the
/// startup `fonts_ready` gate (egui applies new definitions at the next pass).
fonts_rebind: bool,
/// Han face order the installed fonts were built with; a language switch rebuilds the fonts
/// only when it changes this (see `theme::HanOrder`).
fonts_han: Option<theme::HanOrder>,
@ -252,6 +261,9 @@ impl SheetApp {
perf: Perf::default(),
fonts_ready: false,
fonts_set: false,
fonts_bound: Vec::new(),
fonts_seen: (0, 0),
fonts_rebind: false,
fonts_han: None,
effective_dark: false,
desktop_theme: desktop_theme::DesktopTheme::default(),
@ -264,8 +276,12 @@ impl SheetApp {
}
}
/// One-time context setup: fonts and visuals (default Han order; never reads the host locale).
/// One-time context setup: start cataloging the machine's fonts, then install the fonts and
/// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]).
///
/// [`logic`]: SheetApp::logic
pub fn setup_context(ctx: &egui::Context, dark: bool) {
fonts::start_scan();
Self::setup_context_for_language(ctx, dark, i18n::Language::En);
}
@ -275,6 +291,18 @@ impl SheetApp {
theme::apply(ctx, dark);
}
/// The font names the active workbook uses: every style's font plus the theme's heading/body
/// fonts. These are the families [`fonts`] binds an installed file for.
pub fn used_fonts(&self) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
if let Some(d) = self.session.active() {
names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone()));
names.push(d.wb.theme.major_font.clone());
names.push(d.wb.theme.minor_font.clone());
}
names
}
/// Runs an engine command (or a UI command) and handles UI requests it makes.
pub fn run(&mut self, id: &str, params: Json) -> Result<Json, String> {
if id.starts_with("file.")
@ -764,9 +792,30 @@ impl SheetApp {
} else {
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
self.fonts_set = true;
// The rebuilt set has no workbook fonts yet: bind them again once it is active.
self.fonts_bound.clear();
self.fonts_seen = (0, 0);
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
ctx.request_repaint();
}
} else if self.fonts_rebind {
// A font-set change made last frame is active now.
self.fonts_rebind = false;
} else if fonts::scan_done() {
// Bind the workbook's installed fonts once the background scan has finished. Adding
// fonts re-applies the font set, which egui activates at the next pass, so skip this
// frame's paint (same one-frame cost as startup). Only when the workbook changed.
let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
if seen != self.fonts_seen {
let used = self.used_fonts();
self.fonts_seen = seen;
if used != self.fonts_bound {
ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used));
self.fonts_bound = used;
self.fonts_rebind = true;
ctx.request_repaint();
}
}
}
if let Some(answer) = self.desktop_theme.take_change() {
self.desktop_dark = Some(answer);
@ -828,7 +877,7 @@ impl SheetApp {
/// Lays out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
if !self.fonts_ready {
if !self.fonts_ready || self.fonts_rebind {
ctx.request_repaint();
return;
}

View File

@ -829,9 +829,9 @@ fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let mut name = st.font.name.clone();
let before = name.clone();
egui::ComboBox::from_id_salt("font_name").width(150.0).selected_text(egui::RichText::new(&name).font(theme::ui_font(12.5))).show_ui(ui, |ui| {
for f in FONTS {
let fam = theme::cell_family(f, false, false);
ui.selectable_value(&mut name, f.to_string(), egui::RichText::new(*f).font(egui::FontId::new(14.0, fam)));
for f in crate::fonts::families() {
let fam = theme::cell_family(&f, false, false);
ui.selectable_value(&mut name, f.clone(), egui::RichText::new(&f).font(egui::FontId::new(14.0, fam)));
}
});
if name != before {

View File

@ -159,7 +159,16 @@ pub fn ui_bold(size: f32) -> FontId {
}
/// The font family to use for a cell font name and weight.
///
/// When the name is an installed font whose faces were bound by [`font_definitions`], that family
/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui
/// panics on a family that was never bound, so the installed branch is gated on
/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given.
pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily {
let role = crate::fonts::role_key(name, bold, italic);
if crate::fonts::is_bound(&role) {
return FontFamily::Name(role.into());
}
let lower = name.to_ascii_lowercase();
if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") {
return FontFamily::Name(MONO.into());
@ -370,9 +379,17 @@ impl HanOrder {
/// Builds the font set with the Han fallback faces ordered for the interface language.
pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions {
font_definitions_with(language, &[])
}
/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are
/// installed, so cells set in them render with them; the rest fall back to the roles. The bound
/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has.
pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let base_prop: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let base_mono: Vec<String> = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default();
let mut bound: Vec<String> = Vec::new();
// The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin,
// and epaint picks the first face that has the glyph, so they only catch their own scripts.
let fallback = load_fallback(&mut fonts, HanOrder::of(language));
@ -393,12 +410,20 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef
chain.extend(fallback.iter().cloned());
chain.retain(|k| fonts.font_data.contains_key(k));
fonts.families.insert(FontFamily::Name(role.into()), chain);
bound.push(role.to_string());
}
// Installed workbook fonts, so a cell's chosen font actually renders in it.
for family in crate::fonts::installed_among(used) {
bound.extend(crate::fonts::bind_family(&mut fonts, &family));
}
// Default proportional text in widgets uses the UI font.
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
fonts.families.insert(FontFamily::Proportional, ui);
}
drop_unparseable_fonts(&mut fonts);
// Only families egui still has data for count as bound (a dropped face can empty a chain).
bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty()));
crate::fonts::set_bound(bound);
fonts
}
@ -675,6 +700,54 @@ pub fn color32(rgb: [u8; 3]) -> Color32 {
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
}
#[cfg(test)]
mod font_binding_tests {
use super::*;
/// The families [`font_definitions`] always binds, whatever is installed.
fn always_bound() -> Vec<String> {
[UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() {
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// Nothing installed: every name buckets to a role that font_definitions bound.
crate::fonts::set_bound(always_bound());
for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] {
for (b, i) in [(false, false), (true, false), (false, true), (true, true)] {
let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") };
assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}");
}
}
}
#[test]
fn a_bound_installed_font_is_used_by_name() {
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let role = crate::fonts::role_key("Ubuntu", true, false);
crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()]));
assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role));
// The same family in a style that wasn't bound falls back to a built-in role that is.
let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") };
assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role");
crate::fonts::set_bound(always_bound());
}
#[test]
fn arbitrary_names_never_yield_an_unbound_family() {
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// A workbook can hold any font name; none may name a family egui has no data for.
crate::fonts::set_bound(always_bound());
for seed in 0..500u32 {
let name: String =
(0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect();
let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue };
assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}");
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;