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}