use unicode_properties::{EmojiStatus, GeneralCategory, UnicodeEmoji, UnicodeGeneralCategory};
use unicode_script::{Script, UnicodeScript};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum CanvasFontFallback {
Disabled,
#[default]
Emoji,
EmojiAndCjk,
}
impl CanvasFontFallback {
#[cfg(any(target_family = "wasm", test))]
pub(crate) fn allows(self, emoji_presentation: bool) -> bool {
match self {
Self::Disabled => false,
Self::Emoji => emoji_presentation,
Self::EmojiAndCjk => true,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CanvasFallback {
pub emoji_presentation: bool,
}
pub fn classify_canvas_fallback(grapheme: &str) -> Option<CanvasFallback> {
if grapheme.is_ascii() {
return None;
}
let mut graphemes = grapheme.graphemes(true);
if graphemes.next()? != grapheme || graphemes.next().is_some() {
return None;
}
if is_cjk(grapheme) {
Some(CanvasFallback {
emoji_presentation: false,
})
} else {
classify_emoji(grapheme).map(|emoji_presentation| CanvasFallback { emoji_presentation })
}
}
fn is_cjk(grapheme: &str) -> bool {
let mut characters = grapheme.chars();
let Some(base) = characters.next() else {
return false;
};
let suffix = characters.as_str();
if base.script() == Script::Han
&& base.general_category() == GeneralCategory::OtherLetter
&& matches!(base, '\u{3400}'..='\u{9fff}' | '\u{f900}'..='\u{faff}'
| '\u{20000}'..='\u{323af}')
{
return suffix.is_empty()
|| matches!(
(characters.next(), characters.next()),
(Some('\u{fe00}'..='\u{fe02}' | '\u{e0100}'..='\u{e01ef}'), None)
);
}
if matches!(base, '\u{3041}'..='\u{3096}' | '\u{30a1}'..='\u{30fa}') {
return suffix.is_empty()
|| match suffix {
"\u{3099}" => "γγγγγγγγγγγγγ‘γ€γ¦γ¨γ―γ²γ΅γΈγ»γ¦γ«γγ―γ±γ³γ΅γ·γΉγ»γ½γΏγγγγγγγγγγ―γ°γ±γ²".contains(base),
"\u{309a}" => "γ―γ²γ΅γΈγ»γγγγγ".contains(base),
_ => false,
};
}
if matches!(base, '\u{ac00}'..='\u{d7a3}') {
return suffix.is_empty()
|| ((base as u32 - 0xac00).is_multiple_of(28)
&& is_single_modern_trailing_jamo(suffix));
}
if matches!(base, '\u{1100}'..='\u{1112}') {
return matches!(characters.next(), Some('\u{1161}'..='\u{1175}'))
&& (characters.as_str().is_empty()
|| is_single_modern_trailing_jamo(characters.as_str()));
}
suffix.is_empty()
&& (matches!(base, '\u{3131}'..='\u{3163}')
|| "γγγγγγγγγγγγγγοΌοΌοΌ»οΌ½ο½ο½οΌοΌοΌοΌοΌοΌγ»γΌγ
γ".contains(base))
}
fn is_single_modern_trailing_jamo(text: &str) -> bool {
let mut characters = text.chars();
matches!(characters.next(), Some('\u{11a8}'..='\u{11c2}')) && characters.next().is_none()
}
fn classify_emoji(grapheme: &str) -> Option<bool> {
let mut characters = grapheme.chars();
let base = characters.next()?;
let suffix = characters.as_str();
if matches!(base, '0'..='9' | '#' | '*') {
return match suffix {
"\u{20e3}" | "\u{fe0f}\u{20e3}" => Some(true),
_ => None,
};
}
if unicode_properties::emoji::is_regional_indicator(base) {
return (characters
.next()
.is_some_and(unicode_properties::emoji::is_regional_indicator)
&& characters.next().is_none())
.then_some(true);
}
if matches!(
grapheme,
"π΄\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}"
| "π΄\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}"
| "π΄\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}"
) {
return Some(true);
}
if grapheme.contains('\u{200d}') {
return is_supported_zwj_sequence(grapheme).then_some(true);
}
classify_emoji_unit(grapheme)
}
fn classify_emoji_unit(text: &str) -> Option<bool> {
let mut characters = text.chars();
let base = characters.next()?;
if !base.is_emoji_char() || base.is_emoji_component() {
return None;
}
let status = base.emoji_status();
let mut emoji_presentation = matches!(
status,
EmojiStatus::EmojiPresentation | EmojiStatus::EmojiPresentationAndModifierBase
);
let mut next = characters.next();
let explicit_text = next == Some('\u{fe0e}');
if matches!(next, Some('\u{fe0e}' | '\u{fe0f}')) {
emoji_presentation = !explicit_text;
next = characters.next();
}
if let Some(modifier) = next {
if explicit_text
|| !matches!(
status,
EmojiStatus::EmojiModifierBase | EmojiStatus::EmojiPresentationAndModifierBase
)
|| modifier.emoji_status() != EmojiStatus::EmojiPresentationAndModifierAndEmojiComponent
{
return None;
}
emoji_presentation = true;
}
characters.next().is_none().then_some(emoji_presentation)
}
fn is_supported_zwj_sequence(grapheme: &str) -> bool {
if matches!(
grapheme,
"π¨βπ©βπ§" | "π¨βπ©βπ§βπ¦"
| "π©βπ©βπ§βπ¦"
| "π¨βπ¨βπ§βπ¦"
| "π³οΈβπ"
| "π³οΈββ§οΈ"
| "π΄ββ οΈ"
| "β€οΈβπ₯"
| "β€οΈβπ©Ή"
| "ποΈβπ¨οΈ"
| "π»ββοΈ"
) {
return true;
}
let Some((person, profession)) = grapheme.split_once('\u{200d}') else {
return false;
};
matches!(person.chars().next(), Some('π¨' | 'π©' | 'π§'))
&& classify_emoji_unit(person) == Some(true)
&& matches!(
profession,
"βοΈ" | "βοΈ"
| "βοΈ"
| "πΎ"
| "π³"
| "π"
| "π€"
| "π¨"
| "π«"
| "π"
| "π»"
| "πΌ"
| "π§"
| "π¬"
| "π"
| "π"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canvas_font_fallback_policy() {
assert_eq!(CanvasFontFallback::default(), CanvasFontFallback::Emoji);
for grapheme in ["π", "β€οΈ", "1οΈβ£", "π¨βπ©βπ§βπ¦", "δΈ", "γ\u{3099}", "αα
‘α¨", "Β©", "β€οΈ"]
{
let fallback = classify_canvas_fallback(grapheme).expect("eligible grapheme");
assert!(!CanvasFontFallback::Disabled.allows(fallback.emoji_presentation));
assert_eq!(
CanvasFontFallback::Emoji.allows(fallback.emoji_presentation),
["π", "β€οΈ", "1οΈβ£", "π¨βπ©βπ§βπ¦"].contains(&grapheme),
);
assert!(CanvasFontFallback::EmojiAndCjk.allows(fallback.emoji_presentation));
}
}
#[test]
fn ascii_is_ineligible_but_keycaps_are_preserved() {
for byte in 0..=0x7f_u8 {
assert_eq!(classify_canvas_fallback(&(byte as char).to_string()), None);
}
for text in ["", "Hello", "0123456789#*", "\r\n"] {
assert_eq!(classify_canvas_fallback(text), None);
}
for base in "0123456789#*".chars() {
for text in [format!("{base}\u{20e3}"), format!("{base}\u{fe0f}\u{20e3}")] {
assert_eq!(
classify_canvas_fallback(&text),
Some(CanvasFallback {
emoji_presentation: true,
}),
"{text:?}"
);
}
}
}
#[test]
fn ordinary_cjk_clusters() {
for text in [
"ζΌ’",
"π ",
"ο¨",
"ζΌ’\u{fe00}",
"θ\u{e0100}",
"θ\u{e01ef}",
"γ",
"γ¬",
"γ\u{3099}",
"γ\u{309a}",
"κ°",
"κ°",
"αα
‘",
"αα
‘α¨",
"κ°α¨",
"γ±",
"γ",
"γ",
"γ",
"γ",
"οΌ",
"οΌ",
"γΌ",
"γ
",
] {
assert_eq!(
classify_canvas_fallback(text),
Some(CanvasFallback {
emoji_presentation: false,
}),
"{text:?}"
);
}
}
#[test]
fn emoji_presentation_is_preserved() {
for (text, emoji_presentation) in [
("π", true),
("Β©", false),
("Β©\u{fe0e}", false),
("Β©\u{fe0f}", true),
("β€", false),
("β€\u{fe0e}", false),
("β€\u{fe0f}", true),
("π\u{fe0e}", false),
("ππ½", true),
("βπ½", true),
("π\u{fe0f}π½", true),
("π―π΅", true),
("1\u{20e3}", true),
("#\u{fe0f}\u{20e3}", true),
("*\u{fe0f}\u{20e3}", true),
("π©π½βπ»", true),
("π§ββοΈ", true),
("π¨βπ©βπ§βπ¦", true),
("π³οΈβπ", true),
(
"π΄\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}",
true,
),
(
"π΄\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}",
true,
),
(
"π΄\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}",
true,
),
] {
assert_eq!(
classify_canvas_fallback(text),
Some(CanvasFallback { emoji_presentation }),
"{text:?}"
);
}
}
#[test]
fn unsupported_scripts_and_cjk_forms_stay_on_cosmic() {
for text in [
"a",
"Γ©",
"Ξ±",
"Π",
"Ψ΄",
"Χ©",
"ΰ€",
"ΰ€ΰ₯ΰ€·",
"ΰΈ",
"α",
"α ",
"ΰ½",
"\0",
"\n",
"\r\n",
"\u{202e}",
"\u{200d}",
"\u{fdd0}",
"\u{10ffff}",
"\u{2a6e0}",
"\u{e000}",
"\u{3099}",
"\u{fe0f}",
"\u{e0100}",
"ζΌ’\u{301}",
"ζΌ’\u{fe03}",
"ζΌ’\u{fe0f}",
"ζΌ’\u{e0100}\u{e0101}",
"γ\u{3099}",
"γ\u{309a}",
"γ\u{3099}\u{3099}",
"α",
"α
‘",
"α¨",
"αα
‘",
"αα
Ά",
"αα
‘α",
"κ°α¨",
"ααα
‘",
"\u{3164}",
"\u{3165}",
"βΊ",
"βΌ",
"γ",
"γ",
"γ
",
"γ\u{200d}",
"\u{1b000}",
"ο½Ά",
"οΌ\u{301}",
] {
assert_eq!(classify_canvas_fallback(text), None, "{text:?}");
}
}
#[test]
fn malformed_or_out_of_policy_emoji_stay_on_cosmic() {
for text in [
"0",
"#",
"*",
"1\u{fe0f}",
"#\u{fe0e}",
"1\u{fe0e}\u{20e3}",
"a\u{20e3}",
"π½",
"π―",
"ππ½",
"ππ½π½",
"π\u{fe0e}π½",
"ππ½\u{fe0f}",
"β€\u{fe0f}\u{fe0f}",
"π\u{301}",
"π¦°",
"πβπ",
"π©β",
"π©βπ»βπ",
"π©\u{fe0e}βπ»",
"π΄\u{e0067}",
"π΄\u{e0061}\u{e0062}\u{e007f}",
"π\u{e0067}\u{e007f}",
] {
assert_eq!(classify_canvas_fallback(text), None, "{text:?}");
}
}
#[test]
fn accepts_only_one_whole_extended_grapheme() {
for text in [
"",
"ζΌ’ε",
"γγ",
"κ°λ",
"ππ",
"π―π΅πΊ",
"π―π΅πΊπΈ",
" ζΌ’",
"ζΌ’\n",
] {
assert_eq!(classify_canvas_fallback(text), None, "{text:?}");
}
let text = "aγ\u{3099}π©π½βπ»ζΌ’\u{e0100}";
let eligible: Vec<_> = text
.grapheme_indices(true)
.filter_map(|(start, grapheme)| {
classify_canvas_fallback(grapheme).map(|_| start..start + grapheme.len())
})
.collect();
assert_eq!(
eligible
.iter()
.map(|range| &text[range.clone()])
.collect::<Vec<_>>(),
["γ\u{3099}", "π©π½βπ»", "ζΌ’\u{e0100}"]
);
}
}