1use unicode_properties::{EmojiStatus, GeneralCategory, UnicodeEmoji, UnicodeGeneralCategory};
14use unicode_script::{Script, UnicodeScript};
15use unicode_segmentation::UnicodeSegmentation;
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub enum CanvasFontFallback {
20 Disabled,
22 #[default]
24 Emoji,
25 EmojiAndCjk,
27}
28
29impl CanvasFontFallback {
30 #[cfg(any(target_family = "wasm", test))]
31 pub(crate) fn allows(self, emoji_presentation: bool) -> bool {
32 match self {
33 Self::Disabled => false,
34 Self::Emoji => emoji_presentation,
35 Self::EmojiAndCjk => true,
36 }
37 }
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct CanvasFallback {
43 pub emoji_presentation: bool,
45}
46
47pub fn classify_canvas_fallback(grapheme: &str) -> Option<CanvasFallback> {
50 if grapheme.is_ascii() {
51 return None;
52 }
53 let mut graphemes = grapheme.graphemes(true);
54 if graphemes.next()? != grapheme || graphemes.next().is_some() {
55 return None;
56 }
57
58 if is_cjk(grapheme) {
59 Some(CanvasFallback {
60 emoji_presentation: false,
61 })
62 } else {
63 classify_emoji(grapheme).map(|emoji_presentation| CanvasFallback { emoji_presentation })
64 }
65}
66
67fn is_cjk(grapheme: &str) -> bool {
68 let mut characters = grapheme.chars();
69 let Some(base) = characters.next() else {
70 return false;
71 };
72 let suffix = characters.as_str();
73
74 if base.script() == Script::Han
77 && base.general_category() == GeneralCategory::OtherLetter
78 && matches!(base, '\u{3400}'..='\u{9fff}' | '\u{f900}'..='\u{faff}'
79 | '\u{20000}'..='\u{323af}')
80 {
81 return suffix.is_empty()
82 || matches!(
83 (characters.next(), characters.next()),
84 (Some('\u{fe00}'..='\u{fe02}' | '\u{e0100}'..='\u{e01ef}'), None)
85 );
86 }
87
88 if matches!(base, '\u{3041}'..='\u{3096}' | '\u{30a1}'..='\u{30fa}') {
89 return suffix.is_empty()
90 || match suffix {
91 "\u{3099}" => "うかきくけこさしすせそたちつてとはひふへほウカキクケコサシスセソタチツテトハヒフヘホワヰヱヲ".contains(base),
92 "\u{309a}" => "はひふへほハヒフヘホ".contains(base),
93 _ => false,
94 };
95 }
96
97 if matches!(base, '\u{ac00}'..='\u{d7a3}') {
98 return suffix.is_empty()
99 || ((base as u32 - 0xac00).is_multiple_of(28)
100 && is_single_modern_trailing_jamo(suffix));
101 }
102 if matches!(base, '\u{1100}'..='\u{1112}') {
103 return matches!(characters.next(), Some('\u{1161}'..='\u{1175}'))
104 && (characters.as_str().is_empty()
105 || is_single_modern_trailing_jamo(characters.as_str()));
106 }
107
108 suffix.is_empty()
109 && (matches!(base, '\u{3131}'..='\u{3163}')
110 || "、。〈〉《》「」『』【】〔〕()[]{},.!?:;・ー々〆".contains(base))
111}
112
113fn is_single_modern_trailing_jamo(text: &str) -> bool {
114 let mut characters = text.chars();
115 matches!(characters.next(), Some('\u{11a8}'..='\u{11c2}')) && characters.next().is_none()
116}
117
118fn classify_emoji(grapheme: &str) -> Option<bool> {
119 let mut characters = grapheme.chars();
120 let base = characters.next()?;
121 let suffix = characters.as_str();
122
123 if matches!(base, '0'..='9' | '#' | '*') {
124 return match suffix {
125 "\u{20e3}" | "\u{fe0f}\u{20e3}" => Some(true),
126 _ => None,
127 };
128 }
129 if unicode_properties::emoji::is_regional_indicator(base) {
130 return (characters
131 .next()
132 .is_some_and(unicode_properties::emoji::is_regional_indicator)
133 && characters.next().is_none())
134 .then_some(true);
135 }
136 if matches!(
137 grapheme,
138 "🏴\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}"
139 | "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}"
140 | "🏴\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}"
141 ) {
142 return Some(true);
143 }
144 if grapheme.contains('\u{200d}') {
145 return is_supported_zwj_sequence(grapheme).then_some(true);
146 }
147 classify_emoji_unit(grapheme)
148}
149
150fn classify_emoji_unit(text: &str) -> Option<bool> {
151 let mut characters = text.chars();
152 let base = characters.next()?;
153 if !base.is_emoji_char() || base.is_emoji_component() {
154 return None;
155 }
156 let status = base.emoji_status();
157 let mut emoji_presentation = matches!(
158 status,
159 EmojiStatus::EmojiPresentation | EmojiStatus::EmojiPresentationAndModifierBase
160 );
161 let mut next = characters.next();
162 let explicit_text = next == Some('\u{fe0e}');
163 if matches!(next, Some('\u{fe0e}' | '\u{fe0f}')) {
164 emoji_presentation = !explicit_text;
165 next = characters.next();
166 }
167 if let Some(modifier) = next {
168 if explicit_text
169 || !matches!(
170 status,
171 EmojiStatus::EmojiModifierBase | EmojiStatus::EmojiPresentationAndModifierBase
172 )
173 || modifier.emoji_status() != EmojiStatus::EmojiPresentationAndModifierAndEmojiComponent
174 {
175 return None;
176 }
177 emoji_presentation = true;
178 }
179 characters.next().is_none().then_some(emoji_presentation)
180}
181
182fn is_supported_zwj_sequence(grapheme: &str) -> bool {
183 if matches!(
184 grapheme,
185 "👨👩👧" | "👨👩👧👦"
186 | "👩👩👧👦"
187 | "👨👨👧👦"
188 | "🏳️🌈"
189 | "🏳️⚧️"
190 | "🏴☠️"
191 | "❤️🔥"
192 | "❤️🩹"
193 | "👁️🗨️"
194 | "🐻❄️"
195 ) {
196 return true;
197 }
198 let Some((person, profession)) = grapheme.split_once('\u{200d}') else {
199 return false;
200 };
201 matches!(person.chars().next(), Some('👨' | '👩' | '🧑'))
202 && classify_emoji_unit(person) == Some(true)
203 && matches!(
204 profession,
205 "⚕️" | "⚖️"
206 | "✈️"
207 | "🌾"
208 | "🍳"
209 | "🎓"
210 | "🎤"
211 | "🎨"
212 | "🏫"
213 | "🏭"
214 | "💻"
215 | "💼"
216 | "🔧"
217 | "🔬"
218 | "🚀"
219 | "🚒"
220 )
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn canvas_font_fallback_policy() {
229 assert_eq!(CanvasFontFallback::default(), CanvasFontFallback::Emoji);
230 for grapheme in ["😀", "❤️", "1️⃣", "👨👩👧👦", "中", "か\u{3099}", "각", "©", "❤︎"]
231 {
232 let fallback = classify_canvas_fallback(grapheme).expect("eligible grapheme");
233 assert!(!CanvasFontFallback::Disabled.allows(fallback.emoji_presentation));
234 assert_eq!(
235 CanvasFontFallback::Emoji.allows(fallback.emoji_presentation),
236 ["😀", "❤️", "1️⃣", "👨👩👧👦"].contains(&grapheme),
237 );
238 assert!(CanvasFontFallback::EmojiAndCjk.allows(fallback.emoji_presentation));
239 }
240 }
241
242 #[test]
243 fn ascii_is_ineligible_but_keycaps_are_preserved() {
244 for byte in 0..=0x7f_u8 {
245 assert_eq!(classify_canvas_fallback(&(byte as char).to_string()), None);
246 }
247 for text in ["", "Hello", "0123456789#*", "\r\n"] {
248 assert_eq!(classify_canvas_fallback(text), None);
249 }
250 for base in "0123456789#*".chars() {
251 for text in [format!("{base}\u{20e3}"), format!("{base}\u{fe0f}\u{20e3}")] {
252 assert_eq!(
253 classify_canvas_fallback(&text),
254 Some(CanvasFallback {
255 emoji_presentation: true,
256 }),
257 "{text:?}"
258 );
259 }
260 }
261 }
262
263 #[test]
264 fn ordinary_cjk_clusters() {
265 for text in [
266 "漢",
267 "𠀀",
268 "﨑",
269 "漢\u{fe00}",
270 "葛\u{e0100}",
271 "葛\u{e01ef}",
272 "あ",
273 "ガ",
274 "か\u{3099}",
275 "ハ\u{309a}",
276 "가",
277 "각",
278 "가",
279 "각",
280 "각",
281 "ㄱ",
282 "、",
283 "。",
284 "「",
285 "」",
286 "(",
287 "!",
288 "ー",
289 "々",
290 ] {
291 assert_eq!(
292 classify_canvas_fallback(text),
293 Some(CanvasFallback {
294 emoji_presentation: false,
295 }),
296 "{text:?}"
297 );
298 }
299 }
300
301 #[test]
302 fn emoji_presentation_is_preserved() {
303 for (text, emoji_presentation) in [
304 ("😀", true),
305 ("©", false),
306 ("©\u{fe0e}", false),
307 ("©\u{fe0f}", true),
308 ("❤", false),
309 ("❤\u{fe0e}", false),
310 ("❤\u{fe0f}", true),
311 ("😀\u{fe0e}", false),
312 ("👍🏽", true),
313 ("☝🏽", true),
314 ("👍\u{fe0f}🏽", true),
315 ("🇯🇵", true),
316 ("1\u{20e3}", true),
317 ("#\u{fe0f}\u{20e3}", true),
318 ("*\u{fe0f}\u{20e3}", true),
319 ("👩🏽💻", true),
320 ("🧑⚕️", true),
321 ("👨👩👧👦", true),
322 ("🏳️🌈", true),
323 (
324 "🏴\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}",
325 true,
326 ),
327 (
328 "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}",
329 true,
330 ),
331 (
332 "🏴\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}",
333 true,
334 ),
335 ] {
336 assert_eq!(
337 classify_canvas_fallback(text),
338 Some(CanvasFallback { emoji_presentation }),
339 "{text:?}"
340 );
341 }
342 }
343
344 #[test]
345 fn unsupported_scripts_and_cjk_forms_stay_on_cosmic() {
346 for text in [
347 "a",
348 "é",
349 "α",
350 "Ж",
351 "ش",
352 "ש",
353 "क",
354 "क्ष",
355 "ก",
356 "ក",
357 "ᠠ",
358 "ཀ",
359 "\0",
360 "\n",
361 "\r\n",
362 "\u{202e}",
363 "\u{200d}",
364 "\u{fdd0}",
365 "\u{10ffff}",
366 "\u{2a6e0}",
367 "\u{e000}",
368 "\u{3099}",
369 "\u{fe0f}",
370 "\u{e0100}",
371 "漢\u{301}",
372 "漢\u{fe03}",
373 "漢\u{fe0f}",
374 "漢\u{e0100}\u{e0101}",
375 "あ\u{3099}",
376 "か\u{309a}",
377 "か\u{3099}\u{3099}",
378 "ᄀ",
379 "ᅡ",
380 "ᆨ",
381 "ᄓᅡ",
382 "ᄀᅶ",
383 "가ᇃ",
384 "각ᆨ",
385 "ᄀ가",
386 "\u{3164}",
387 "\u{3165}",
388 "⺀",
389 "⼀",
390 "㇀",
391 "㆐",
392 "ㄅ",
393 "㐀\u{200d}",
394 "\u{1b000}",
395 "カ",
396 "!\u{301}",
397 ] {
398 assert_eq!(classify_canvas_fallback(text), None, "{text:?}");
399 }
400 }
401
402 #[test]
403 fn malformed_or_out_of_policy_emoji_stay_on_cosmic() {
404 for text in [
405 "0",
406 "#",
407 "*",
408 "1\u{fe0f}",
409 "#\u{fe0e}",
410 "1\u{fe0e}\u{20e3}",
411 "a\u{20e3}",
412 "🏽",
413 "🇯",
414 "😀🏽",
415 "👍🏽🏽",
416 "👍\u{fe0e}🏽",
417 "👍🏽\u{fe0f}",
418 "❤\u{fe0f}\u{fe0f}",
419 "😀\u{301}",
420 "🦰",
421 "😀😀",
422 "👩",
423 "👩💻🚀",
424 "👩\u{fe0e}💻",
425 "🏴\u{e0067}",
426 "🏴\u{e0061}\u{e0062}\u{e007f}",
427 "😀\u{e0067}\u{e007f}",
428 ] {
429 assert_eq!(classify_canvas_fallback(text), None, "{text:?}");
430 }
431 }
432
433 #[test]
434 fn accepts_only_one_whole_extended_grapheme() {
435 for text in [
436 "",
437 "漢字",
438 "あい",
439 "가나",
440 "😀😀",
441 "🇯🇵🇺",
442 "🇯🇵🇺🇸",
443 " 漢",
444 "漢\n",
445 ] {
446 assert_eq!(classify_canvas_fallback(text), None, "{text:?}");
447 }
448 let text = "aか\u{3099}👩🏽💻漢\u{e0100}";
449 let eligible: Vec<_> = text
450 .grapheme_indices(true)
451 .filter_map(|(start, grapheme)| {
452 classify_canvas_fallback(grapheme).map(|_| start..start + grapheme.len())
453 })
454 .collect();
455 assert_eq!(
456 eligible
457 .iter()
458 .map(|range| &text[range.clone()])
459 .collect::<Vec<_>>(),
460 ["か\u{3099}", "👩🏽💻", "漢\u{e0100}"]
461 );
462 }
463}