Skip to main content

rumdl_lib/utils/
unicode.rs

1/// Format a Unicode codepoint as a string in the format "U+XXXX" or "U+XXXXX" or "U+XXXXXX",
2/// depending on the value of the codepoint. The output is always uppercase.
3pub fn format_codepoint(c: char) -> String {
4    let cp = c as u32;
5    if cp <= 0xFFFF {
6        format!("U+{cp:04X}")
7    } else if cp <= 0xFFFFF {
8        format!("U+{cp:05X}")
9    } else if cp <= 0x10FFFF {
10        format!("U+{cp:06X}")
11    } else {
12        panic!("Invalid Unicode codepoint: {cp}");
13    }
14}
15
16/// Parse a single character from a string, returning `Some(char)` if the string contains exactly one character,
17/// or `None` if the string is empty or contains more than one character.
18pub fn parse_single_char(input: &str) -> Option<char> {
19    let mut chars = input.trim().chars();
20    let first = chars.next()?;
21    if chars.next().is_some() {
22        return None;
23    }
24    if first.len_utf8() != input.len() {
25        return None;
26    }
27    Some(first)
28}
29
30/// Check a Unicode codepoint token in the format "U+XXXX" or "u+XXXX",
31/// and return a normalized version of it in the format "U+XXXX".
32/// with uppercase letters and no leading/trailing whitespace.
33pub fn normalize_codepoint(input: &str) -> Result<String, String> {
34    let trimmed = input.trim();
35    let Some(hex) = trimmed.strip_prefix("U+").or_else(|| trimmed.strip_prefix("u+")) else {
36        return Err(format!("Invalid codepoint '{trimmed}': expected format U+XXXX"));
37    };
38
39    if !(4..=6).contains(&hex.len()) {
40        return Err(format!("Invalid codepoint '{trimmed}': expected 4 to 6 hex digits"));
41    }
42
43    if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
44        return Err(format!("Invalid codepoint '{trimmed}': contains non-hex characters"));
45    }
46
47    let value = u32::from_str_radix(hex, 16).map_err(|_| format!("Invalid codepoint '{trimmed}': parse failed"))?;
48
49    if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
50        return Err(format!("Invalid codepoint '{trimmed}': out of Unicode range"));
51    }
52
53    Ok(format_codepoint(char::from_u32(value).unwrap()))
54}
55
56/// Parse a codepoint token in the format "U+XXXX" or "u+XXXX" and returns the corresponding character.
57pub fn parse_codepoint(token: &str) -> Option<char> {
58    let normalized = normalize_codepoint(token).ok()?;
59    let hex = normalized
60        .strip_prefix("U+")
61        .or_else(|| normalized.strip_prefix("u+"))?;
62    if hex.len() < 4 || hex.len() > 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
63        return None;
64    }
65
66    let value = u32::from_str_radix(hex, 16).ok()?;
67    if value > 0x10FFFF || (0xD800..=0xDFFF).contains(&value) {
68        return None;
69    }
70    std::char::from_u32(value)
71}
72
73/// Check if a Unicode character is considered invisible according to the Unicode standard and common usage.
74/// This includes control characters, formatting characters,
75/// and other non-printing characters that do not produce a visible mark in text.
76pub fn is_invisible_char(c: char) -> bool {
77    let cp = c as u32;
78    matches!(
79        cp,
80        0x0000..=0x0008
81            | 0x000A..=0x001F // C0 Control characters, excluding TAB (0x0009)
82            | 0x007F..=0x009F // DEL + C1 control characters
83            | 0x00AD // SOFT HYPHEN
84            | 0x034F // COMBINING GRAPHEME JOINER
85            | 0x061C // ARABIC LETTER MARK
86            | 0x115F // HANGUL CHOSEONG FILLER
87            | 0x1160 // HANGUL JUNGSEONG FILLER
88            | 0x17B4 // KHMER VOWEL INHERENT AQ
89            | 0x17B5 // KHMER VOWEL INHERENT AA
90            | 0x180B..=0x180E // Mongolian variation selectors + MONGOLIAN VOWEL SEPARATOR
91            | 0x200B..=0x200F // ZWSP, ZWNJ, ZWJ, LRM, RLM
92            | 0x202A..=0x202E // Bidi embedding/override controls
93            | 0x2060..=0x206F // WORD JOINER, invisibles, and bidi isolate controls
94            | 0x3164 // HANGUL FILLER
95            | 0xFE00..=0xFE0F // Variation Selectors (VS1..VS16)
96            | 0xFEFF // ZERO WIDTH NO-BREAK SPACE (BOM)
97            | 0xFFA0 // HALFWIDTH HANGUL FILLER
98            | 0xFFF0..=0xFFF8 // Reserved non-rendering specials
99            | 0x1BCA0..=0x1BCA3 // Shorthand format controls
100            | 0x1D173..=0x1D17A // Musical symbol format controls
101            | 0xE0000..=0xE0FFF // Tags block + Variation Selectors Supplement
102    )
103}
104
105/// Check if a Unicode character carries the `Deprecated` property
106/// or is otherwise discouraged from use, but still renders in most environments.
107/// These characters are discouraged from use but they still render, so they are reported without a
108/// removal fix: only the author knows what the text should say instead.
109pub fn is_deprecated_char(c: char) -> bool {
110    let cp = c as u32;
111    matches!(
112        cp,
113        0x0149 // LATIN SMALL LETTER N PRECEDED BY APOSTROPHE
114            | 0x0673 // ARABIC LETTER ALEF WITH WAVY HAMZA ABOVE
115            | 0x0F77 // TIBETAN VOWEL SIGN VOCALIC LL
116            | 0x0F79 // TIBETAN VOWEL SIGN VOCALIC LR
117            | 0x17A3..=0x17A4 // KHMER INHERENT VOWEL SIGN AA..KHMER INHERENT VOWEL SIGN AE
118            | 0x206A..=0x206F // INHIBIT SYMMETRIC SWAPPING..NOMINAL DIGIT SHAPES
119            | 0x2329 // LEFT-POINTING ANGLE BRACKET
120            | 0x232A // RIGHT-POINTING ANGLE BRACKET
121            | 0xE0001 // LANGUAGE TAG
122    )
123}
124
125/// The rows of UTR#20 table 3.1 that neither of the sets above already covers:
126/// visible or structural code points a markup document is meant to express with
127/// markup instead. They are not default-ignorable, so removing one would drop
128/// content or leave a paired construct half-open, and only the two tone marks
129/// have a replacement that preserves the text exactly.
130pub fn is_unsuitable_for_markup_char(c: char) -> bool {
131    let cp = c as u32;
132    matches!(
133        cp,
134        0x0340 // COMBINING GRAVE TONE MARK
135            | 0x0341 // COMBINING ACUTE TONE MARK
136            | 0xFFF9..=0xFFFC // Interlinear annotation delimiters + OBJECT REPLACEMENT CHARACTER
137    )
138}