Skip to main content

voirs_g2p/preprocessing/
unicode.rs

1//! Unicode normalization and text cleaning for G2P preprocessing.
2
3use crate::Result;
4use unicode_normalization::UnicodeNormalization;
5use unicode_segmentation::UnicodeSegmentation;
6
7/// Normalize text using Unicode NFC normalization
8pub fn normalize_text(text: &str) -> Result<String> {
9    // Normalize to NFC form for consistent processing
10    let normalized = text.nfc().collect::<String>();
11
12    // Additional cleaning steps
13    let cleaned = clean_text(&normalized)?;
14
15    Ok(cleaned)
16}
17
18/// Clean text by removing unwanted characters and fixing encoding issues
19fn clean_text(text: &str) -> Result<String> {
20    let mut result = String::new();
21
22    for grapheme in text.graphemes(true) {
23        match grapheme {
24            // Replace common problematic characters
25            "‚" | "'" => result.push('\''),
26            "\u{201c}" | "\u{201d}" => result.push('"'),
27            "—" | "–" => result.push('-'),
28            "…" => result.push_str("..."),
29
30            // Keep regular characters
31            g if is_valid_text_char(g) => result.push_str(g),
32
33            // Replace other characters with space
34            _ => result.push(' '),
35        }
36    }
37
38    // Collapse multiple spaces
39    Ok(collapse_spaces(&result))
40}
41
42/// Check if a grapheme is valid for text processing
43fn is_valid_text_char(grapheme: &str) -> bool {
44    if grapheme.len() == 1 {
45        let ch = grapheme.chars().next().expect("grapheme has len == 1");
46        ch.is_alphabetic()
47            || ch.is_numeric()
48            || ch.is_whitespace()
49            || matches!(
50                ch,
51                '.' | ','
52                    | '!'
53                    | '?'
54                    | ';'
55                    | ':'
56                    | '\''
57                    | '"'
58                    | '-'
59                    | '('
60                    | ')'
61                    | '['
62                    | ']'
63                    | '{'
64                    | '}'
65            )
66    } else {
67        // Multi-character graphemes (like accented characters)
68        grapheme
69            .chars()
70            .all(|c| c.is_alphabetic() || c.is_numeric())
71    }
72}
73
74/// Collapse multiple consecutive spaces into single spaces
75fn collapse_spaces(text: &str) -> String {
76    let mut result = String::new();
77    let mut last_was_space = false;
78
79    for ch in text.chars() {
80        if ch.is_whitespace() {
81            if !last_was_space {
82                result.push(' ');
83                last_was_space = true;
84            }
85        } else {
86            result.push(ch);
87            last_was_space = false;
88        }
89    }
90
91    result.trim().to_string()
92}
93
94/// Detect script type of text
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub enum ScriptType {
97    Latin,
98    Cyrillic,
99    Greek,
100    Arabic,
101    Hebrew,
102    CJK,
103    Hiragana,
104    Katakana,
105    Hangul,
106    Mixed,
107    Unknown,
108}
109
110/// Detect the primary script type of the text
111pub fn detect_script(text: &str) -> ScriptType {
112    let mut script_counts = std::collections::HashMap::new();
113    let mut total_chars = 0;
114
115    for ch in text.chars() {
116        if ch.is_alphabetic() {
117            total_chars += 1;
118
119            let script = match ch as u32 {
120                // Latin
121                0x0041..=0x007A | 0x00C0..=0x00FF | 0x0100..=0x017F => ScriptType::Latin,
122                // Cyrillic
123                0x0400..=0x04FF => ScriptType::Cyrillic,
124                // Greek
125                0x0370..=0x03FF => ScriptType::Greek,
126                // Arabic
127                0x0600..=0x06FF => ScriptType::Arabic,
128                // Hebrew
129                0x0590..=0x05FF => ScriptType::Hebrew,
130                // CJK
131                0x4E00..=0x9FFF => ScriptType::CJK,
132                // Hiragana
133                0x3040..=0x309F => ScriptType::Hiragana,
134                // Katakana
135                0x30A0..=0x30FF => ScriptType::Katakana,
136                // Hangul
137                0xAC00..=0xD7AF => ScriptType::Hangul,
138                _ => ScriptType::Unknown,
139            };
140
141            *script_counts.entry(script).or_insert(0) += 1;
142        }
143    }
144
145    if total_chars == 0 {
146        return ScriptType::Unknown;
147    }
148
149    // Check if it's mixed script
150    let is_mixed = script_counts.len() > 2;
151
152    let dominant_script = script_counts
153        .into_iter()
154        .max_by_key(|(_, count)| *count)
155        .map(|(script, _)| script)
156        .unwrap_or(ScriptType::Unknown);
157
158    if is_mixed {
159        ScriptType::Mixed
160    } else {
161        dominant_script
162    }
163}
164
165/// Filter out emoji and other symbols
166pub fn filter_symbols(text: &str) -> String {
167    text.chars()
168        .filter(|ch| {
169            // Keep letters, numbers, and basic punctuation
170            ch.is_alphabetic()
171                || ch.is_numeric()
172                || ch.is_whitespace()
173                || matches!(
174                    *ch,
175                    '.' | ','
176                        | '!'
177                        | '?'
178                        | ';'
179                        | ':'
180                        | '\''
181                        | '"'
182                        | '-'
183                        | '('
184                        | ')'
185                        | '['
186                        | ']'
187                        | '{'
188                        | '}'
189                )
190        })
191        .collect()
192}
193
194/// Detect if text contains RTL (right-to-left) characters
195pub fn is_rtl_text(text: &str) -> bool {
196    text.chars().any(|ch| {
197        matches!(ch as u32,
198            0x0590..=0x05FF | // Hebrew
199            0x0600..=0x06FF | // Arabic
200            0x0750..=0x077F | // Arabic Supplement
201            0x08A0..=0x08FF   // Arabic Extended-A
202        )
203    })
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_normalize_text() {
212        // Test NFC normalization
213        let text = "café"; // This might be composed differently
214        let result = normalize_text(text).unwrap();
215        assert!(!result.is_empty());
216
217        // Test with special characters
218        let text = "\u{201c}Hello World\u{201d} \u{2014} it's great!";
219        let result = normalize_text(text).unwrap();
220        assert_eq!(result, "\"Hello World\" - it's great!");
221    }
222
223    #[test]
224    fn test_collapse_spaces() {
225        assert_eq!(collapse_spaces("hello    world"), "hello world");
226        assert_eq!(collapse_spaces("  hello  world  "), "hello world");
227        assert_eq!(collapse_spaces("hello\n\t  world"), "hello world");
228    }
229
230    #[test]
231    fn test_script_detection() {
232        assert_eq!(detect_script("Hello World"), ScriptType::Latin);
233        assert_eq!(detect_script("こんにちは"), ScriptType::Hiragana);
234        assert_eq!(detect_script("カタカナ"), ScriptType::Katakana);
235        assert_eq!(detect_script("한글"), ScriptType::Hangul);
236        assert_eq!(detect_script("中文"), ScriptType::CJK);
237        assert_eq!(detect_script("Привет"), ScriptType::Cyrillic);
238    }
239
240    #[test]
241    fn test_filter_symbols() {
242        let text = "Hello 👋 World! 🌍";
243        let result = filter_symbols(text);
244        assert_eq!(result, "Hello  World! ");
245    }
246
247    #[test]
248    fn test_rtl_detection() {
249        assert!(is_rtl_text("שלום"));
250        assert!(is_rtl_text("مرحبا"));
251        assert!(!is_rtl_text("Hello"));
252    }
253
254    #[test]
255    fn test_valid_text_char() {
256        assert!(is_valid_text_char("a"));
257        assert!(is_valid_text_char("A"));
258        assert!(is_valid_text_char("1"));
259        assert!(is_valid_text_char(" "));
260        assert!(is_valid_text_char("."));
261        assert!(is_valid_text_char("é"));
262        assert!(!is_valid_text_char("👋"));
263    }
264}