Files
gridcraft/crates/ui-egui/src/theme.rs
T
aiongg ca1cdd4229 Integration: track t13
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	crates/engine/src/cmd/data.rs
#	crates/xlsx/src/tests/mod.rs
#	docs/chart-parity.md
#	docs/file-format-parity.md
#	docs/function-parity.md
#	docs/gaps.md
#	docs/localization-parity.md
#	docs/target-app-parity.md
2026-10-22 03:05:18 +00:00

1176 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Design tokens and fonts.
//!
//! Colours are our own, tuned to read like a modern desktop spreadsheet. Fonts come from the
//! operating system at runtime (nothing bundled): the UI uses the platform UI font, cells use the
//! best available match for the workbook font (Calibri/Carlito/Aptos/Arial…) with egui's built-in
//! fonts as the last resort.
use std::sync::Arc;
use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tokens {
pub dark: bool,
pub window: Color32,
pub ribbon: Color32,
pub ribbon_border: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_disabled: Color32,
pub accent: Color32,
pub accent_dark: Color32,
pub accent_soft: Color32,
pub hover: Color32,
pub pressed: Color32,
pub separator: Color32,
pub grid_bg: Color32,
/// Behind the pages in Page Layout view.
pub desk: Color32,
pub gridline: Color32,
pub header_bg: Color32,
pub header_text: Color32,
pub header_line: Color32,
pub header_sel_bg: Color32,
pub header_sel_text: Color32,
pub header_all_bg: Color32,
pub sel_fill: Color32,
pub sel_border: Color32,
pub input_bg: Color32,
pub input_border: Color32,
pub tab_bar: Color32,
pub tab_active: Color32,
pub status_bar: Color32,
pub cell_text: Color32,
pub menu_bg: Color32,
pub shadow: Color32,
pub danger: Color32,
/// Background error checking: the corner triangle, and the warning button's diamond and mark.
pub error_check_green: Color32,
pub warning_fill: Color32,
pub warning_ink: Color32,
/// Info-bar (notice strip) fills, by severity.
pub notice_warn_bg: Color32,
pub notice_warn_border: Color32,
pub notice_info_bg: Color32,
pub notice_info_border: Color32,
}
impl Tokens {
pub fn light() -> Tokens {
Tokens {
dark: false,
window: Color32::from_rgb(0xF3, 0xF3, 0xF3),
ribbon: Color32::from_rgb(0xFF, 0xFF, 0xFF),
ribbon_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
text: Color32::from_rgb(0x24, 0x24, 0x24),
text_dim: Color32::from_rgb(0x61, 0x61, 0x61),
text_disabled: Color32::from_rgb(0xB0, 0xB0, 0xB0),
accent: Color32::from_rgb(0x10, 0x7C, 0x41),
accent_dark: Color32::from_rgb(0x0B, 0x5E, 0x31),
accent_soft: Color32::from_rgb(0xDF, 0xF1, 0xE6),
hover: Color32::from_rgb(0xEB, 0xEB, 0xEB),
pressed: Color32::from_rgb(0xDD, 0xDD, 0xDD),
separator: Color32::from_rgb(0xE0, 0xE0, 0xE0),
grid_bg: Color32::WHITE,
desk: Color32::from_rgb(0xDE, 0xDE, 0xDE),
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_bg: Color32::from_rgb(0xF8, 0xF8, 0xF8),
header_text: Color32::from_rgb(0x42, 0x42, 0x42),
header_line: Color32::from_rgb(0xD6, 0xD6, 0xD6),
header_sel_bg: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_sel_text: Color32::from_rgb(0x10, 0x7C, 0x41),
header_all_bg: Color32::from_rgb(0xC8, 0xE6, 0xD3),
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C),
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
input_bg: Color32::WHITE,
input_border: Color32::from_rgb(0xD1, 0xD1, 0xD1),
tab_bar: Color32::from_rgb(0xF3, 0xF3, 0xF3),
tab_active: Color32::WHITE,
status_bar: Color32::from_rgb(0xF3, 0xF3, 0xF3),
cell_text: Color32::BLACK,
menu_bg: Color32::WHITE,
shadow: Color32::from_black_alpha(40),
danger: Color32::from_rgb(0xC4, 0x2B, 0x1C),
error_check_green: Color32::from_rgb(0x1E, 0x8E, 0x3E),
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
warning_ink: Color32::BLACK,
notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE),
notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E),
notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB),
notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC),
}
}
pub fn dark() -> Tokens {
Tokens {
dark: true,
window: Color32::from_rgb(0x29, 0x29, 0x29),
ribbon: Color32::from_rgb(0x33, 0x33, 0x33),
ribbon_border: Color32::from_rgb(0x40, 0x40, 0x40),
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0),
text_disabled: Color32::from_rgb(0x70, 0x70, 0x70),
accent: Color32::from_rgb(0x5C, 0xC5, 0x87),
accent_dark: Color32::from_rgb(0x3F, 0xA8, 0x6C),
accent_soft: Color32::from_rgb(0x23, 0x45, 0x31),
hover: Color32::from_rgb(0x42, 0x42, 0x42),
pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D),
separator: Color32::from_rgb(0x48, 0x48, 0x48),
grid_bg: Color32::WHITE,
desk: Color32::from_rgb(0x3A, 0x3A, 0x3A),
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_bg: Color32::from_rgb(0x30, 0x30, 0x30),
header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC),
header_line: Color32::from_rgb(0x45, 0x45, 0x45),
header_sel_bg: Color32::from_rgb(0x45, 0x45, 0x45),
header_sel_text: Color32::from_rgb(0x5C, 0xC5, 0x87),
header_all_bg: Color32::from_rgb(0x2E, 0x55, 0x3E),
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24),
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
input_bg: Color32::from_rgb(0x1F, 0x1F, 0x1F),
input_border: Color32::from_rgb(0x50, 0x50, 0x50),
tab_bar: Color32::from_rgb(0x29, 0x29, 0x29),
tab_active: Color32::from_rgb(0x3A, 0x3A, 0x3A),
status_bar: Color32::from_rgb(0x29, 0x29, 0x29),
cell_text: Color32::BLACK,
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
shadow: Color32::from_black_alpha(90),
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B),
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
warning_ink: Color32::BLACK,
notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E),
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
}
}
pub fn get(ctx: &egui::Context) -> Tokens {
if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::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::<f32>() + 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<FontData> {
for (p, index) in paths {
if let Some(bytes) = font_file(p, *index) {
let mut fd = FontData::from_static(bytes);
fd.index = *index;
return Some(fd);
}
}
None
}
/// The interface and script fallback font files, each read once per run and kept for the rest of
/// it. egui copies an owned font's bytes into its text engine and keeps the original too
/// (`epaint`'s `blob_from_font_data`), so every one of these files was in memory twice, about
/// 100 MB on a Mac; bytes it borrows for `'static` it shares instead. These faces are loaded by
/// every font rebuild for as long as the app runs, so keeping them costs nothing, and rebuilds no
/// longer read them from disk again. 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 [u8]> {
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, &'static [u8]>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(&bytes) = files.get(path) {
return font_parses(bytes, index).then_some(bytes);
}
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 bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice());
files.insert(path.to_string(), bytes);
Some(bytes)
}
/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not
/// parse (and its key from every family), so a bad font costs a fallback, never a crash.
pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) {
let bad: Vec<String> = fonts.font_data.iter().filter(|(_, fd)| !font_parses(&fd.font, fd.index)).map(|(k, _)| k.clone()).collect();
for key in &bad {
log::warn!("dropping font {key:?}: egui can't parse it");
fonts.font_data.remove(key);
}
if !bad.is_empty() {
for chain in fonts.families.values_mut() {
chain.retain(|k| !bad.contains(k));
}
}
}
#[cfg(target_arch = "wasm32")]
fn try_load(_paths: &[(&str, u32)]) -> Option<FontData> {
None
}
fn home_fonts(file: &str) -> String {
let home = std::env::var("HOME").unwrap_or_default();
format!("{home}/Library/Fonts/{file}")
}
/// 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 d in lin {
add(format!("{d}/cantarell/Cantarell-Regular.otf"), 0);
add(format!("{d}/noto/NotoSans-Regular.ttf"), 0);
add(format!("{d}/dejavu/DejaVuSans.ttf"), 0);
add(format!("{d}/liberation/LiberationSans-Regular.ttf"), 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 d in lin {
add(format!("{d}/noto/NotoSans-Bold.ttf"), 0);
add(format!("{d}/dejavu/DejaVuSans-Bold.ttf"), 0);
add(format!("{d}/liberation/LiberationSans-Bold.ttf"), 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 d in lin {
add(format!("{d}/crosextra/{carlito}"), 0);
add(format!("{d}/carlito/{carlito}"), 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 d in lin {
add(format!("{d}/liberation/{lib}"), 0);
add(format!("{d}/dejavu/{dejavu}"), 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 d in lin {
add(format!("{d}/liberation/LiberationSerif-Regular.ttf"), 0);
add(format!("{d}/dejavu/DejaVuSerif.ttf"), 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 d in lin {
add(format!("{d}/dejavu/DejaVuSansMono.ttf"), 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 d in lin {
add(format!("{d}/liberation/LiberationMono-Regular.ttf"), 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, &[])
}
/// [`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));
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
let paths = candidates(role);
let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect();
let mut chain: Vec<String> = Vec::new();
if let Some(mut fd) = try_load(&refs) {
// 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.clone() } else { base_prop.clone() });
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));
}
// 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).
let cell_key = format!("sys-{CELL}");
if fonts.font_data.contains_key(&cell_key) {
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());
}
}
}
// 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
}
/// 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<String> {
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<String> = 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<String> {
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",
];
/// 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 = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
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
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); // JP face
}
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 = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei
v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun
v.push((format!("{win}\\simhei.ttf"), 0)); // SimHei
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS
v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 2)); // SC face
}
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 = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic
v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0));
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 1)); // KR face
}
v
}
/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai);
/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda.
#[cfg(not(target_arch = "wasm32"))]
fn thai() -> 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());
let lin = [
"/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)
];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
v.push((format!("{win}\\micross.ttf"), 0)); // Microsoft Sans Serif
v.push((format!("{mac_sup}/Thonburi.ttc"), 0));
v.push((format!("{mac_sys}/SukhumvitSet.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansThai-Regular.ttf"), 0));
v.push((format!("{d}/tlwg/Garuda.ttf"), 0));
v.push((format!("{d}/garuda/Garuda.ttf"), 0));
}
// Fedora ships the variable Noto Sans Thai; its default instance is Regular.
v.push(("/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf".into(), 0));
v
}
/// 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 = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
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::new();
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
v.push((format!("{win}\\dokchamp.ttf"), 0)); // DokChampa
v.push((format!("{mac_sup}/Lao Sangam MN.ttf"), 0));
v.push((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)> {
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());
let lin = [
"/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)
];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\david.ttf"), 0)); // David
v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
v.push((format!("{mac_sys}/ArialHB.ttc"), 0)); // Arial Hebrew
v.push((format!("{mac_sys}/SFHebrew.ttf"), 0));
v.push((format!("{mac_sup}/Arial Hebrew.ttf"), 0));
v.push((format!("{mac_sup}/David.ttf"), 0));
v.push((format!("{mac_sys}/LucidaGrande.ttc"), 0));
v.push((format!("{mac_sys}/Lucida Grande.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansHebrew-Regular.ttf"), 0));
v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0));
v.push((format!("{d}/freefont/FreeSans.ttf"), 0));
}
v.push(("/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf".into(), 0));
v
}
/// 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
}
}
pub fn apply(ctx: &egui::Context, dark: bool) {
// Install both palettes before System can switch between the style slots.
for theme in [egui::Theme::Light, egui::Theme::Dark] {
let dark = theme == egui::Theme::Dark;
let t = if dark { Tokens::dark() } else { Tokens::light() };
let mut v = if dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.window;
v.window_fill = t.menu_bg;
v.extreme_bg_color = t.input_bg;
v.selection.bg_fill = t.accent_soft;
v.selection.stroke = Stroke::new(1.0, t.accent);
v.hyperlink_color = t.accent;
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text);
v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text);
v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
v.widgets.hovered.weak_bg_fill = t.hover;
v.widgets.active.weak_bg_fill = t.pressed;
v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border);
v.window_corner_radius = egui::CornerRadius::same(10);
v.menu_corner_radius = egui::CornerRadius::same(8);
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow };
ctx.set_visuals_of(theme, v);
}
ctx.options_mut(|o| o.fallback_theme = egui::Theme::Light);
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
ctx.all_styles_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 3.0);
s.spacing.interact_size.y = 22.0;
s.text_styles.insert(egui::TextStyle::Body, ui_font(13.0));
s.text_styles.insert(egui::TextStyle::Button, ui_font(13.0));
s.text_styles.insert(egui::TextStyle::Small, ui_font(11.0));
s.text_styles.insert(egui::TextStyle::Heading, ui_bold(18.0));
s.text_styles.insert(egui::TextStyle::Monospace, FontId::new(12.0, FontFamily::Name(MONO.into())));
});
}
/// Model colour → egui colour.
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::*;
use crate::i18n::Language;
#[test]
fn fallback_faces_are_appended_to_every_family() {
// Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain
// (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order.
for han in [HanOrder::Chinese, HanOrder::Japanese] {
let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh };
let fonts = font_definitions_for_language(language);
let fb_keys: Vec<String> =
fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect();
if fb_keys.is_empty() {
return; // no fallback face on this machine (minimal Linux)
}
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default();
assert!(chain.len() >= fb_keys.len(), "{role} chain too short for its fallback");
let tail = &chain[chain.len() - fb_keys.len()..];
assert_eq!(tail, fb_keys.as_slice(), "{role} must end its chain with the fallback faces");
}
}
}
#[test]
fn interface_families_fall_back_to_the_cell_font() {
// Structural (#349): the formula bar, name box and dialogs draw every script the cells can,
// so each interface chain carries the cell face, ahead of the script fallback faces.
let fonts = font_definitions_for_language(Language::En);
let cell = format!("sys-{CELL}");
if !fonts.font_data.contains_key(&cell) {
return; // no system cell font on this machine
}
let fb: Vec<String> = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect();
for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] {
let chain = fonts.families.get(&family).cloned().unwrap_or_default();
let at = chain.iter().position(|k| *k == cell);
assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}");
let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len());
assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces");
}
}
#[test]
fn language_reorders_only_the_han_faces() {
let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
assert_eq!(
keys(HanOrder::Japanese),
["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
);
assert_eq!(
keys(HanOrder::Chinese),
["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
);
assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese);
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] {
assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)");
}
}
#[test]
fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() {
// Exercise the candidates the fallback loader actually consumes, not just the directory
// constant. This must work on CI without an installed CJK font or a Flatpak runtime.
for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] {
for directory in [
"/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",
] {
let path = format!("{directory}/NotoSansCJK-Regular.ttc");
assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}");
}
}
let chinese = cjk_han_simplified();
assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0)));
assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0)));
}
#[test]
fn fallback_faces_load_each_file_once() {
// The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded
// once, not once per script (tens of MB each).
for language in [Language::En, Language::Ja] {
let fonts = font_definitions_for_language(language);
let present: Vec<&Arc<FontData>> =
fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect();
for (i, a) in present.iter().enumerate() {
for b in present.iter().skip(i + 1) {
assert!(a.font != b.font, "the same font file is loaded for two fallback scripts");
}
}
}
}
#[test]
fn fallback_covers_its_script() {
// Real render check, per script: the proportional family (what cells and the UI use) must
// resolve a sample string to a real glyph, not the tofu replacement char. Skips a script
// whose face is absent (a minimal machine) and fails if a face is present but not wired in.
let cases: [(&str, Vec<(String, u32)>, &str); 8] = [
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"),
("sys-cjk-hangul", cjk_hangul(), "한글"),
("sys-thai", thai(), "ทดสอบ"),
("sys-lao", lao(), "ພາສາລາວ"),
("sys-hebrew", hebrew(), "עברית"),
("ui", candidates(UI), "Русский"),
// Czech interface labels: every Czech accented letter, in both cases.
("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"),
];
for language in [Language::En, Language::Ja] {
let ctx = egui::Context::default();
ctx.set_fonts(font_definitions_for_language(language));
let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {});
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
// `has_glyphs` is a false negative when the face that owns the script is also the face
// that draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare
// the laid out glyphs with the tofu glyph itself instead.
for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] {
let font = FontId::new(14.0, family);
let glyphs = |text: &str| {
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK));
galley.rows.iter().flat_map(|r| r.glyphs.iter().filter(|g| g.chr != ' ').map(|g| g.uv_rect)).collect::<Vec<_>>()
};
let tofu = glyphs("\u{25FB}");
for (key, cands, sample) in &cases {
if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) {
continue; // no face for this script on this machine; nothing to assert
}
let drawn = glyphs(sample);
assert!(
!drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)),
"{key} is present but {sample:?} still renders as tofu ({language:?}, {:?})",
font.family
);
}
}
}
}
#[test]
fn chromeos_font_layout_is_probed() {
// #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per
// family set; a script whose list misses it renders as tofu with the font installed.
let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path);
let cros = "/usr/share/fonts/chromeos";
assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf")));
assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf")));
assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf")));
assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf")));
assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf")));
}
#[test]
fn unparseable_font_files_are_rejected_not_fatal() {
// #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of
// bounds"). Garbage, a truncated real font and a bad collection index must all be refused.
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
assert!(font_parses(&real, 0), "egui's own font must parse");
assert!(!font_parses(b"", 0));
assert!(!font_parses(&[0xAB; 64], 0));
assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused");
assert!(!font_parses(&real, 3), "a single-face file has no face 3");
// A bad face that slipped into the definitions is dropped (key and family entries), and the
// context builds its fonts without panicking.
let mut fonts = font_definitions();
fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0])));
for chain in fonts.families.values_mut() {
chain.push("sys-hebrew".into());
}
drop_unparseable_fonts(&mut fonts);
assert!(!fonts.font_data.contains_key("sys-hebrew"));
assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew")));
let ctx = egui::Context::default();
ctx.set_fonts(fonts);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label("Hebrew עברית");
});
out.textures_delta.clear(); // nothing consumes the atlas in a headless test
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn try_load_skips_a_broken_candidate() {
// A candidate that exists but is not a readable font must not win over a good one behind it.
let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let bad = dir.join("broken.ttf");
let good = dir.join("good.ttf");
let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default();
let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default());
let _ = std::fs::write(&good, &real);
let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned());
assert!(try_load(&[(bad_s.as_str(), 0)]).is_none());
let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]);
assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len()));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn interface_fonts_are_read_once_and_shared_with_egui() {
// egui copies owned font bytes into its text engine and keeps the original too; borrowed
// bytes it shares. A rebuild of the font set reuses the file read the first time.
let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id()));
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap();
let p = path.to_string_lossy().into_owned();
let first = try_load(&[(p.as_str(), 0)]).unwrap();
std::fs::remove_file(&path).unwrap();
let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read");
let (std::borrow::Cow::Borrowed(a), std::borrow::Cow::Borrowed(b)) = (&first.font, &second.font) else {
panic!("interface font bytes must be borrowed, so egui shares them instead of copying");
};
assert!(std::ptr::eq(*a, *b));
assert_eq!(*a, epaint_default_fonts::UBUNTU_LIGHT);
assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused");
}
#[test]
fn macos_bold_interface_face_is_drawn_unhinted() {
// #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a
// Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every
// other app draws it; the regular face and the other platforms keep egui's default.
let fonts = font_definitions_for_language(Language::En);
let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting);
if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) {
assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None });
}
if let Some(regular) = hinting(&format!("sys-{UI}")) {
assert_eq!(regular, None, "only the bold face is affected");
}
}
#[test]
fn fedora_package_folders_are_searched_after_the_other_linux_layouts() {
// #1694: Fedora keeps each font family in its own package folder, so without these entries
// no cell face is found there and bold and italic cells draw as regular text. Each comes
// after the existing Linux folders for the same font, so wherever one of those matched,
// nothing changes.
let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::<Vec<_>>();
let cases: [(&str, Vec<String>, &str, &str); 9] = [
(CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"),
(CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"),
(CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"),
(CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"),
(CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"),
(SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"),
(MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"),
("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"),
("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"),
];
for (role, list, fedora_file, earlier) in &cases {
let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}"));
let last_earlier = list.iter().rposition(|p| p.ends_with(earlier));
assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate");
assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with");
assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}");
}
// Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed.
let mono = paths(candidates(MONO));
let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf"));
let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf"));
assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}");
let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf");
assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}");
}
}