diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 28dc465e2..0adb1563c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -20,27 +20,22 @@ use std::sync::Arc; use egui::epaint::text::Blob; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals}; -/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see -/// and its `docs/integration.md`). The slice is empty -/// when the app is built without the checkout; nothing is downloaded and no font is in this repo. -#[derive(Debug)] -pub struct CraftFont { - pub family: &'static str, - pub style: &'static str, - /// ISO 15924 scripts the family is intended for, from the craft-fonts manifest. - pub scripts: &'static [&'static str], - pub bytes: &'static [u8], -} +mod font_fallback; +mod font_setup; -mod craft_fonts { - use super::CraftFont; - include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); -} - -/// The light or dark value of a colour, by mode. -fn pick(dark: bool, light: T, dark_value: T) -> T { - if dark { dark_value } else { light } -} +pub use font_fallback::symbol_face_installed; +#[cfg(test)] +use font_fallback::{cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, symbols_more, thai}; +use font_setup::pick; +pub use font_setup::{ + CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MONO, SERIF, UI, UI_BOLD, available_text, cell_family, cell_text_width, + chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required, font_definitions, + font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font, +}; +#[cfg(test)] +use font_setup::{candidates, craft_font_faces, try_load}; +#[cfg(test)] +pub(crate) use font_setup::{drop_unparseable_fonts, font_parses}; #[derive(Clone, Copy, Debug, PartialEq)] pub struct Tokens { @@ -261,816 +256,6 @@ pub fn contrast(a: Color32, b: Color32) -> f32 { (la.max(lb) + 0.05) / (la.min(lb) + 0.05) } -pub const UI: &str = "ui"; -pub const UI_BOLD: &str = "ui-bold"; -pub const CELL: &str = "cell"; -pub const CELL_BOLD: &str = "cell-bold"; -pub const CELL_ITALIC: &str = "cell-italic"; -pub const CELL_BOLD_ITALIC: &str = "cell-bold-italic"; -pub const SERIF: &str = "serif"; -pub const MONO: &str = "mono"; - -pub fn ui_font(size: f32) -> FontId { - FontId::new(size, FontFamily::Name(UI.into())) -} -pub fn ui_bold(size: f32) -> FontId { - FontId::new(size, FontFamily::Name(UI_BOLD.into())) -} - -/// 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()); - } - if lower.contains("times") - || lower.contains("georgia") - || lower.contains("cambria") - || lower.contains("serif") && !lower.contains("sans") - || lower.contains("garamond") - { - return FontFamily::Name(SERIF.into()); - } - FontFamily::Name( - match (bold, italic) { - (true, true) => CELL_BOLD_ITALIC, - (true, false) => CELL_BOLD, - (false, true) => CELL_ITALIC, - (false, false) => CELL, - } - .into(), - ) -} - -/// Width in points, at 100% zoom, of one line of cell text drawn as the grid draws it: in the -/// face [`cell_family`] picks, which on a machine without the named font is a wider or narrower -/// substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the -/// next frame) falls back to the engine's estimate rather than letting egui panic on it. -/// -/// `exact` lays the text out (a galley kept until the end of the frame); otherwise the glyph -/// advances are summed without kerning, which is cheap and never narrower. This takes the -/// Context lock (`fonts_mut`): never call it, or run a command, while an egui lock is held. -pub fn cell_text_width(ctx: &egui::Context, text: &str, font: &gridcraft_engine::model::Font, exact: bool) -> f32 { - let family = cell_family(&font.name, font.bold, font.italic); - let id = FontId::new(font.size * 96.0 / 72.0, family); - ctx.fonts_mut(|f| { - f.families().contains(&id.family).then(|| { - if exact { - f.layout_no_wrap(text.to_owned(), id, Color32::PLACEHOLDER).size().x - } else { - // Half a point covers the galley's rounding of its width up to the pixel grid. - text.chars().map(|c| f.glyph_width(&id, c)).sum::() + 0.5 - } - }) - }) - .unwrap_or_else(|| gridcraft_engine::cmd::format::approx_text_width(text, font.size, font.bold)) -} - -/// 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 Some(blob) = font_file(p, *index) { - return Some(FontData::from_blob(blob, *index)); - } - } - None -} - -/// The interface and script fallback font files, each read once per run and kept as a shared -/// `Blob` for the rest of it. egui shares the blob instead of copying the file into its text -/// engine, because every one of these files is loaded by every font rebuild for as long as the -/// app runs. Only a file that parses is kept: a missing or broken one is looked for again next -/// time. -#[cfg(not(target_arch = "wasm32"))] -fn font_file(path: &str, index: u32) -> Option { - static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); - let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(blob) = files.get(path) { - let bytes: &[u8] = (**blob).as_ref(); - return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob)); - } - let bytes = std::fs::read(path).ok()?; - if !font_parses(&bytes, index) { - log::warn!("skipping font {path} (face {index}): not a font file egui can read"); - return None; - } - let blob: Blob = std::sync::Arc::new(bytes); - files.insert(path.to_string(), blob.clone()); - Some(blob) -} - -/// 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.bytes(), 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)); - } - } -} - -/// egui's bundled monospace face, used as the last-resort symbol fallback (see `Hack-Regular.ttf` -/// in `epaint_default_fonts`). It is present whenever egui is built with `default_fonts`. -const HACK: &str = "Hack"; - -/// The permitted craft-fonts faces with their `FontDefinitions` keys, in manifest order. Empty -/// without a `CRAFT_FONTS_DIR` checkout at build time. The filter is repeated here so a stale -/// generated table can never smuggle in a barred face or a web-build face outside the allowlist. -fn craft_font_faces() -> Vec<(String, &'static CraftFont)> { - let wasm = cfg!(target_arch = "wasm32"); - craft_fonts::CRAFT_FONTS - .iter() - .enumerate() - .filter(|(_, font)| !crate::craft_fonts_policy::barred(font.family)) - .filter(|(_, font)| !wasm || crate::craft_fonts_policy::web_allowed(font.family, font.style)) - .map(|(i, font)| (format!("craft-{i}-{}-{}", font.family, font.style), font)) - .collect() -} - -/// Adds the optional craft-fonts faces to `fonts` and returns their keys. -fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec { - let mut keys = Vec::new(); - for (key, font) in craft_font_faces() { - fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(font.bytes))); - keys.push(key); - } - keys -} - -/// Whether the optional craft-fonts input was embedded in this build. -pub fn craft_fonts_loaded() -> bool { - !craft_fonts::CRAFT_FONTS.is_empty() -} - -/// Whether the native-only Simplified Chinese craft face (LXGW WenKai Lite) was embedded. A web -/// build or an older craft-fonts checkout without `fonts/simplified-chinese-manifest.txt` has none. -pub fn chinese_font_loaded() -> bool { - craft_font_faces().iter().any(|(_, f)| f.family == "LXGW WenKai Lite" && f.style == "Regular") -} - -/// Whether this build was made with `CRAFT_FONTS_REQUIRED=1`. Acceptance tests must fail rather -/// than skip when this is true and the faces are absent. -pub fn craft_fonts_required() -> bool { - craft_fonts::CRAFT_FONTS_REQUIRED -} - -/// A bundled-face character with the same meaning as `c`, for interface chrome whose exact symbol -/// no bundled face has (`✕`, `✓`, `⌃`). Document text still needs a real glyph; this is only a -/// display fallback and never changes the characters a command inserts or stores. -pub fn symbol_fallback(c: char) -> Option { - match c { - '✕' => Some('×'), - '✓' => Some('✔'), - '⌃' => Some('^'), - _ => None, - } -} - -/// `text` with every character the current font set cannot draw replaced by its semantic fallback -/// (see [`symbol_fallback`]). Used by chrome tooltips so a host without symbol faces never paints -/// a tofu box; it is not applied to workbook text. -pub fn available_text(ui: &egui::Ui, text: &str) -> String { - let font = ui_font(13.0); - text.chars().map(|c| if ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) { c } else { symbol_fallback(c).unwrap_or(c) }).collect() -} - -/// The family, style and scripts of every embedded craft-fonts face (empty without the input). -pub fn craft_font_scripts() -> impl Iterator { - craft_font_faces().into_iter().map(|(_, f)| (f.family, f.style, f.scripts)) -} - -/// Whether one embedded craft-fonts face maps every non-whitespace character in `text`. Tests use -/// this to tell "the pinned fixture covers this script" from "this host has no face for it", -/// without trusting a manifest script tag alone. -pub fn craft_fonts_cover(text: &str) -> bool { - craft_font_faces().iter().any(|(_, f)| { - use skrifa::MetadataProvider; - let Ok(font) = skrifa::FontRef::from_index(f.bytes, 0) else { - return false; - }; - let map = font.charmap(); - text.chars().filter(|c| !c.is_whitespace()).all(|c| map.map(c).is_some()) - }) -} - -#[cfg(target_arch = "wasm32")] -fn try_load(_paths: &[(&str, u32)]) -> Option { - None -} - -fn home_fonts(file: &str) -> String { - let home = std::env::var("HOME").unwrap_or_default(); - format!("{home}/Library/Fonts/{file}") -} - -/// Every file in `files` under every directory in `dirs`, directory first, in the same order a -/// nested loop over the directories would add them. -fn in_dirs(dirs: &[&str], files: &[&str]) -> Vec { - dirs.iter().flat_map(|d| files.iter().map(move |f| format!("{d}/{f}"))).collect() -} - -/// Candidate font files per role, in preference order. -fn candidates(role: &str) -> Vec<(String, u32)> { - let mac_sys = "/System/Library/Fonts"; - let mac_sup = "/System/Library/Fonts/Supplemental"; - let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). - let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; - let mut v: Vec<(String, u32)> = Vec::new(); - let mut add = |s: String, i: u32| v.push((s, i)); - match role { - "ui" => { - add(format!("{mac_sys}/SFNS.ttf"), 0); - add(format!("{mac_sys}/HelveticaNeue.ttc"), 0); - add(format!("{win}\\segoeui.ttf"), 0); - for p in in_dirs( - &lin, - &["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"], - ) { - add(p, 0); - } - // Fedora keeps each family in its own package folder (#54). - add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); - add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); - add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); - add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); - } - "ui-bold" => { - add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); - add(format!("{win}\\segoeuib.ttf"), 0); - for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); - add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); - add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); - } - "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { - let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { - "cell" => { - ("Carlito-Regular.ttf", "calibri.ttf", "Calibri.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf") - } - "cell-bold" => ( - "Carlito-Bold.ttf", - "calibrib.ttf", - "Calibri Bold.ttf", - "Arial Bold.ttf", - "arialbd.ttf", - "LiberationSans-Bold.ttf", - "DejaVuSans-Bold.ttf", - ), - "cell-italic" => ( - "Carlito-Italic.ttf", - "calibrii.ttf", - "Calibri Italic.ttf", - "Arial Italic.ttf", - "ariali.ttf", - "LiberationSans-Italic.ttf", - "DejaVuSans-Oblique.ttf", - ), - _ => ( - "Carlito-BoldItalic.ttf", - "calibriz.ttf", - "Calibri Bold Italic.ttf", - "Arial Bold Italic.ttf", - "arialbi.ttf", - "LiberationSans-BoldItalic.ttf", - "DejaVuSans-BoldOblique.ttf", - ), - }; - // Calibri if the user has it installed system-wide (never from an Office bundle). - add(format!("/Library/Fonts/{calibri_mac}"), 0); - add(home_fonts(calibri_mac), 0); - add(format!("{win}\\{calibri_win}"), 0); - for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) { - add(p, 0); - } - // Fedora keeps each family in its own package folder, as for the UI fonts (#54). - add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0); - add(home_fonts(carlito), 0); - add(format!("{mac_sup}/{arial_mac}"), 0); - add(format!("{win}\\{arial_win}"), 0); - for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) { - add(p, 0); - } - add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0); - add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0); - } - "serif" => { - add(format!("{mac_sup}/Times New Roman.ttf"), 0); - add(format!("{win}\\times.ttf"), 0); - for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0); - add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0); - } - "mono" => { - add(format!("{mac_sys}/SFNSMono.ttf"), 0); - add(format!("{mac_sys}/Menlo.ttc"), 0); - add(format!("{win}\\consola.ttf"), 0); - for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0); - // Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice - // (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always. - for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0); - add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0); - } - _ => {} - } - v -} - -/// Builds the font set from system fonts (with egui's defaults as fallback), with the default -/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable. -pub fn font_definitions() -> FontDefinitions { - font_definitions_for_language(crate::i18n::Language::En) -} - -/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only -/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two -/// languages with the same order needs no font rebuild. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HanOrder { - /// Japanese glyph shapes first (the interface is Japanese). - Japanese, - /// Simplified Chinese first: the widest Han coverage, for every other interface language. - Chinese, -} - -impl HanOrder { - pub fn of(language: crate::i18n::Language) -> Self { - if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese } - } -} - -/// 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, &[]) -} - -/// The bundled faces plus the pinned craft-fonts faces, with no system-font probing or installed -/// workbook-font binding. -/// -/// Integration tests use this through their shared harness, so a minimal CI image and a developer -/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository -/// asset; without it only egui's bundled faces are available, which is the documented no-font mode. -pub fn pinned_font_definitions() -> FontDefinitions { - font_definitions_impl(crate::i18n::Language::En, &[], false) -} - -/// [`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 { - font_definitions_impl(language, used, true) -} - -/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound. -fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions { - let mut fonts = FontDefinitions::default(); - let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); - let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); - let mut bound: Vec = 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 = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; - let hack = fonts.font_data.contains_key(HACK).then_some(HACK); - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { - let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); - 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. - if system { - for family in crate::fonts::installed_among(used) { - bound.extend(crate::fonts::bind_family(&mut fonts, &family)); - } - } - // Interface text (formula bar, name box, dialogs) falls back to the cell font before the - // script faces: whatever a cell can draw, the formula bar can draw too, even where no script - // fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it). - bind_cell_fallback(&mut fonts, &fallback); - // 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); - } - // The optional craft-fonts faces go at the very end of every family: a system or bundled face - // that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and - // symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols). - // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still - // leads, and the barred Noto CJK collection never enters. - append_craft_faces(&mut fonts); - 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 -} - -/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled -/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data -/// are dropped, as before. -fn build_role_chain( - fonts: &mut FontDefinitions, - role: &str, - base_prop: &[String], - base_mono: &[String], - fallback: &[String], - hack: Option<&str>, - system: bool, -) -> Vec { - let paths = candidates(role); - let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); - let mut chain: Vec = Vec::new(); - let loaded = if system { try_load(&refs) } else { None }; - if let Some(mut fd) = loaded { - // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina - // screen (#1184). Core Text never runs them, so this draws it the way macOS does. - if cfg!(target_os = "macos") && role == UI_BOLD { - fd.tweak.hinting = Some(false); - } - let key = format!("sys-{role}"); - fonts.font_data.insert(key.clone(), Arc::new(fd)); - chain.push(key); - } else if role == UI_BOLD { - chain.push(format!("sys-{UI}")); - } else if role.starts_with("cell-") { - chain.push(format!("sys-{CELL}")); - } - chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); - // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but - // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as - // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it - // only catches characters those do not have. - if let Some(hack) = hack - && !chain.iter().any(|k| k.as_str() == hack) - { - chain.push(hack.to_string()); - } - chain.extend(fallback.iter().cloned()); - chain.retain(|k| fonts.font_data.contains_key(k)); - chain -} - -/// Inserts the loaded cell face before the script fallbacks of the interface families, so the -/// formula bar and dialogs can draw whatever a cell can (#349). -fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) { - let cell_key = format!("sys-{CELL}"); - if !fonts.font_data.contains_key(&cell_key) { - return; - } - for role in [UI, UI_BOLD, SERIF, MONO] { - if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) - && !chain.contains(&cell_key) - { - let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); - chain.insert(at, cell_key.clone()); - } - } -} - -/// Appends the permitted craft-fonts faces to every family, preserving the leading system or -/// bundled face's glyph shapes. -fn append_craft_faces(fonts: &mut FontDefinitions) { - let craft = add_craft_fonts(fonts); - if craft.is_empty() { - return; - } - for chain in fonts.families.values_mut() { - for key in &craft { - if !chain.contains(key) { - chain.push(key.clone()); - } - } - } -} - -/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so -/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so -/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text -/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing -/// is bundled, and a machine without the face keeps the old behaviour. -#[cfg(not(target_arch = "wasm32"))] -fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec { - let mut keys = Vec::new(); - // Files already loaded as a fallback face. A file serves at most one key: the region-merged - // Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and - // Hebrew), so loading it again for a later script would only duplicate tens of MB in memory. - let mut loaded: Vec = Vec::new(); - for (key, cands) in fallback_faces(han) { - for (path, index) in &cands { - if loaded.contains(path) { - break; // a face loaded for an earlier script already covers this one - } - if let Some(fd) = try_load(&[(path.as_str(), *index)]) { - fonts.font_data.insert(key.to_string(), Arc::new(fd)); - keys.push(key.to_string()); - loaded.push(path.clone()); - break; - } - } - } - keys -} - -#[cfg(target_arch = "wasm32")] -fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec { - Vec::new() -} - -/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script -/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one -/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so -/// the interface language reorders the two Han entries and nothing else. -#[cfg(not(target_arch = "wasm32"))] -fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { - let ja = ("sys-cjk-han-ja", cjk_han_japanese()); - let zh = ("sys-cjk-han-zh", cjk_han_simplified()); - let (first, second) = match han { - HanOrder::Japanese => (ja, zh), - HanOrder::Chinese => (zh, ja), - }; - vec![ - first, - second, - ("sys-cjk-hangul", cjk_hangul()), - ("sys-thai", thai()), - ("sys-lao", lao()), - ("sys-hebrew", hebrew()), - ("sys-symbols", symbols()), - ("sys-symbols-2", symbols_more()), - ] -} - -/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, -/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux -/// container (`chromeos/notocjk`, #1762), plus local and -/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts. -#[cfg(not(target_arch = "wasm32"))] -const LINUX_CJK_DIRS: [&str; 22] = [ - // Flatpak mounts host fonts here without a broad filesystem permission. Keep the same - // distribution layouts as below; /usr/share/fonts inside the sandbox belongs to the runtime. - "/run/host/fonts/opentype/noto", - "/run/host/fonts/google-noto-cjk", - "/run/host/fonts/noto-cjk", - "/run/host/fonts/truetype/noto", - "/run/host/fonts/noto", - "/run/host/fonts/truetype", - "/run/host/fonts/TTF", - "/run/host/fonts", - "/run/host/local-fonts", - "/run/host/local-fonts/noto-cjk", - "/run/host/user-fonts", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/google-noto-cjk", - "/usr/share/fonts/noto-cjk", - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/noto", - "/usr/share/fonts/truetype", - "/usr/share/fonts/TTF", - "/usr/share/fonts", - "/usr/local/share/fonts", - "/usr/local/share/fonts/noto-cjk", - "/usr/share/fonts/chromeos/notocjk", -]; - -/// The Windows fonts folder (`%WINDIR%\Fonts`, or the default install location). -#[cfg(not(target_arch = "wasm32"))] -fn windows_fonts() -> String { - std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()) -} - -/// The Noto Sans CJK faces for one locale (`jp`, `sc`, `kr`): the per-locale OTF, then the -/// region-merged TTC at `face`, in every Linux font directory. -#[cfg(not(target_arch = "wasm32"))] -fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> { - let mut v = Vec::new(); - for d in LINUX_CJK_DIRS { - v.push((format!("{d}/NotoSansCJK{locale}-Regular.otf"), 0)); - v.push((format!("{d}/NotoSansCJK-Regular.ttc"), face)); - } - v -} - -/// Japanese Han + Kana faces (Japanese kanji glyph shapes). -/// -/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, -/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list -/// comes first loads it; [`load_fallback`] does not load the same file again for the other. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_han_japanese() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = Vec::new(); - v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic - v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic - v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans - v.extend(noto_cjk("jp", 0)); - v -} - -/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks -/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_han_simplified() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = vec![ - (format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei - (format!("{win}\\simsun.ttc"), 0), // SimSun - (format!("{win}\\simhei.ttf"), 0), // SimHei - (format!("{mac}/Hiragino Sans GB.ttc"), 0), // ships on every recent macOS - (format!("{mac}/PingFang.ttc"), 0), // older macOS only; now a downloadable asset - ]; - v.extend(noto_cjk("sc", 2)); - for d in LINUX_CJK_DIRS { - v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); - v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0)); - v.push((format!("{d}/wqy-microhei.ttc"), 0)); - } - v -} - -/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file -/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_hangul() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = Vec::new(); - v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic - v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); - v.extend(noto_cjk("kr", 1)); - v -} - -/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai); -/// Linux font directories searched by the Thai and Hebrew faces, in priority order. -#[cfg(not(target_arch = "wasm32"))] -fn linux_noto_dirs() -> [&'static str; 5] { - [ - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/truetype", - "/usr/share/fonts", - "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) - ] -} - -/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the -/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font. -#[cfg(not(target_arch = "wasm32"))] -fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> { - let w = windows_fonts(); - let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect(); - v.extend(mac.iter().map(|p| (p.to_string(), 0))); - v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0))); - v.push((variable.into(), 0)); - v -} - -/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda. -#[cfg(not(target_arch = "wasm32"))] -fn thai() -> Vec<(String, u32)> { - script_fonts( - // Leelawadee UI, Tahoma, Microsoft Sans Serif - &["LeelawUI.ttf", "tahoma.ttf", "micross.ttf"], - &["/System/Library/Fonts/Supplemental/Thonburi.ttc", "/System/Library/Fonts/SukhumvitSet.ttc"], - &["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"], - // Fedora ships the variable Noto Sans Thai; its default instance is Regular. - "/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf", - ) -} - -/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam -/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT. -#[cfg(not(target_arch = "wasm32"))] -fn lao() -> Vec<(String, u32)> { - let mac_sup = "/System/Library/Fonts/Supplemental"; - let win = windows_fonts(); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; - let mut v: Vec<(String, u32)> = vec![ - (format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI - (format!("{win}\\dokchamp.ttf"), 0), // DokChampa - (format!("{mac_sup}/Lao Sangam MN.ttf"), 0), - (format!("{mac_sup}/Lao MN.ttc"), 0), - ]; - for d in lin { - v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0)); - v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0)); - v.push((format!("{d}/Phetsarath_OT.ttf"), 0)); - } - v -} - -/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has -/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans. -#[cfg(not(target_arch = "wasm32"))] -fn hebrew() -> Vec<(String, u32)> { - script_fonts( - // David, FrankRuehl, Tahoma - &["david.ttf", "frank.ttf", "tahoma.ttf"], - &[ - "/System/Library/Fonts/ArialHB.ttc", - "/System/Library/Fonts/SFHebrew.ttf", - "/System/Library/Fonts/Supplemental/Arial Hebrew.ttf", - "/System/Library/Fonts/Supplemental/David.ttf", - "/System/Library/Fonts/LucidaGrande.ttc", - "/System/Library/Fonts/Lucida Grande.ttc", - ], - &["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"], - "/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf", - ) -} - -/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's -/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), -/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover -/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already -/// loaded it. -#[cfg(not(target_arch = "wasm32"))] -fn symbols() -> Vec<(String, u32)> { - let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; - for d in [ - "/usr/share/fonts/truetype/dejavu", - "/usr/share/fonts/dejavu-sans-fonts", - "/usr/share/fonts/TTF", - "/usr/share/fonts/dejavu", - "/usr/share/fonts/truetype/ttf-dejavu", - "/usr/local/share/fonts/dejavu", - "/usr/local/share/fonts", - ] { - v.push((format!("{d}/DejaVuSans.ttf"), 0)); - } - v -} - -/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans -/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. -#[cfg(not(target_arch = "wasm32"))] -fn symbols_more() -> Vec<(String, u32)> { - let mut v: Vec<(String, u32)> = Vec::new(); - for d in [ - "/usr/share/fonts/noto", - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/google-noto-sans-symbols-2-fonts", - "/usr/share/fonts/google-noto", - "/usr/local/share/fonts/noto", - "/usr/share/fonts/chromeos/noto", - ] { - v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); - } - v -} - -/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). -pub fn symbol_face_installed() -> bool { - #[cfg(not(target_arch = "wasm32"))] - { - symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) - } - #[cfg(target_arch = "wasm32")] - { - false - } -} - /// Installs light and dark token visuals in both theme slots, keeps the 2-3 px widget and 8-10 px window/menu corner radii from 0.36, and sets spacing, text styles and the active theme. pub fn apply(ctx: &egui::Context, dark: bool) { // Install both palettes before System can switch between the style slots. @@ -1120,54 +305,6 @@ 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 { - [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(test)] mod contrast_tests { use super::*; diff --git a/crates/ui-egui/src/theme/font_fallback.rs b/crates/ui-egui/src/theme/font_fallback.rs new file mode 100644 index 000000000..940a871d5 --- /dev/null +++ b/crates/ui-egui/src/theme/font_fallback.rs @@ -0,0 +1,296 @@ +use super::font_setup::HanOrder; +#[cfg(not(target_arch = "wasm32"))] +use super::font_setup::{in_dirs, try_load}; +use super::*; + +/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so +/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so +/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text +/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing +/// is bundled, and a machine without the face keeps the old behaviour. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec { + let mut keys = Vec::new(); + // Files already loaded as a fallback face. A file serves at most one key: the region-merged + // Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and + // Hebrew), so loading it again for a later script would only duplicate tens of MB in memory. + let mut loaded: Vec = Vec::new(); + for (key, cands) in fallback_faces(han) { + for (path, index) in &cands { + if loaded.contains(path) { + break; // a face loaded for an earlier script already covers this one + } + if let Some(fd) = try_load(&[(path.as_str(), *index)]) { + fonts.font_data.insert(key.to_string(), Arc::new(fd)); + keys.push(key.to_string()); + loaded.push(path.clone()); + break; + } + } + } + keys +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec { + Vec::new() +} + +/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script +/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one +/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so +/// the interface language reorders the two Han entries and nothing else. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { + let ja = ("sys-cjk-han-ja", cjk_han_japanese()); + let zh = ("sys-cjk-han-zh", cjk_han_simplified()); + let (first, second) = match han { + HanOrder::Japanese => (ja, zh), + HanOrder::Chinese => (zh, ja), + }; + vec![ + first, + second, + ("sys-cjk-hangul", cjk_hangul()), + ("sys-thai", thai()), + ("sys-lao", lao()), + ("sys-hebrew", hebrew()), + ("sys-symbols", symbols()), + ("sys-symbols-2", symbols_more()), + ] +} + +/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, +/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux +/// container (`chromeos/notocjk`, #1762), plus local and +/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts. +#[cfg(not(target_arch = "wasm32"))] +const LINUX_CJK_DIRS: [&str; 22] = [ + // Flatpak mounts host fonts here without a broad filesystem permission. Keep the same + // distribution layouts as below; /usr/share/fonts inside the sandbox belongs to the runtime. + "/run/host/fonts/opentype/noto", + "/run/host/fonts/google-noto-cjk", + "/run/host/fonts/noto-cjk", + "/run/host/fonts/truetype/noto", + "/run/host/fonts/noto", + "/run/host/fonts/truetype", + "/run/host/fonts/TTF", + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/local-fonts/noto-cjk", + "/run/host/user-fonts", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-cjk", + "/usr/share/fonts/noto-cjk", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts/TTF", + "/usr/share/fonts", + "/usr/local/share/fonts", + "/usr/local/share/fonts/noto-cjk", + "/usr/share/fonts/chromeos/notocjk", +]; + +/// The Windows fonts folder (`%WINDIR%\Fonts`, or the default install location). +#[cfg(not(target_arch = "wasm32"))] +fn windows_fonts() -> String { + std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()) +} + +/// The Noto Sans CJK faces for one locale (`jp`, `sc`, `kr`): the per-locale OTF, then the +/// region-merged TTC at `face`, in every Linux font directory. +#[cfg(not(target_arch = "wasm32"))] +fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> { + let mut v = Vec::new(); + for d in LINUX_CJK_DIRS { + v.push((format!("{d}/NotoSansCJK{locale}-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), face)); + } + v +} + +/// Japanese Han + Kana faces (Japanese kanji glyph shapes). +/// +/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, +/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list +/// comes first loads it; [`load_fallback`] does not load the same file again for the other. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_han_japanese() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic + v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic + v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans + v.extend(noto_cjk("jp", 0)); + v +} + +/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks +/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_han_simplified() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = vec![ + (format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei + (format!("{win}\\simsun.ttc"), 0), // SimSun + (format!("{win}\\simhei.ttf"), 0), // SimHei + (format!("{mac}/Hiragino Sans GB.ttc"), 0), // ships on every recent macOS + (format!("{mac}/PingFang.ttc"), 0), // older macOS only; now a downloadable asset + ]; + v.extend(noto_cjk("sc", 2)); + for d in LINUX_CJK_DIRS { + v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); + v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0)); + v.push((format!("{d}/wqy-microhei.ttc"), 0)); + } + v +} + +/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file +/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_hangul() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic + v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); + v.extend(noto_cjk("kr", 1)); + v +} + +/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai); +/// Linux font directories searched by the Thai and Hebrew faces, in priority order. +#[cfg(not(target_arch = "wasm32"))] +fn linux_noto_dirs() -> [&'static str; 5] { + [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ] +} + +/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the +/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font. +#[cfg(not(target_arch = "wasm32"))] +fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> { + let w = windows_fonts(); + let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect(); + v.extend(mac.iter().map(|p| (p.to_string(), 0))); + v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0))); + v.push((variable.into(), 0)); + v +} + +/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn thai() -> Vec<(String, u32)> { + script_fonts( + // Leelawadee UI, Tahoma, Microsoft Sans Serif + &["LeelawUI.ttf", "tahoma.ttf", "micross.ttf"], + &["/System/Library/Fonts/Supplemental/Thonburi.ttc", "/System/Library/Fonts/SukhumvitSet.ttc"], + &["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"], + // Fedora ships the variable Noto Sans Thai; its default instance is Regular. + "/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf", + ) +} + +/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam +/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn lao() -> Vec<(String, u32)> { + let mac_sup = "/System/Library/Fonts/Supplemental"; + let win = windows_fonts(); + let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let mut v: Vec<(String, u32)> = vec![ + (format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI + (format!("{win}\\dokchamp.ttf"), 0), // DokChampa + (format!("{mac_sup}/Lao Sangam MN.ttf"), 0), + (format!("{mac_sup}/Lao MN.ttc"), 0), + ]; + for d in lin { + v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0)); + v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0)); + v.push((format!("{d}/Phetsarath_OT.ttf"), 0)); + } + v +} + +/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has +/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn hebrew() -> Vec<(String, u32)> { + script_fonts( + // David, FrankRuehl, Tahoma + &["david.ttf", "frank.ttf", "tahoma.ttf"], + &[ + "/System/Library/Fonts/ArialHB.ttc", + "/System/Library/Fonts/SFHebrew.ttf", + "/System/Library/Fonts/Supplemental/Arial Hebrew.ttf", + "/System/Library/Fonts/Supplemental/David.ttf", + "/System/Library/Fonts/LucidaGrande.ttc", + "/System/Library/Fonts/Lucida Grande.ttc", + ], + &["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"], + "/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf", + ) +} + +/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's +/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), +/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover +/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already +/// loaded it. +#[cfg(not(target_arch = "wasm32"))] +fn symbols() -> Vec<(String, u32)> { + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; + for d in [ + "/usr/share/fonts/truetype/dejavu", + "/usr/share/fonts/dejavu-sans-fonts", + "/usr/share/fonts/TTF", + "/usr/share/fonts/dejavu", + "/usr/share/fonts/truetype/ttf-dejavu", + "/usr/local/share/fonts/dejavu", + "/usr/local/share/fonts", + ] { + v.push((format!("{d}/DejaVuSans.ttf"), 0)); + } + v +} + +/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans +/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn symbols_more() -> Vec<(String, u32)> { + let mut v: Vec<(String, u32)> = Vec::new(); + for d in [ + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-sans-symbols-2-fonts", + "/usr/share/fonts/google-noto", + "/usr/local/share/fonts/noto", + "/usr/share/fonts/chromeos/noto", + ] { + v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); + } + v +} + +/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). +pub fn symbol_face_installed() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) + } + #[cfg(target_arch = "wasm32")] + { + false + } +} diff --git a/crates/ui-egui/src/theme/font_setup.rs b/crates/ui-egui/src/theme/font_setup.rs new file mode 100644 index 000000000..749079e71 --- /dev/null +++ b/crates/ui-egui/src/theme/font_setup.rs @@ -0,0 +1,598 @@ +use super::font_fallback::load_fallback; +use super::*; + +/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see +/// and its `docs/integration.md`). The slice is empty +/// when the app is built without the checkout; nothing is downloaded and no font is in this repo. +#[derive(Debug)] +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the family is intended for, from the craft-fonts manifest. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +mod craft_fonts { + use super::CraftFont; + include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +} + +/// The light or dark value of a colour, by mode. +pub(super) fn pick(dark: bool, light: T, dark_value: T) -> T { + if dark { dark_value } else { light } +} + +pub const UI: &str = "ui"; + +pub const UI_BOLD: &str = "ui-bold"; + +pub const CELL: &str = "cell"; + +pub const CELL_BOLD: &str = "cell-bold"; + +pub const CELL_ITALIC: &str = "cell-italic"; + +pub const CELL_BOLD_ITALIC: &str = "cell-bold-italic"; + +pub const SERIF: &str = "serif"; + +pub const MONO: &str = "mono"; + +pub fn ui_font(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(UI.into())) +} + +pub fn ui_bold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(UI_BOLD.into())) +} + +/// 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()); + } + if lower.contains("times") + || lower.contains("georgia") + || lower.contains("cambria") + || lower.contains("serif") && !lower.contains("sans") + || lower.contains("garamond") + { + return FontFamily::Name(SERIF.into()); + } + FontFamily::Name( + match (bold, italic) { + (true, true) => CELL_BOLD_ITALIC, + (true, false) => CELL_BOLD, + (false, true) => CELL_ITALIC, + (false, false) => CELL, + } + .into(), + ) +} + +/// Width in points, at 100% zoom, of one line of cell text drawn as the grid draws it: in the +/// face [`cell_family`] picks, which on a machine without the named font is a wider or narrower +/// substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the +/// next frame) falls back to the engine's estimate rather than letting egui panic on it. +/// +/// `exact` lays the text out (a galley kept until the end of the frame); otherwise the glyph +/// advances are summed without kerning, which is cheap and never narrower. This takes the +/// Context lock (`fonts_mut`): never call it, or run a command, while an egui lock is held. +pub fn cell_text_width(ctx: &egui::Context, text: &str, font: &gridcraft_engine::model::Font, exact: bool) -> f32 { + let family = cell_family(&font.name, font.bold, font.italic); + let id = FontId::new(font.size * 96.0 / 72.0, family); + ctx.fonts_mut(|f| { + f.families().contains(&id.family).then(|| { + if exact { + f.layout_no_wrap(text.to_owned(), id, Color32::PLACEHOLDER).size().x + } else { + // Half a point covers the galley's rounding of its width up to the pixel grid. + text.chars().map(|c| f.glyph_width(&id, c)).sum::() + 0.5 + } + }) + }) + .unwrap_or_else(|| gridcraft_engine::cmd::format::approx_text_width(text, font.size, font.bold)) +} + +/// 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"))] +pub(super) fn try_load(paths: &[(&str, u32)]) -> Option { + for (p, index) in paths { + if let Some(blob) = font_file(p, *index) { + return Some(FontData::from_blob(blob, *index)); + } + } + None +} + +/// The interface and script fallback font files, each read once per run and kept as a shared +/// `Blob` for the rest of it. egui shares the blob instead of copying the file into its text +/// engine, because every one of these files is loaded by every font rebuild for as long as the +/// app runs. Only a file that parses is kept: a missing or broken one is looked for again next +/// time. +#[cfg(not(target_arch = "wasm32"))] +fn font_file(path: &str, index: u32) -> Option { + static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); + let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(blob) = files.get(path) { + let bytes: &[u8] = (**blob).as_ref(); + return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob)); + } + let bytes = std::fs::read(path).ok()?; + if !font_parses(&bytes, index) { + log::warn!("skipping font {path} (face {index}): not a font file egui can read"); + return None; + } + let blob: Blob = std::sync::Arc::new(bytes); + files.insert(path.to_string(), blob.clone()); + Some(blob) +} + +/// 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.bytes(), 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)); + } + } +} + +/// egui's bundled monospace face, used as the last-resort symbol fallback (see `Hack-Regular.ttf` +/// in `epaint_default_fonts`). It is present whenever egui is built with `default_fonts`. +const HACK: &str = "Hack"; + +/// The permitted craft-fonts faces with their `FontDefinitions` keys, in manifest order. Empty +/// without a `CRAFT_FONTS_DIR` checkout at build time. The filter is repeated here so a stale +/// generated table can never smuggle in a barred face or a web-build face outside the allowlist. +pub(super) fn craft_font_faces() -> Vec<(String, &'static CraftFont)> { + let wasm = cfg!(target_arch = "wasm32"); + craft_fonts::CRAFT_FONTS + .iter() + .enumerate() + .filter(|(_, font)| !crate::craft_fonts_policy::barred(font.family)) + .filter(|(_, font)| !wasm || crate::craft_fonts_policy::web_allowed(font.family, font.style)) + .map(|(i, font)| (format!("craft-{i}-{}-{}", font.family, font.style), font)) + .collect() +} + +/// Adds the optional craft-fonts faces to `fonts` and returns their keys. +fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec { + let mut keys = Vec::new(); + for (key, font) in craft_font_faces() { + fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(font.bytes))); + keys.push(key); + } + keys +} + +/// Whether the optional craft-fonts input was embedded in this build. +pub fn craft_fonts_loaded() -> bool { + !craft_fonts::CRAFT_FONTS.is_empty() +} + +/// Whether the native-only Simplified Chinese craft face (LXGW WenKai Lite) was embedded. A web +/// build or an older craft-fonts checkout without `fonts/simplified-chinese-manifest.txt` has none. +pub fn chinese_font_loaded() -> bool { + craft_font_faces().iter().any(|(_, f)| f.family == "LXGW WenKai Lite" && f.style == "Regular") +} + +/// Whether this build was made with `CRAFT_FONTS_REQUIRED=1`. Acceptance tests must fail rather +/// than skip when this is true and the faces are absent. +pub fn craft_fonts_required() -> bool { + craft_fonts::CRAFT_FONTS_REQUIRED +} + +/// A bundled-face character with the same meaning as `c`, for interface chrome whose exact symbol +/// no bundled face has (`✕`, `✓`, `⌃`). Document text still needs a real glyph; this is only a +/// display fallback and never changes the characters a command inserts or stores. +pub fn symbol_fallback(c: char) -> Option { + match c { + '✕' => Some('×'), + '✓' => Some('✔'), + '⌃' => Some('^'), + _ => None, + } +} + +/// `text` with every character the current font set cannot draw replaced by its semantic fallback +/// (see [`symbol_fallback`]). Used by chrome tooltips so a host without symbol faces never paints +/// a tofu box; it is not applied to workbook text. +pub fn available_text(ui: &egui::Ui, text: &str) -> String { + let font = ui_font(13.0); + text.chars().map(|c| if ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) { c } else { symbol_fallback(c).unwrap_or(c) }).collect() +} + +/// The family, style and scripts of every embedded craft-fonts face (empty without the input). +pub fn craft_font_scripts() -> impl Iterator { + craft_font_faces().into_iter().map(|(_, f)| (f.family, f.style, f.scripts)) +} + +/// Whether one embedded craft-fonts face maps every non-whitespace character in `text`. Tests use +/// this to tell "the pinned fixture covers this script" from "this host has no face for it", +/// without trusting a manifest script tag alone. +pub fn craft_fonts_cover(text: &str) -> bool { + craft_font_faces().iter().any(|(_, f)| { + use skrifa::MetadataProvider; + let Ok(font) = skrifa::FontRef::from_index(f.bytes, 0) else { + return false; + }; + let map = font.charmap(); + text.chars().filter(|c| !c.is_whitespace()).all(|c| map.map(c).is_some()) + }) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn try_load(_paths: &[(&str, u32)]) -> Option { + None +} + +fn home_fonts(file: &str) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + format!("{home}/Library/Fonts/{file}") +} + +/// Every file in `files` under every directory in `dirs`, directory first, in the same order a +/// nested loop over the directories would add them. +pub(super) fn in_dirs(dirs: &[&str], files: &[&str]) -> Vec { + dirs.iter().flat_map(|d| files.iter().map(move |f| format!("{d}/{f}"))).collect() +} + +/// Candidate font files per role, in preference order. +pub(super) fn candidates(role: &str) -> Vec<(String, u32)> { + let mac_sys = "/System/Library/Fonts"; + let mac_sup = "/System/Library/Fonts/Supplemental"; + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). + let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; + let mut v: Vec<(String, u32)> = Vec::new(); + let mut add = |s: String, i: u32| v.push((s, i)); + match role { + "ui" => { + add(format!("{mac_sys}/SFNS.ttf"), 0); + add(format!("{mac_sys}/HelveticaNeue.ttc"), 0); + add(format!("{win}\\segoeui.ttf"), 0); + for p in in_dirs( + &lin, + &["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"], + ) { + add(p, 0); + } + // Fedora keeps each family in its own package folder (#54). + add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); + add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); + } + "ui-bold" => { + add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); + add(format!("{win}\\segoeuib.ttf"), 0); + for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); + } + "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { + let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { + "cell" => { + ("Carlito-Regular.ttf", "calibri.ttf", "Calibri.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf") + } + "cell-bold" => ( + "Carlito-Bold.ttf", + "calibrib.ttf", + "Calibri Bold.ttf", + "Arial Bold.ttf", + "arialbd.ttf", + "LiberationSans-Bold.ttf", + "DejaVuSans-Bold.ttf", + ), + "cell-italic" => ( + "Carlito-Italic.ttf", + "calibrii.ttf", + "Calibri Italic.ttf", + "Arial Italic.ttf", + "ariali.ttf", + "LiberationSans-Italic.ttf", + "DejaVuSans-Oblique.ttf", + ), + _ => ( + "Carlito-BoldItalic.ttf", + "calibriz.ttf", + "Calibri Bold Italic.ttf", + "Arial Bold Italic.ttf", + "arialbi.ttf", + "LiberationSans-BoldItalic.ttf", + "DejaVuSans-BoldOblique.ttf", + ), + }; + // Calibri if the user has it installed system-wide (never from an Office bundle). + add(format!("/Library/Fonts/{calibri_mac}"), 0); + add(home_fonts(calibri_mac), 0); + add(format!("{win}\\{calibri_win}"), 0); + for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) { + add(p, 0); + } + // Fedora keeps each family in its own package folder, as for the UI fonts (#54). + add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0); + add(home_fonts(carlito), 0); + add(format!("{mac_sup}/{arial_mac}"), 0); + add(format!("{win}\\{arial_win}"), 0); + for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) { + add(p, 0); + } + add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0); + add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0); + } + "serif" => { + add(format!("{mac_sup}/Times New Roman.ttf"), 0); + add(format!("{win}\\times.ttf"), 0); + for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0); + } + "mono" => { + add(format!("{mac_sys}/SFNSMono.ttf"), 0); + add(format!("{mac_sys}/Menlo.ttc"), 0); + add(format!("{win}\\consola.ttf"), 0); + for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0); + // Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice + // (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always. + for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0); + add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0); + } + _ => {} + } + v +} + +/// Builds the font set from system fonts (with egui's defaults as fallback), with the default +/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable. +pub fn font_definitions() -> FontDefinitions { + font_definitions_for_language(crate::i18n::Language::En) +} + +/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only +/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two +/// languages with the same order needs no font rebuild. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HanOrder { + /// Japanese glyph shapes first (the interface is Japanese). + Japanese, + /// Simplified Chinese first: the widest Han coverage, for every other interface language. + Chinese, +} + +impl HanOrder { + pub fn of(language: crate::i18n::Language) -> Self { + if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese } + } +} + +/// 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, &[]) +} + +/// The bundled faces plus the pinned craft-fonts faces, with no system-font probing or installed +/// workbook-font binding. +/// +/// Integration tests use this through their shared harness, so a minimal CI image and a developer +/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository +/// asset; without it only egui's bundled faces are available, which is the documented no-font mode. +pub fn pinned_font_definitions() -> FontDefinitions { + font_definitions_impl(crate::i18n::Language::En, &[], false) +} + +/// [`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 { + font_definitions_impl(language, used, true) +} + +/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound. +fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions { + let mut fonts = FontDefinitions::default(); + let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); + let mut bound: Vec = 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 = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; + let hack = fonts.font_data.contains_key(HACK).then_some(HACK); + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); + 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. + if system { + for family in crate::fonts::installed_among(used) { + bound.extend(crate::fonts::bind_family(&mut fonts, &family)); + } + } + // Interface text (formula bar, name box, dialogs) falls back to the cell font before the + // script faces: whatever a cell can draw, the formula bar can draw too, even where no script + // fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it). + bind_cell_fallback(&mut fonts, &fallback); + // 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); + } + // The optional craft-fonts faces go at the very end of every family: a system or bundled face + // that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and + // symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols). + // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still + // leads, and the barred Noto CJK collection never enters. + append_craft_faces(&mut fonts); + 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 +} + +/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled +/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data +/// are dropped, as before. +fn build_role_chain( + fonts: &mut FontDefinitions, + role: &str, + base_prop: &[String], + base_mono: &[String], + fallback: &[String], + hack: Option<&str>, + system: bool, +) -> Vec { + let paths = candidates(role); + let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); + let mut chain: Vec = Vec::new(); + let loaded = if system { try_load(&refs) } else { None }; + if let Some(mut fd) = loaded { + // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina + // screen (#1184). Core Text never runs them, so this draws it the way macOS does. + if cfg!(target_os = "macos") && role == UI_BOLD { + fd.tweak.hinting = Some(false); + } + let key = format!("sys-{role}"); + fonts.font_data.insert(key.clone(), Arc::new(fd)); + chain.push(key); + } else if role == UI_BOLD { + chain.push(format!("sys-{UI}")); + } else if role.starts_with("cell-") { + chain.push(format!("sys-{CELL}")); + } + chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); + // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but + // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as + // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it + // only catches characters those do not have. + if let Some(hack) = hack + && !chain.iter().any(|k| k.as_str() == hack) + { + chain.push(hack.to_string()); + } + chain.extend(fallback.iter().cloned()); + chain.retain(|k| fonts.font_data.contains_key(k)); + chain +} + +/// Inserts the loaded cell face before the script fallbacks of the interface families, so the +/// formula bar and dialogs can draw whatever a cell can (#349). +fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) { + let cell_key = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell_key) { + return; + } + for role in [UI, UI_BOLD, SERIF, MONO] { + if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) + && !chain.contains(&cell_key) + { + let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); + chain.insert(at, cell_key.clone()); + } + } +} + +/// Appends the permitted craft-fonts faces to every family, preserving the leading system or +/// bundled face's glyph shapes. +fn append_craft_faces(fonts: &mut FontDefinitions) { + let craft = add_craft_fonts(fonts); + if craft.is_empty() { + return; + } + for chain in fonts.families.values_mut() { + for key in &craft { + if !chain.contains(key) { + chain.push(key.clone()); + } + } + } +} + +#[cfg(test)] +mod font_binding_tests { + use super::*; + + /// The families [`font_definitions`] always binds, whatever is installed. + fn always_bound() -> Vec { + [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:?}"); + } + } +}