| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| use pyo3::prelude::*; |
| use rayon::prelude::*; |
| use regex::Regex; |
| use unicode_normalization::UnicodeNormalization; |
| use once_cell::sync::Lazy; |
|
|
| |
|
|
| |
| static HTML_TAG: Lazy<Regex> = Lazy::new(|| { |
| Regex::new(r"<[^>]{0,2000}>").expect("HTML_TAG regex is valid") |
| }); |
|
|
| |
| static HTML_ENTITY: Lazy<Regex> = Lazy::new(|| { |
| Regex::new(r"&(?:[a-zA-Z]{1,10}|#\d{1,6});").expect("HTML_ENTITY regex is valid") |
| }); |
|
|
| |
| static EXCESS_NEWLINES: Lazy<Regex> = Lazy::new(|| { |
| Regex::new(r"\n{3,}").expect("EXCESS_NEWLINES regex is valid") |
| }); |
|
|
| |
| static EXCESS_SPACES: Lazy<Regex> = Lazy::new(|| { |
| Regex::new(r"[ \t]{2,}").expect("EXCESS_SPACES regex is valid") |
| }); |
|
|
| |
| static CONTROL_CHARS: Lazy<Regex> = Lazy::new(|| { |
| Regex::new(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]").expect("CONTROL_CHARS regex is valid") |
| }); |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| fn clean_text_internal(text: &str) -> String { |
| |
| let input = if text.len() > 10_000_000 { |
| &text[..10_000_000] |
| } else { |
| text |
| }; |
|
|
| |
| let no_html = HTML_TAG.replace_all(input, " "); |
| let no_entities = HTML_ENTITY.replace_all(&no_html, " "); |
|
|
| |
| let normalized: String = no_entities.nfc().collect(); |
|
|
| |
| let no_ctrl = CONTROL_CHARS.replace_all(&normalized, ""); |
|
|
| |
| let single_spaces = EXCESS_SPACES.replace_all(&no_ctrl, " "); |
| let clean_newlines = EXCESS_NEWLINES.replace_all(&single_spaces, "\n\n"); |
|
|
| |
| clean_newlines.trim().to_string() |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| #[pyfunction] |
| pub fn clean_text(py: Python<'_>, text: String) -> String { |
| |
| py.allow_threads(|| clean_text_internal(&text)) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| #[pyfunction] |
| pub fn clean_batch(py: Python<'_>, texts: Vec<String>) -> Vec<String> { |
| py.allow_threads(|| { |
| texts.par_iter() |
| .map(|t| clean_text_internal(t)) |
| .collect() |
| }) |
| } |
|
|
| |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
|
|
| #[test] |
| fn test_strip_html() { |
| let result = clean_text_internal("<p>Hello <b>world</b></p>"); |
| assert!(!result.contains('<'), "HTML tags should be removed"); |
| assert!(result.contains("Hello"), "Text content preserved"); |
| } |
|
|
| #[test] |
| fn test_unicode_nfc() { |
| |
| let decomposed = "Cafe\u{0301}"; |
| let result = clean_text_internal(decomposed); |
| let expected = "Caf\u{00E9}"; |
| assert_eq!(result, expected, "Unicode should be NFC normalized"); |
| } |
|
|
| #[test] |
| fn test_collapse_whitespace() { |
| let result = clean_text_internal("hello world\n\n\n\nfoo"); |
| assert!(!result.contains(" "), "Excess spaces collapsed"); |
| assert!(!result.contains("\n\n\n"), "Excess newlines collapsed"); |
| } |
|
|
| #[test] |
| fn test_control_chars_removed() { |
| let result = clean_text_internal("hello\x00\x01\x07world"); |
| assert!(!result.contains('\x00'), "Null bytes removed"); |
| assert!(result.contains("helloworld"), "Text content preserved"); |
| } |
|
|
| #[test] |
| fn test_empty_string() { |
| assert_eq!(clean_text_internal(""), ""); |
| } |
|
|
| #[test] |
| fn test_large_input_bounded() { |
| |
| let large = "a".repeat(20_000_000); |
| let result = clean_text_internal(&large); |
| assert!(result.len() <= 10_000_000, "Large input is bounded safely"); |
| } |
| } |
|
|