use std::sync::LazyLock;
use regex::Regex;
use unicode_normalization::UnicodeNormalization as _;
static BLANK_LINES_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
pub(crate) fn normalize_text(input: &str) -> String {
let nfc: String = input.nfc().collect();
let unified = nfc.replace("\r\n", "\n").replace('\r', "\n");
let decoded = decode_entities(&unified);
let mut cleaned = String::with_capacity(decoded.len());
for ch in decoded.chars() {
match ch {
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{00AD}' | '\u{2060}' => {}
'\n' => cleaned.push('\n'),
'\t' => cleaned.push('\t'),
c if c.is_whitespace() => cleaned.push(' '),
c => cleaned.push(c),
}
}
let mut lines: Vec<String> = Vec::new();
for line in cleaned.split('\n') {
lines.push(collapse_line_ws(line));
}
let joined = lines.join("\n");
let collapsed = BLANK_LINES_RE.replace_all(&joined, "\n\n").into_owned();
collapsed
.trim_matches(|c: char| c == '\n' || c == ' ' || c == '\t')
.to_string()
}
fn collapse_line_ws(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut prev_space = false;
let mut prev_tab = false;
for ch in line.chars() {
match ch {
' ' => {
if !prev_space {
out.push(' ');
}
prev_space = true;
prev_tab = false;
}
'\t' => {
if !prev_tab {
out.push('\t');
}
prev_tab = true;
prev_space = false;
}
c => {
out.push(c);
prev_space = false;
prev_tab = false;
}
}
}
out.trim_matches(|c| c == ' ' || c == '\t').to_string()
}
fn decode_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < chars.len() {
if chars[i] != '&' {
out.push(chars[i]);
i += 1;
continue;
}
let end = (i + 1..chars.len().min(i + 12)).find(|&j| chars[j] == ';');
match end {
Some(j) => {
let entity: String = chars[i + 1..j].iter().collect();
if let Some(decoded) = decode_one_entity(&entity) {
out.push_str(&decoded);
i = j + 1;
} else {
out.push('&');
i += 1;
}
}
None => {
out.push('&');
i += 1;
}
}
}
out
}
fn decode_one_entity(body: &str) -> Option<String> {
if let Some(num) = body.strip_prefix('#') {
let code = if let Some(hex) = num.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok()?
} else {
num.parse::<u32>().ok()?
};
return char::from_u32(code).map(|c| c.to_string());
}
let c = match body {
"amp" => '&',
"lt" => '<',
"gt" => '>',
"quot" => '"',
"apos" => '\'',
"nbsp" => '\u{00A0}',
"auml" => 'ä',
"ouml" => 'ö',
"uuml" => 'ü',
"Auml" => 'Ä',
"Ouml" => 'Ö',
"Uuml" => 'Ü',
"szlig" => 'ß',
"eacute" => 'é',
"egrave" => 'è',
"agrave" => 'à',
"ccedil" => 'ç',
"euro" => '€',
"copy" => '©',
"reg" => '®',
"ndash" => '–',
"mdash" => '—',
_ => return None,
};
Some(c.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_zero_width_and_soft_hyphen() {
assert_eq!(normalize_text("Mül\u{00AD}ler\u{200B}"), "Müller");
}
#[test]
fn normalizes_nbsp_and_crlf_and_collapses_spaces() {
assert_eq!(
normalize_text("Hans\u{00A0}Müller\r\nSitz Berlin"),
"Hans Müller\nSitz Berlin"
);
}
#[test]
fn decodes_well_formed_entities_only() {
assert_eq!(
normalize_text("M\u{00FC}ller & S\u{00F6}hne"),
"Müller & Söhne"
);
assert_eq!(
normalize_text("über über über"),
"über über über"
);
assert_eq!(normalize_text("R&D Abteilung"), "R&D Abteilung");
}
#[test]
fn decodes_entities_exactly_once() {
assert_eq!(decode_entities("&#64;"), "@");
assert_eq!(decode_entities("&"), "&");
assert_eq!(decode_entities("@"), "@");
assert_eq!(
decode_entities("Müller & Söhne"),
"Müller & Söhne"
);
}
#[test]
fn preserves_tabs_and_collapses_blank_lines() {
assert_eq!(
normalize_text("Telefon\t030\n\n\n\nSitz"),
"Telefon\t030\n\nSitz"
);
}
#[test]
fn never_panics_on_multibyte_garbage() {
for s in [
"\u{200B}\u{00AD}",
"€\t\r\n",
"ä".repeat(50).as_str(),
"&#;&#x;&",
] {
let _ = normalize_text(s);
}
}
}