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 for eligible emoji, including missing text-presentation symbols.
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, 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/// A supported grapheme assumed to be independently renderable.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct CanvasFallback {
43    /// Whether this is an emoji-capable grapheme, independent of its presentation.
44    pub is_emoji: bool,
45    /// Whether to request emoji presentation instead of ordinary text presentation.
46    pub emoji_presentation: bool,
47}
48
49/// Returns a presentation only when `grapheme` is exactly one supported extended
50/// grapheme cluster. No normalization, splitting, or font-coverage decision occurs.
51pub 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    // The script/category checks exclude unassigned holes; the ranges exclude
81    // Han radicals, iteration marks, and other non-ideographic Han characters.
82    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}