use std::borrow::Cow;
use crate::{
runner::aligner::normalizer::{NormalizationError, NormalizedText, TextNormalizer},
types::Lang,
};
#[derive(Clone, Copy, Debug)]
struct LatinRules {
splits_clitic_apostrophe: bool,
}
impl LatinRules {
fn for_lang(lang: &Lang) -> Self {
match lang {
Lang::Fr | Lang::It => Self {
splits_clitic_apostrophe: true,
},
_ => Self {
splits_clitic_apostrophe: false,
},
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct LatinNormalizer {
rules: LatinRules,
}
impl LatinNormalizer {
pub fn new(lang: Lang) -> Self {
Self {
rules: LatinRules::for_lang(&lang),
}
}
pub const fn english() -> Self {
Self {
rules: LatinRules {
splits_clitic_apostrophe: false,
},
}
}
}
fn is_word_punct(c: char) -> bool {
matches!(
c,
'.' | ','
| '!'
| '?'
| ';'
| ':'
| '"'
| '\'' | '('
| ')'
| '['
| ']'
| '{'
| '}'
| '-'
| '\u{2014}' | '\u{2013}' | '\u{201C}' | '\u{201D}' | '\u{2018}' | '\u{2019}' | '\u{00BF}' | '\u{00A1}' )
}
fn strip_word_punct(s: &str) -> &str {
let trimmed_left = s.trim_start_matches(is_word_punct);
trimmed_left.trim_end_matches(is_word_punct)
}
fn is_internal_separator(c: char) -> bool {
matches!(
c,
'-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '\u{2015}' )
}
fn is_clitic_apostrophe(c: char) -> bool {
matches!(c, '\'' | '\u{2019}')
}
fn lowercase_for_match(s: &str) -> String {
s.to_lowercase()
}
fn split_at_clitic_apostrophes(token: &str) -> Vec<(String, usize)> {
let mut pieces: Vec<(String, usize)> = Vec::new();
let mut current_start: usize = 0;
let chars: Vec<(usize, char)> = token.char_indices().collect();
let mut i = 0usize;
while i < chars.len() {
let (byte_idx, ch) = chars[i];
if is_clitic_apostrophe(ch) {
let after_apos = byte_idx + ch.len_utf8();
let left = &token[current_start..after_apos];
pieces.push((String::from(left), current_start));
current_start = after_apos;
}
i += 1;
}
if current_start < token.len() {
pieces.push((String::from(&token[current_start..]), current_start));
} else if pieces.is_empty() {
pieces.push((String::new(), 0));
}
pieces
}
impl TextNormalizer for LatinNormalizer {
fn normalize<'a>(&self, text: &'a str) -> Result<NormalizedText<'a>, NormalizationError> {
let mut normalized = String::with_capacity(text.len());
let mut original_words: Vec<Cow<'a, str>> = Vec::new();
let mut wildcards_per_word: Vec<crate::runner::aligner::normalizer::WildcardBoundary> =
Vec::new();
for (token_start, raw_token) in token_spans(text) {
let token_pieces: Vec<(String, usize, bool)> =
if self.rules.splits_clitic_apostrophe && raw_token.chars().any(is_clitic_apostrophe) {
let split = split_at_clitic_apostrophes(raw_token);
let last_idx = split.len().saturating_sub(1);
split
.into_iter()
.enumerate()
.filter(|(_, (p, _))| !p.is_empty())
.map(|(i, (p, off))| {
let is_clitic_left = i != last_idx;
(p, off, is_clitic_left)
})
.collect()
} else {
vec![(String::from(raw_token), 0usize, false)]
};
for (sub_token, sub_offset, is_clitic_left) in &token_pieces {
let sub_start = token_start + sub_offset;
let sub_len = sub_token.len();
let stripped = if *is_clitic_left {
sub_token.trim_start_matches(is_word_punct)
} else {
strip_word_punct(sub_token)
};
if stripped.is_empty() {
continue;
}
let original_slice: &'a str = &text[sub_start..sub_start + sub_len];
let trimmed_left = sub_token.trim_start_matches(is_word_punct);
let prefix_stripped: u32 =
(sub_token.chars().count() - trimmed_left.chars().count()) as u32;
let suffix_stripped: u32 = if *is_clitic_left {
0
} else {
(trimmed_left.chars().count() - stripped.chars().count()) as u32
};
if stripped.contains(is_internal_separator) {
let pieces: Vec<&str> = stripped
.split(is_internal_separator)
.filter(|p| !p.is_empty())
.collect();
let last_idx = pieces.len().saturating_sub(1);
for (pi, piece_orig) in pieces.iter().enumerate() {
let piece_lower = lowercase_for_match(piece_orig);
if !normalized.is_empty() {
normalized.push(' ');
}
normalized.push_str(&piece_lower);
original_words.push(Cow::Owned(String::from(*piece_orig)));
let prefix = if pi == 0 { prefix_stripped } else { 0 };
let suffix = if pi == last_idx { suffix_stripped } else { 0 };
wildcards_per_word.push(crate::runner::aligner::normalizer::WildcardBoundary::new(
prefix, suffix,
));
}
} else {
let lower = lowercase_for_match(stripped);
if !normalized.is_empty() {
normalized.push(' ');
}
normalized.push_str(&lower);
original_words.push(Cow::Borrowed(original_slice));
wildcards_per_word.push(crate::runner::aligner::normalizer::WildcardBoundary::new(
prefix_stripped,
suffix_stripped,
));
}
}
}
if original_words.is_empty() {
return Err(NormalizationError::EmptyText);
}
Ok(NormalizedText::with_wildcards(
normalized,
original_words,
wildcards_per_word,
))
}
}
fn token_spans(text: &str) -> impl Iterator<Item = (usize, &str)> + '_ {
let mut iter = text.split_whitespace();
core::iter::from_fn(move || {
let token = iter.next()?;
let token_start = (token.as_ptr() as usize).saturating_sub(text.as_ptr() as usize);
Some((token_start, token))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn en() -> LatinNormalizer {
LatinNormalizer::new(Lang::En)
}
#[test]
fn en_lowercase_and_strip_punct() {
let nt = en().normalize("Hello, World!").unwrap();
assert_eq!(nt.normalized(), "hello world");
assert_eq!(nt.original_words().len(), 2);
assert_eq!(nt.original_words()[0], "Hello,");
assert_eq!(nt.original_words()[1], "World!");
}
#[test]
fn en_contraction_stays_one_word_with_apostrophe_inline() {
let nt = en().normalize("Don't go.").unwrap();
assert_eq!(nt.normalized(), "don't go");
assert_eq!(nt.original_words().len(), 2);
assert_eq!(nt.original_words()[0], "Don't");
assert_eq!(nt.original_words()[1], "go.");
}
#[test]
fn en_em_dash_splits_into_per_piece_surface_spans() {
let nt = en().normalize("hello\u{2014}world").unwrap();
assert_eq!(nt.normalized(), "hello world");
assert_eq!(nt.original_words().len(), 2);
assert_eq!(nt.original_words()[0], "hello");
assert_eq!(nt.original_words()[1], "world");
}
#[test]
fn en_hyphen_compound_splits_into_per_piece_surface_spans() {
let nt = en().normalize("Hello-World").unwrap();
assert_eq!(nt.normalized(), "hello world");
assert_eq!(nt.original_words().len(), 2);
assert_eq!(nt.original_words()[0], "Hello");
assert_eq!(nt.original_words()[1], "World");
}
#[test]
fn en_slash_alternation_splits_into_per_piece_surface_spans() {
let nt = en().normalize("and/or").unwrap();
assert_eq!(nt.normalized(), "and or");
assert_eq!(nt.original_words().len(), 2);
assert_eq!(nt.original_words()[0], "and");
assert_eq!(nt.original_words()[1], "or");
}
#[test]
fn en_empty_input_errors() {
let err = en().normalize(" .,!? ").unwrap_err();
assert!(matches!(err, NormalizationError::EmptyText));
}
#[test]
fn en_casing_preserved_in_original_words() {
let nt = en().normalize("The Quick BROWN Fox.").unwrap();
assert_eq!(nt.normalized(), "the quick brown fox");
assert_eq!(nt.original_words()[1], "Quick");
assert_eq!(nt.original_words()[2], "BROWN");
assert_eq!(nt.original_words()[3], "Fox.");
}
#[test]
fn en_contraction_inside_sentence_stays_intact() {
let nt = en().normalize("I won't be late.").unwrap();
assert_eq!(nt.normalized(), "i won't be late");
assert_eq!(nt.original_words().len(), 4);
assert_eq!(nt.original_words()[0], "I");
assert_eq!(nt.original_words()[1], "won't");
assert_eq!(nt.original_words()[2], "be");
assert_eq!(nt.original_words()[3], "late.");
}
#[test]
fn en_apostrophe_word_passes_through_lowercased() {
let nt = en().normalize("O'Brien rocks.").unwrap();
assert_eq!(nt.normalized(), "o'brien rocks");
}
#[test]
fn en_boundary_ascii_apostrophes_are_stripped() {
let nt = en().normalize("'hello'").unwrap();
assert_eq!(nt.normalized(), "hello");
assert_eq!(nt.original_words()[0], "'hello'");
}
#[test]
fn en_boundary_apostrophe_around_contraction_keeps_internal() {
let nt = en().normalize("'don't'").unwrap();
assert_eq!(nt.normalized(), "don't");
assert_eq!(nt.original_words().len(), 1);
assert_eq!(nt.original_words()[0], "'don't'");
}
#[test]
fn en_trailing_possessive_apostrophe_strips() {
let nt = en().normalize("the dogs'.").unwrap();
assert_eq!(nt.normalized(), "the dogs");
}
#[test]
fn en_uses_word_delimiter() {
assert!(en().use_word_delimiter());
}
#[test]
fn en_em_dash_and_slash_split() {
let nt = en()
.normalize("two\u{2014}three and/or four\u{2013}five")
.unwrap();
assert_eq!(nt.normalized(), "two three and or four five");
assert_eq!(nt.original_words().len(), 6);
}
#[test]
fn en_pure_separator_token_is_dropped() {
let nt = en().normalize("hello --- world").unwrap();
assert_eq!(nt.normalized(), "hello world");
assert_eq!(nt.original_words().len(), 2);
}
#[test]
fn en_collapses_consecutive_internal_separators() {
let nt = en().normalize("foo\u{2014}/-bar").unwrap();
assert_eq!(nt.normalized(), "foo bar");
assert_eq!(nt.original_words().len(), 2);
}
#[test]
fn es_strips_inverted_question_and_exclamation() {
let n = LatinNormalizer::new(Lang::Es);
let nt = n.normalize("¿Cómo estás? ¡Hola!").unwrap();
assert_eq!(nt.normalized(), "cómo estás hola");
assert_eq!(nt.original_words().len(), 3);
assert_eq!(nt.original_words()[0], "¿Cómo");
assert_eq!(nt.original_words()[1], "estás?");
assert_eq!(nt.original_words()[2], "¡Hola!");
}
#[test]
fn es_preserves_enye() {
let n = LatinNormalizer::new(Lang::Es);
let nt = n.normalize("España niño").unwrap();
assert_eq!(nt.normalized(), "españa niño");
}
#[test]
fn de_preserves_umlauts_and_sharp_s() {
let n = LatinNormalizer::new(Lang::De);
let nt = n.normalize("Mädchen Größe heißt Tür").unwrap();
assert_eq!(nt.normalized(), "mädchen größe heißt tür");
}
#[test]
fn de_lowercases_capital_umlauts() {
let n = LatinNormalizer::new(Lang::De);
let nt = n.normalize("ÄRGER ÖFTER ÜBER").unwrap();
assert_eq!(nt.normalized(), "ärger öfter über");
}
#[test]
fn fr_splits_clitic_apostrophe() {
let n = LatinNormalizer::new(Lang::Fr);
let nt = n.normalize("l'eau d'argent").unwrap();
assert_eq!(nt.normalized(), "l' eau d' argent");
assert_eq!(nt.original_words().len(), 4);
assert_eq!(nt.original_words()[0], "l'");
assert_eq!(nt.original_words()[1], "eau");
assert_eq!(nt.original_words()[2], "d'");
assert_eq!(nt.original_words()[3], "argent");
}
#[test]
fn fr_preserves_accented_vowels_and_cedilla() {
let n = LatinNormalizer::new(Lang::Fr);
let nt = n.normalize("Café à côté ça va.").unwrap();
assert_eq!(nt.normalized(), "café à côté ça va");
}
#[test]
fn fr_preserves_casing_in_original_words() {
let n = LatinNormalizer::new(Lang::Fr);
let nt = n.normalize("L'Hôtel est ouvert.").unwrap();
assert_eq!(nt.normalized(), "l' hôtel est ouvert");
assert_eq!(nt.original_words()[0], "L'");
assert_eq!(nt.original_words()[1], "Hôtel");
}
#[test]
fn it_splits_clitic_apostrophe() {
let n = LatinNormalizer::new(Lang::It);
let nt = n.normalize("dell'arte un'altra").unwrap();
assert_eq!(nt.normalized(), "dell' arte un' altra");
assert_eq!(nt.original_words().len(), 4);
assert_eq!(nt.original_words()[0], "dell'");
assert_eq!(nt.original_words()[1], "arte");
}
#[test]
fn it_preserves_accented_vowels() {
let n = LatinNormalizer::new(Lang::It);
let nt = n.normalize("Città però così già più").unwrap();
assert_eq!(nt.normalized(), "città però così già più");
}
#[test]
fn pt_preserves_cedilla_and_tilde_vowels() {
let n = LatinNormalizer::new(Lang::Pt);
let nt = n.normalize("Coração não são informação").unwrap();
assert_eq!(nt.normalized(), "coração não são informação");
}
#[test]
fn pt_lowercases_accented_vowels() {
let n = LatinNormalizer::new(Lang::Pt);
let nt = n.normalize("Á É Í Ó Ú À").unwrap();
assert_eq!(nt.normalized(), "á é í ó ú à");
}
#[test]
fn pt_apostrophe_does_not_split() {
let n = LatinNormalizer::new(Lang::Pt);
let nt = n.normalize("d'água").unwrap();
assert_eq!(nt.normalized(), "d'água");
assert_eq!(nt.original_words().len(), 1);
}
#[test]
fn all_latin_use_word_delimiter() {
for lang in [Lang::En, Lang::Es, Lang::Fr, Lang::De, Lang::It, Lang::Pt] {
let n = LatinNormalizer::new(lang.clone());
assert!(
n.use_word_delimiter(),
"Latin-script normaliser must enable | word delimiter for {lang:?}"
);
}
}
}