pub fn is_cjk(ch: char) -> bool {
matches!(ch,
'\u{3040}'..='\u{309F}' |
'\u{30A0}'..='\u{30FF}' |
'\u{02EA}'..='\u{02EB}' |
'\u{3105}'..='\u{312F}' |
'\u{31A0}'..='\u{31BF}' |
'\u{1100}'..='\u{11FF}' |
'\u{302E}'..='\u{302F}' |
'\u{3131}'..='\u{318E}' |
'\u{3200}'..='\u{321E}' |
'\u{3260}'..='\u{327E}' |
'\u{A960}'..='\u{A97C}' |
'\u{AC00}'..='\u{D7A3}' |
'\u{D7B0}'..='\u{D7C6}' |
'\u{D7CB}'..='\u{D7FB}' |
'\u{FFA0}'..='\u{FFBE}' |
'\u{FFC2}'..='\u{FFC7}' |
'\u{FFCA}'..='\u{FFCF}' |
'\u{FFD2}'..='\u{FFD7}' |
'\u{FFDA}'..='\u{FFDC}' |
'\u{2E80}'..='\u{2EFF}' |
'\u{2F00}'..='\u{2FDF}' |
'\u{2FF0}'..='\u{2FFF}' |
'\u{3000}'..='\u{303F}' |
'\u{3400}'..='\u{4DBF}' |
'\u{4E00}'..='\u{9FFF}' |
'\u{A000}'..='\u{A48F}' |
'\u{A490}'..='\u{A4CF}' |
'\u{F900}'..='\u{FAFF}' |
'\u{FE30}'..='\u{FE4F}' |
'\u{20000}'..='\u{3134F}'
)
}
pub fn is_emoji(ch: char) -> bool {
matches!(ch,
'\u{1F600}'..='\u{1F64F}' |
'\u{1F300}'..='\u{1F5FF}' |
'\u{1F680}'..='\u{1F6FF}' |
'\u{1F900}'..='\u{1F9FF}' |
'\u{1FA00}'..='\u{1FA6F}' |
'\u{1FA70}'..='\u{1FAFF}' |
'\u{1F1E0}'..='\u{1F1FF}' |
'\u{2702}'..='\u{27B0}' |
'\u{2600}'..='\u{26FF}'
)
}
pub type FamilyEntry = (String, fn(char) -> bool);
pub struct FallbackChain {
families: Vec<FamilyEntry>,
}
fn accept_all(_ch: char) -> bool {
true
}
impl FallbackChain {
pub fn default_chain() -> Self {
Self {
families: vec![
("Noto Sans CJK".to_owned(), is_cjk as fn(char) -> bool),
("Noto Emoji".to_owned(), is_emoji as fn(char) -> bool),
("DejaVu Sans".to_owned(), accept_all as fn(char) -> bool),
],
}
}
pub fn add_family(&mut self, family: String) {
let len = self.families.len();
let insert_pos = if len > 0 { len - 1 } else { 0 };
self.families
.insert(insert_pos, (family, accept_all as fn(char) -> bool));
}
pub fn resolve_glyph(&self, ch: char) -> Option<&str> {
for (family, predicate) in &self.families {
if predicate(ch) {
return Some(family.as_str());
}
}
None
}
pub fn families(&self) -> &[FamilyEntry] {
&self.families
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fallback_cjk_detected() {
assert!(is_cjk('中'), "'中' must be CJK");
assert!(is_cjk('あ'), "'あ' (Hiragana) must be CJK");
assert!(!is_cjk('a'), "'a' must not be CJK");
assert!(!is_cjk('!'), "'!' must not be CJK");
}
#[test]
fn fallback_emoji_detected() {
assert!(is_emoji('😀'), "'😀' must be emoji");
assert!(is_emoji('🎉'), "'🎉' must be emoji");
assert!(!is_emoji('a'), "'a' must not be emoji");
}
#[test]
fn fallback_chain_has_entries() {
let chain = FallbackChain::default_chain();
assert!(
!chain.families().is_empty(),
"default chain must have entries"
);
}
#[test]
fn fallback_resolves_cjk_to_cjk_family() {
let chain = FallbackChain::default_chain();
let family = chain.resolve_glyph('中').unwrap();
assert!(
family.contains("CJK"),
"CJK char should resolve to a CJK family"
);
}
#[test]
fn fallback_resolves_emoji() {
let chain = FallbackChain::default_chain();
let family = chain.resolve_glyph('😀').unwrap();
assert!(
family.to_lowercase().contains("emoji"),
"emoji should resolve to emoji family"
);
}
#[test]
fn fallback_resolves_latin_to_last_resort() {
let chain = FallbackChain::default_chain();
let family = chain.resolve_glyph('a').unwrap();
assert!(!family.is_empty());
}
#[test]
fn fallback_add_family_inserts_before_tofu() {
let mut chain = FallbackChain::default_chain();
let original_len = chain.families().len();
chain.add_family("My Custom Font".to_owned());
assert_eq!(chain.families().len(), original_len + 1);
}
}