Skip to main content

dom_content_extraction/
unicode.rs

1// src/unicode.rs
2use unicode_normalization::UnicodeNormalization;
3use unicode_segmentation::UnicodeSegmentation;
4
5/// Counts the number of Unicode grapheme clusters (user-perceived characters) in a string
6///
7/// # Arguments
8///
9/// * `text` - The text to count characters in
10///
11/// # Returns
12///
13/// The number of grapheme clusters in the text
14///
15/// # Examples
16///
17/// ```
18/// use dom_content_extraction::unicode::count_graphemes;
19///
20/// let text = "Hello, 世界!";
21/// assert_eq!(count_graphemes(text), 10); // Not 13 bytes or 11 code points
22/// ```
23#[inline]
24pub fn count_graphemes(text: &str) -> u32 {
25    UnicodeSegmentation::graphemes(text, true).count() as u32
26}
27
28/// Counts the number of Unicode code points in a string
29///
30/// # Arguments
31///
32/// * `text` - The text to count code points in
33///
34/// # Returns
35///
36/// The number of Unicode code points in the text
37///
38/// # Examples
39///
40/// ```
41/// use dom_content_extraction::unicode::count_code_points;
42///
43/// let text = "café"; // 4 letters, 5 bytes in UTF-8
44/// assert_eq!(count_code_points(text), 4); // Not 5 bytes
45/// ```
46#[inline]
47pub fn count_code_points(text: &str) -> u32 {
48    text.chars().count() as u32
49}
50
51/// Normalizes text using Unicode NFC normalization
52/// and trims excess whitespace
53///
54/// # Arguments
55///
56/// * `text` - The text to normalize
57///
58/// # Returns
59///
60/// A normalized string
61///
62/// # Examples
63///
64/// ```
65/// use dom_content_extraction::unicode::normalize_text;
66///
67/// let text = "  café   \n  résumé  ";
68/// assert_eq!(normalize_text(text), "café résumé");
69/// ```
70pub fn normalize_text(text: &str) -> String {
71    // Perform NFC normalization
72    let normalized = text.nfc().collect::<String>();
73
74    // Normalize whitespace
75    normalized
76        .split_whitespace()
77        .collect::<Vec<&str>>()
78        .join(" ")
79}
80
81/// Joins multiple text fragments with proper whitespace handling
82///
83/// # Arguments
84///
85/// * `fragments` - Vector of text fragments to join
86///
87/// # Returns
88///
89/// A single string with normalized whitespace between fragments
90///
91/// # Examples
92///
93/// ```
94/// use dom_content_extraction::unicode::join_text_fragments;
95///
96/// let fragments = vec!["Hello".to_string(), "世界".to_string(), "!".to_string()];
97/// assert_eq!(join_text_fragments(fragments), "Hello 世界 !");
98/// ```
99pub fn join_text_fragments(fragments: Vec<String>) -> String {
100    let joined = fragments.join(" ");
101    normalize_text(&joined)
102}
103
104/// Detects the probable script of text based on the most common script
105///
106/// This is a simple heuristic approach that can be useful for adjusting
107/// text density calculations based on script properties
108///
109/// # Arguments
110///
111/// * `text` - The text to analyze
112///
113/// # Returns
114///
115/// A string representing the most common script in the text
116///
117/// # Examples
118///
119/// ```
120/// use dom_content_extraction::unicode::detect_primary_script;
121///
122/// assert_eq!(detect_primary_script("Hello world"), "Latin");
123/// assert_eq!(detect_primary_script("こんにちは世界"), "Han");
124/// ```
125pub fn detect_primary_script(text: &str) -> &'static str {
126    // This is a simplified implementation
127    // A production version would use the unicode_script crate
128    // or implement the full Unicode Script detection algorithm
129
130    let latin_chars = text
131        .chars()
132        .filter(|c| c.is_ascii() || matches!(c, 'À'..='ÿ'))
133        .count();
134    let cjk_chars = text
135        .chars()
136        .filter(|c| matches!(c, '\u{3000}'..='\u{9FFF}'))
137        .count();
138    let cyrillic_chars = text
139        .chars()
140        .filter(|c| matches!(c, '\u{0400}'..='\u{04FF}'))
141        .count();
142
143    if cjk_chars > latin_chars && cjk_chars > cyrillic_chars {
144        "Han"
145    } else if cyrillic_chars > latin_chars && cyrillic_chars > cjk_chars {
146        "Cyrillic"
147    } else {
148        "Latin"
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_count_graphemes() {
158        assert_eq!(count_graphemes("hello"), 5);
159        assert_eq!(count_graphemes("café"), 4);
160        assert_eq!(count_graphemes("こんにちは"), 5);
161        // Grapheme with multiple code points (emoji with skin tone modifier)
162        assert_eq!(count_graphemes("👩‍💻"), 1);
163    }
164
165    #[test]
166    fn test_count_code_points() {
167        assert_eq!(count_code_points("hello"), 5);
168        assert_eq!(count_code_points("café"), 4);
169        assert_eq!(count_code_points("こんにちは"), 5);
170        // Multiple code points for a single grapheme
171        assert_eq!(count_code_points("\u{1F469}\u{200D}\u{1F4BB}"), 3); // Woman technologist emoji
172    }
173
174    #[test]
175    fn test_normalize_text() {
176        // Combining characters
177        assert_eq!(normalize_text("café"), "café");
178        // Different representations of same character
179        let nfd = "cafe\u{0301}"; // café with combining acute
180        assert_eq!(normalize_text(nfd), "café");
181        // Whitespace normalization
182        assert_eq!(normalize_text("  hello  world  "), "hello world");
183        assert_eq!(normalize_text("hello\n\t world"), "hello world");
184    }
185
186    #[test]
187    fn test_join_text_fragments() {
188        let fragments =
189            vec!["Hello".to_string(), "world".to_string(), "!".to_string()];
190        assert_eq!(join_text_fragments(fragments), "Hello world !");
191
192        let fragments = vec![
193            "  Text  ".to_string(),
194            " with ".to_string(),
195            "  extra  ".to_string(),
196            " spaces ".to_string(),
197        ];
198        assert_eq!(join_text_fragments(fragments), "Text with extra spaces");
199    }
200
201    #[test]
202    fn test_detect_primary_script() {
203        assert_eq!(detect_primary_script("Hello world"), "Latin");
204        assert_eq!(detect_primary_script("Привет мир"), "Cyrillic");
205        assert_eq!(detect_primary_script("こんにちは世界"), "Han");
206        // Mixed script with Latin dominant
207        assert_eq!(detect_primary_script("Hello 世界 and more Latin"), "Latin");
208    }
209}