Skip to main content

gpui_web/
canvas_fallback.rs

1//! Eligibility only for independent, horizontal Canvas fallback of a complete
2//! extended grapheme. Callers must still check whether bundled shaping supplies
3//! the requested glyph and presentation; eligibility alone does not override it.
4//!
5//! This is intentionally not a Unicode sequence validator or an RGI emoji database.
6//! Han variation selectors are checked structurally, not against the IVD registry.
7//! Emoji selectors are checked against the Emoji property, not the registered
8//! variation-sequence list. Eligibility does not guarantee browser coverage.
9//! Flags accept two regional indicators, not only assigned country codes. ZWJ
10//! support is limited to person/profession pairs and a small explicit allowlist;
11//! tags are limited to the three subdivision flags. Other sequences stay on Cosmic.
12
13use unicode_properties::{EmojiStatus, GeneralCategory, UnicodeEmoji, UnicodeGeneralCategory};
14use unicode_script::{Script, UnicodeScript};
15use unicode_segmentation::UnicodeSegmentation;
16
17/// Controls browser-font fallback when loaded fonts lack a glyph or its emoji presentation.
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub enum CanvasFontFallback {
20    /// Use only fonts loaded into GPUI.
21    Disabled,
22    /// Use Canvas only for eligible graphemes requesting emoji presentation.
23    #[default]
24    Emoji,
25    /// Also allow approximate independent rendering of eligible horizontal CJK text.
26    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/// A supported grapheme assumed to be independently renderable.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct CanvasFallback {
43    /// Whether to request emoji presentation instead of ordinary text presentation.
44    pub emoji_presentation: bool,
45}
46
47/// Returns a presentation only when `grapheme` is exactly one supported extended
48/// grapheme cluster. No normalization, splitting, or font-coverage decision occurs.
49pub 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    // The script/category checks exclude unassigned holes; the ranges exclude
75    // Han radicals, iteration marks, and other non-ideographic Han characters.
76    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}