use std::sync::Arc;
use egui::{FontData, FontDefinitions, FontFamily};
pub const MAX_FONT_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FontError {
#[error("font `{0}` is not a readable TrueType/OpenType face")]
Unreadable(String),
#[error("font `{0}` is larger than {MAX_FONT_BYTES} bytes")]
TooLarge(String),
#[error("font `{0}` was not added")]
Unknown(String),
#[error("font name must not be empty")]
EmptyName,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CjkScript {
Japanese,
SimplifiedChinese,
TraditionalChinese,
Korean,
}
impl CjkScript {
pub fn for_language(tag: &str) -> Option<Self> {
let lower = tag.to_ascii_lowercase();
let mut parts = lower.split(['-', '_']);
match parts.next()? {
"ja" => Some(CjkScript::Japanese),
"ko" => Some(CjkScript::Korean),
"zh" => {
let rest: Vec<&str> = parts.collect();
let traditional = rest.iter().any(|p| matches!(*p, "hant" | "tw" | "hk" | "mo"));
Some(if traditional { CjkScript::TraditionalChinese } else { CjkScript::SimplifiedChinese })
}
_ => None,
}
}
}
pub enum FontBytes {
Static(&'static [u8]),
Owned(Vec<u8>),
}
impl From<&'static [u8]> for FontBytes {
fn from(b: &'static [u8]) -> Self {
FontBytes::Static(b)
}
}
impl From<Vec<u8>> for FontBytes {
fn from(b: Vec<u8>) -> Self {
FontBytes::Owned(b)
}
}
impl FontBytes {
fn as_slice(&self) -> &[u8] {
match self {
FontBytes::Static(b) => b,
FontBytes::Owned(b) => b,
}
}
}
pub struct FontInstall {
defs: FontDefinitions,
fallbacks: Vec<String>,
cjk: Vec<(CjkScript, String)>,
preferred: Option<CjkScript>,
}
impl Default for FontInstall {
fn default() -> Self {
Self::new()
}
}
impl FontInstall {
pub fn new() -> Self {
Self::from_definitions(FontDefinitions::default())
}
pub fn empty() -> Self {
Self::from_definitions(FontDefinitions::empty())
}
pub fn from_definitions(defs: FontDefinitions) -> Self {
Self { defs, fallbacks: Vec::new(), cjk: Vec::new(), preferred: None }
}
pub fn add(&mut self, name: &str, bytes: impl Into<FontBytes>) -> Result<&mut Self, FontError> {
self.add_indexed(name, bytes, 0)
}
pub fn add_indexed(&mut self, name: &str, bytes: impl Into<FontBytes>, index: u32) -> Result<&mut Self, FontError> {
if name.is_empty() {
return Err(FontError::EmptyName);
}
let bytes = bytes.into();
if bytes.as_slice().len() > MAX_FONT_BYTES {
return Err(FontError::TooLarge(name.to_owned()));
}
if skrifa::FontRef::from_index(bytes.as_slice(), index).is_err() {
return Err(FontError::Unreadable(name.to_owned()));
}
let mut data = match bytes {
FontBytes::Static(b) => FontData::from_static(b),
FontBytes::Owned(b) => FontData::from_owned(b),
};
data.index = index;
self.defs.font_data.insert(name.to_owned(), Arc::new(data));
Ok(self)
}
fn known(&self, name: &str) -> Result<(), FontError> {
if self.defs.font_data.contains_key(name) { Ok(()) } else { Err(FontError::Unknown(name.to_owned())) }
}
pub fn primary(&mut self, family: FontFamily, name: &str) -> Result<&mut Self, FontError> {
self.known(name)?;
let stack = self.defs.families.entry(family).or_default();
stack.retain(|n| n != name);
stack.insert(0, name.to_owned());
Ok(self)
}
pub fn family(&mut self, family: FontFamily, names: &[&str]) -> Result<&mut Self, FontError> {
for name in names {
self.known(name)?;
}
self.defs.families.insert(family, names.iter().map(|n| (*n).to_owned()).collect());
Ok(self)
}
pub fn fallback(&mut self, name: &str, bytes: impl Into<FontBytes>) -> Result<&mut Self, FontError> {
self.add(name, bytes)?;
self.fallbacks.retain(|n| n != name);
self.fallbacks.push(name.to_owned());
Ok(self)
}
pub fn add_cjk(&mut self, script: CjkScript, name: &str, bytes: impl Into<FontBytes>) -> Result<&mut Self, FontError> {
self.add(name, bytes)?;
self.cjk.retain(|(_, n)| n != name);
self.cjk.push((script, name.to_owned()));
Ok(self)
}
pub fn prefer(&mut self, script: CjkScript) -> &mut Self {
self.preferred = Some(script);
self
}
pub fn fallback_chain(&self) -> Vec<&str> {
let mut cjk: Vec<&(CjkScript, String)> = self.cjk.iter().collect();
if let Some(p) = self.preferred {
cjk.sort_by_key(|(s, _)| *s != p);
}
self.fallbacks.iter().map(String::as_str).chain(cjk.into_iter().map(|(_, n)| n.as_str())).collect()
}
pub fn definitions(&self) -> FontDefinitions {
let mut defs = self.defs.clone();
let chain = self.fallback_chain();
for stack in defs.families.values_mut() {
for name in &chain {
if !stack.iter().any(|n| n == name) {
stack.push((*name).to_owned());
}
}
stack.retain(|n| defs.font_data.contains_key(n));
}
for family in [FontFamily::Proportional, FontFamily::Monospace] {
defs.families.entry(family).or_insert_with(|| chain.iter().map(|n| (*n).to_owned()).collect());
}
defs
}
pub fn install(&self, ctx: &egui::Context) {
ctx.set_fonts(self.definitions());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bundled(name: &str) -> Vec<u8> {
let defs = FontDefinitions::default();
defs.font_data.get(name).map(|d| d.font.to_vec()).unwrap_or_default()
}
fn any_bundled() -> (String, Vec<u8>) {
let defs = FontDefinitions::default();
let (name, data) = defs.font_data.iter().next().map(|(n, d)| (n.clone(), d.font.to_vec())).unwrap_or_default();
(name, data)
}
#[test]
fn corrupt_or_unknown_faces_are_errors_not_panics() {
let mut f = FontInstall::empty();
assert_eq!(f.add("bad", vec![0u8; 64]).err(), Some(FontError::Unreadable("bad".into())));
assert_eq!(f.add("", vec![]).err(), Some(FontError::EmptyName));
assert_eq!(f.primary(FontFamily::Proportional, "missing").err(), Some(FontError::Unknown("missing".into())));
assert_eq!(f.family(FontFamily::Name("x".into()), &["missing"]).err(), Some(FontError::Unknown("missing".into())));
let ctx = egui::Context::default();
f.install(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label("still works");
});
out.textures_delta.clear();
}
#[test]
fn faces_install_and_cjk_chain_puts_the_preferred_script_first() {
let (_, face) = any_bundled();
assert!(!face.is_empty(), "egui default_fonts is enabled for tests");
let mut f = FontInstall::new();
f.add("ui", face.clone()).unwrap().primary(FontFamily::Proportional, "ui").unwrap();
f.add_cjk(CjkScript::SimplifiedChinese, "sc", face.clone()).unwrap();
f.add_cjk(CjkScript::Japanese, "jp", face.clone()).unwrap();
f.fallback("emoji", face.clone()).unwrap();
assert_eq!(f.fallback_chain(), ["emoji", "sc", "jp"]);
f.prefer(CjkScript::Japanese);
assert_eq!(f.fallback_chain(), ["emoji", "jp", "sc"]);
f.family(FontFamily::Name("semibold".into()), &["ui"]).unwrap();
let defs = f.definitions();
let prop = &defs.families[&FontFamily::Proportional];
assert_eq!(prop.first().map(String::as_str), Some("ui"));
assert!(prop.ends_with(&["emoji".to_owned(), "jp".to_owned(), "sc".to_owned()]), "{prop:?}");
assert_eq!(defs.families[&FontFamily::Name("semibold".into())], ["ui", "emoji", "jp", "sc"]);
let ctx = egui::Context::default();
f.install(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label(egui::RichText::new("semibold").family(FontFamily::Name("semibold".into())));
});
out.textures_delta.clear();
assert!(ctx.fonts(|fonts| fonts.definitions().font_data.contains_key("jp")));
}
#[test]
fn empty_install_still_defines_the_two_base_families() {
let face = bundled("Hack");
let mut f = FontInstall::empty();
if !face.is_empty() {
f.fallback("hack", face).unwrap();
}
let defs = f.definitions();
assert!(defs.families.contains_key(&FontFamily::Proportional));
assert!(defs.families.contains_key(&FontFamily::Monospace));
}
#[test]
fn language_tags_choose_a_script() {
assert_eq!(CjkScript::for_language("ja-JP"), Some(CjkScript::Japanese));
assert_eq!(CjkScript::for_language("zh"), Some(CjkScript::SimplifiedChinese));
assert_eq!(CjkScript::for_language("zh-Hant"), Some(CjkScript::TraditionalChinese));
assert_eq!(CjkScript::for_language("zh_TW"), Some(CjkScript::TraditionalChinese));
assert_eq!(CjkScript::for_language("ko"), Some(CjkScript::Korean));
assert_eq!(CjkScript::for_language("en-US"), None);
assert_eq!(CjkScript::for_language(""), None);
}
}