Skip to main content

rumdl_lib/utils/
unicode.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4/// Format a Unicode codepoint as a string in the format "U+XXXX" or "U+XXXXX" or "U+XXXXXX",
5/// depending on the value of the codepoint. The output is always uppercase.
6pub fn format_codepoint(c: char) -> String {
7    let cp = c as u32;
8    if cp <= 0xFFFF {
9        format!("U+{cp:04X}")
10    } else if cp <= 0xFFFFF {
11        format!("U+{cp:05X}")
12    } else if cp <= 0x10FFFF {
13        format!("U+{cp:06X}")
14    } else {
15        panic!("Invalid Unicode codepoint: {cp}");
16    }
17}
18
19/// Parse a single character from a string, returning `Some(char)` if the string contains exactly one character,
20/// or `None` if the string is empty or contains more than one character.
21pub fn parse_single_char(input: &str) -> Option<char> {
22    let mut chars = input.trim().chars();
23    let first = chars.next()?;
24    if chars.next().is_some() {
25        return None;
26    }
27    if first.len_utf8() != input.len() {
28        return None;
29    }
30    Some(first)
31}
32
33/// Check a Unicode codepoint token in the format "U+XXXX" or "u+XXXX",
34/// and return a normalized version of it in the format "U+XXXX".
35/// with uppercase letters and no leading/trailing whitespace.
36pub fn normalize_codepoint(input: &str) -> Result<String, String> {
37    let trimmed = input.trim();
38    let Some(hex) = trimmed.strip_prefix("U+").or_else(|| trimmed.strip_prefix("u+")) else {
39        return Err(format!("Invalid codepoint '{trimmed}': expected format U+XXXX"));
40    };
41
42    if !(4..=6).contains(&hex.len()) {
43        return Err(format!("Invalid codepoint '{trimmed}': expected 4 to 6 hex digits"));
44    }
45
46    if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
47        return Err(format!("Invalid codepoint '{trimmed}': contains non-hex characters"));
48    }
49
50    let value = u32::from_str_radix(hex, 16).map_err(|_| format!("Invalid codepoint '{trimmed}': parse failed"))?;
51
52    if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
53        return Err(format!("Invalid codepoint '{trimmed}': out of Unicode range"));
54    }
55
56    Ok(format_codepoint(char::from_u32(value).unwrap()))
57}
58
59/// Parse a codepoint token in the format "U+XXXX" or "u+XXXX" and returns the corresponding character.
60pub fn parse_codepoint(token: &str) -> Option<char> {
61    let normalized = normalize_codepoint(token).ok()?;
62    let hex = normalized
63        .strip_prefix("U+")
64        .or_else(|| normalized.strip_prefix("u+"))?;
65    if hex.len() < 4 || hex.len() > 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
66        return None;
67    }
68
69    let value = u32::from_str_radix(hex, 16).ok()?;
70    if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
71        return None;
72    }
73    std::char::from_u32(value)
74}
75
76/// Check if a Unicode character is considered invisible according to the Unicode standard and common usage.
77/// This includes control characters, formatting characters,
78/// and other non-printing characters that do not produce a visible mark in text.
79pub fn is_invisible_char(c: char) -> bool {
80    let cp = c as u32;
81    matches!(
82        cp,
83        0x0000..=0x0008
84            | 0x000A..=0x001F // C0 Control characters, excluding TAB (0x0009)
85            | 0x007F..=0x009F // DEL + C1 control characters
86            | 0x00AD // SOFT HYPHEN
87            | 0x034F // COMBINING GRAPHEME JOINER
88            | 0x061C // ARABIC LETTER MARK
89            | 0x115F // HANGUL CHOSEONG FILLER
90            | 0x1160 // HANGUL JUNGSEONG FILLER
91            | 0x17B4 // KHMER VOWEL INHERENT AQ
92            | 0x17B5 // KHMER VOWEL INHERENT AA
93            | 0x180B..=0x180E // Mongolian variation selectors + MONGOLIAN VOWEL SEPARATOR
94            | 0x200B..=0x200F // ZWSP, ZWNJ, ZWJ, LRM, RLM
95            | 0x202A..=0x202E // Bidi embedding/override controls
96            | 0x2060..=0x206F // WORD JOINER, invisibles, and bidi isolate controls
97            | 0x3164 // HANGUL FILLER
98            | 0xFE00..=0xFE0F // Variation Selectors (VS1..VS16)
99            | 0xFEFF // ZERO WIDTH NO-BREAK SPACE (BOM)
100            | 0xFFA0 // HALFWIDTH HANGUL FILLER
101            | 0xFFF0..=0xFFF8 // Reserved non-rendering specials
102            | 0x1BCA0..=0x1BCA3 // Shorthand format controls
103            | 0x1D173..=0x1D17A // Musical symbol format controls
104            | 0xE0000..=0xE0FFF // Tags block + Variation Selectors Supplement
105    )
106}
107
108/// Check if a Unicode character carries the `Deprecated` property
109/// or is otherwise discouraged from use, but still renders in most environments.
110/// These characters are discouraged from use but they still render, so they are reported without a
111/// removal fix: only the author knows what the text should say instead.
112pub fn is_deprecated_char(c: char) -> bool {
113    let cp = c as u32;
114    matches!(
115        cp,
116        0x0149 // LATIN SMALL LETTER N PRECEDED BY APOSTROPHE
117            | 0x0673 // ARABIC LETTER ALEF WITH WAVY HAMZA ABOVE
118            | 0x0F77 // TIBETAN VOWEL SIGN VOCALIC LL
119            | 0x0F79 // TIBETAN VOWEL SIGN VOCALIC LR
120            | 0x17A3..=0x17A4 // KHMER INHERENT VOWEL SIGN AA..KHMER INHERENT VOWEL SIGN AE
121            | 0x206A..=0x206F // INHIBIT SYMMETRIC SWAPPING..NOMINAL DIGIT SHAPES
122            | 0x2329 // LEFT-POINTING ANGLE BRACKET
123            | 0x232A // RIGHT-POINTING ANGLE BRACKET
124            | 0xE0001 // LANGUAGE TAG
125    )
126}
127
128/// The rows of UTR#20 table 3.1 that neither of the sets above already covers:
129/// visible or structural code points a markup document is meant to express with
130/// markup instead. They are not default-ignorable, so removing one would drop
131/// content or leave a paired construct half-open, and only the two tone marks
132/// have a replacement that preserves the text exactly.
133pub fn is_unsuitable_for_markup_char(c: char) -> bool {
134    let cp = c as u32;
135    matches!(
136        cp,
137        0x0340 // COMBINING GRAVE TONE MARK
138            | 0x0341 // COMBINING ACUTE TONE MARK
139            | 0xFFF9..=0xFFFC // Interlinear annotation delimiters + OBJECT REPLACEMENT CHARACTER
140    )
141}
142
143/// Whether `c` is a letter of a Chinese, Japanese or Korean script.
144///
145/// Letters only: Han ideographs (`中`, the iteration mark `々`, `〇`), kana
146/// including half-width forms and the prolonged sound mark (`ー`), and Hangul
147/// syllables and jamo. CJK punctuation (`。`, `「`, `・`), full-width Latin
148/// letters and digits (`T`, `1`) and bopomofo are not CJK letters. Neither are
149/// combining marks such as the kana voiced sound mark (`\u{3099}`): a mark
150/// renders as part of the letter it follows, so it is that letter's business.
151pub fn is_cjk_letter(c: char) -> bool {
152    let cp = c as u32;
153    // Below U+1100 only U+0305 COMBINING OVERLINE and U+0323 COMBINING DOT BELOW match
154    // the class, carrying Katakana script extensions. These combining marks attach to
155    // whatever precedes them, so returning false here is deliberate to avoid splitting
156    // grapheme clusters.
157    if cp < 0x1100 {
158        return false;
159    }
160    // The three dense blocks contain nothing but letters; answer without the regex.
161    if matches!(cp, 0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0xAC00..=0xD7A3) {
162        return true;
163    }
164    // Script extensions catch characters shared across CJK scripts (`々`, `ー`);
165    // subtracting punctuation, symbols and combining marks leaves letters and
166    // numerals.
167    static CJK_LETTER: LazyLock<Regex> = LazyLock::new(|| {
168        Regex::new(r"^[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}\p{scx=Hangul}--\p{P}--\p{S}--\p{Mn}--\p{Me}]$")
169            .expect("CJK letter class is a valid regex")
170    });
171    let mut buf = [0u8; 4];
172    CJK_LETTER.is_match(c.encode_utf8(&mut buf))
173}
174
175/// Whether `c` belongs to text that CJK-aware renderers join without a space
176/// across a soft line break: a Han or kana letter, or CJK punctuation.
177///
178/// Hangul is excluded because Korean separates words with spaces. The
179/// punctuation is the kind only CJK text uses (`。`, `,`, `「`, `・`):
180/// ideographic, katakana, and full-width or half-width forms. Marks Western text shares,
181/// such as `“`, `…` and `—`, are left out, so a break next to one keeps its
182/// space. So is the ideographic space (`\u{3000}`), which is whitespace the
183/// author wrote.
184pub fn joins_cjk_soft_break(c: char) -> bool {
185    if is_cjk_letter(c) {
186        static HANGUL: LazyLock<Regex> =
187            LazyLock::new(|| Regex::new(r"^\p{sc=Hangul}$").expect("Hangul class is a valid regex"));
188        let mut buf = [0u8; 4];
189        return !HANGUL.is_match(c.encode_utf8(&mut buf));
190    }
191    let cp = c as u32;
192    // CJK Symbols and Punctuation, the katakana middle dot and double hyphen, the
193    // vertical and compatibility forms, and the full-width and half-width forms,
194    // each limited to its punctuation.
195    if !matches!(
196        cp,
197        0x3001..=0x303F | 0x30A0 | 0x30FB | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFF01..=0xFF65
198    ) {
199        return false;
200    }
201    static PUNCTUATION: LazyLock<Regex> =
202        LazyLock::new(|| Regex::new(r"^\p{P}$").expect("punctuation class is a valid regex"));
203    let mut buf = [0u8; 4];
204    PUNCTUATION.is_match(c.encode_utf8(&mut buf))
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn cjk_letters_cover_han_kana_and_hangul() {
213        // Han: ideographs, the iteration mark, the ideographic zero, Extension B.
214        for c in ['中', '々', '〇', '\u{20000}'] {
215            assert!(is_cjk_letter(c), "{c:?} (U+{:04X}) is a CJK letter", c as u32);
216        }
217        // Kana: hiragana, katakana, the prolonged sound mark, half-width katakana.
218        for c in ['あ', 'カ', 'ー', 'ハ'] {
219            assert!(is_cjk_letter(c), "{c:?} (U+{:04X}) is a CJK letter", c as u32);
220        }
221        // Hangul: a syllable and a conjoining jamo.
222        for c in ['한', '\u{1100}'] {
223            assert!(is_cjk_letter(c), "{c:?} (U+{:04X}) is a CJK letter", c as u32);
224        }
225    }
226
227    #[test]
228    fn cjk_punctuation_symbols_and_other_scripts_are_not_letters() {
229        // CJK punctuation and symbols share the scripts but are not letters. U+2E80 is a
230        // symbol rather than a letter, so the --\p{S} term in the class excludes it.
231        for c in ['・', '。', '「', '〜', '\u{2E80}', '\u{3000}'] {
232            assert!(!is_cjk_letter(c), "{c:?} (U+{:04X}) is not a CJK letter", c as u32);
233        }
234        // Full-width Latin and digits, ASCII, bopomofo.
235        for c in ['1', 'T', 'a', '1', 'ㄅ', ' ', 'é'] {
236            assert!(!is_cjk_letter(c), "{c:?} (U+{:04X}) is not a CJK letter", c as u32);
237        }
238        // Combining marks are excluded whatever their script, so a base character
239        // and its mark stay one unit. U+3099 and U+309A voice decomposed kana,
240        // U+0305 and U+0323 carry Katakana script extensions.
241        for c in ['\u{3099}', '\u{309A}', '\u{0305}', '\u{0323}'] {
242            assert!(!is_cjk_letter(c), "{c:?} (U+{:04X}) is not a CJK letter", c as u32);
243        }
244    }
245
246    #[test]
247    fn han_kana_and_cjk_punctuation_join_soft_breaks() {
248        for c in ['中', '々', '〇', '\u{20000}', 'あ', 'カ', 'ー', 'ハ'] {
249            assert!(joins_cjk_soft_break(c), "{c:?} (U+{:04X}) joins", c as u32);
250        }
251        // Ideographic, full-width, half-width, vertical and compatibility punctuation.
252        for c in [
253            '。', '、', '「', '」', '・', '〜', '゠', ',', '!', '(', ')', '。', '「', '\u{FE10}', '\u{FE41}',
254        ] {
255            assert!(joins_cjk_soft_break(c), "{c:?} (U+{:04X}) joins", c as u32);
256        }
257    }
258
259    #[test]
260    fn hangul_shared_punctuation_and_other_characters_keep_the_space() {
261        // Korean separates words with spaces.
262        for c in ['한', '\u{1100}', '\u{3131}', '\u{FFA1}'] {
263            assert!(!joins_cjk_soft_break(c), "{c:?} (U+{:04X}) keeps the space", c as u32);
264        }
265        // Whitespace, punctuation shared with Western text, CJK symbols that are
266        // not punctuation, full-width letters and digits, and markup characters.
267        for c in [
268            '\u{3000}', '\u{00A0}', ' ', '“', '”', '…', '—', '〒', 'T', '1', 'a', '1', '*', '`', ')', ']',
269        ] {
270            assert!(!joins_cjk_soft_break(c), "{c:?} (U+{:04X}) keeps the space", c as u32);
271        }
272    }
273}