mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 12:16:54 -06:00
refactor(ui-egui): move theme.rs items to theme/
This commit is contained in:
+15
-878
@@ -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
|
||||
/// <https://github.com/storytold/craft-fonts> 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<T>(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::<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(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<Blob> {
|
||||
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, Blob>> = 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<String> = 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<String> {
|
||||
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<char> {
|
||||
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<Item = (&'static str, &'static str, &'static [&'static str])> {
|
||||
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<FontData> {
|
||||
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<String> {
|
||||
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<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 = 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<String> {
|
||||
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();
|
||||
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<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",
|
||||
];
|
||||
|
||||
/// 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<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(test)]
|
||||
mod contrast_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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<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")]
|
||||
pub(super) 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"))]
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// <https://github.com/storytold/craft-fonts> 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<T>(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::<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"))]
|
||||
pub(super) fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
|
||||
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<Blob> {
|
||||
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, Blob>> = 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<String> = 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<String> {
|
||||
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<char> {
|
||||
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<Item = (&'static str, &'static str, &'static [&'static str])> {
|
||||
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<FontData> {
|
||||
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<String> {
|
||||
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<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 = 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<String> {
|
||||
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();
|
||||
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<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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user