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