1use regex::Regex;
38use std::sync::OnceLock;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum Mode {
43 None,
45 Basic,
47 #[default]
49 English,
50 Ocr,
60}
61
62#[must_use]
64pub fn normalize(text: &str, mode: Mode) -> String {
65 match mode {
66 Mode::None => text.to_string(),
67 Mode::Basic => basic(text),
68 Mode::English => english(text),
69 Mode::Ocr => collapse_whitespace(text),
70 }
71}
72
73fn basic(text: &str) -> String {
75 let s = text.to_lowercase();
76 let s = re(r"[<\[][^>\]]*[>\]]").replace_all(&s, "");
77 let s = re(r"\(([^)]+?)\)").replace_all(&s, "");
78 let s = strip_symbols(&s, "");
79 collapse_whitespace(&s)
80}
81
82fn english(text: &str) -> String {
84 let s = text.to_lowercase();
85 let s = re(r"[<\[][^>\]]*[>\]]").replace_all(&s, "").into_owned();
86 let s = re(r"\(([^)]+?)\)").replace_all(&s, "").into_owned();
87 let s = re(r"\b(hmm|mm|mhm|mmm|uh|um)\b")
89 .replace_all(&s, "")
90 .into_owned();
91 let s = re(r"\s+'").replace_all(&s, "'").into_owned();
93
94 let mut s = s;
95 for (pattern, replacement) in replacers() {
96 s = pattern.replace_all(&s, *replacement).into_owned();
97 }
98
99 let s = re(r"(\d),(\d)").replace_all(&s, "${1}${2}").into_owned();
101 let s = re(r"\.([^0-9]|$)").replace_all(&s, " ${1}").into_owned();
103 let s = strip_symbols(&s, ".%$¢€£");
105 let s = words_to_digits(&s);
106 let s = re(r"[.$¢€£]([^0-9])").replace_all(&s, " ${1}").into_owned();
108 let s = re(r"([^0-9])%").replace_all(&s, "${1} ").into_owned();
109 collapse_whitespace(&s)
110}
111
112fn strip_symbols(text: &str, keep: &str) -> String {
115 text.chars()
116 .map(|c| {
117 if c.is_alphanumeric() || c.is_whitespace() || keep.contains(c) {
118 c
119 } else {
120 ' '
121 }
122 })
123 .collect()
124}
125
126fn collapse_whitespace(text: &str) -> String {
127 text.split_whitespace().collect::<Vec<_>>().join(" ")
128}
129
130fn re(pattern: &str) -> Regex {
131 Regex::new(pattern).expect("static pattern")
135}
136
137fn replacers() -> &'static [(Regex, &'static str)] {
139 static TABLE: OnceLock<Vec<(Regex, &'static str)>> = OnceLock::new();
140 TABLE.get_or_init(|| {
141 [
142 (r"\bwon't\b", "will not"),
144 (r"\bcan't\b", "can not"),
145 (r"\blet's\b", "let us"),
146 (r"\bain't\b", "aint"),
147 (r"\by'all\b", "you all"),
148 (r"\bwanna\b", "want to"),
149 (r"\bgotta\b", "got to"),
150 (r"\bgonna\b", "going to"),
151 (r"\bi'ma\b", "i am going to"),
152 (r"\bimma\b", "i am going to"),
153 (r"\bwoulda\b", "would have"),
154 (r"\bcoulda\b", "could have"),
155 (r"\bshoulda\b", "should have"),
156 (r"\bma'am\b", "madam"),
157 (r"\bmr\b", "mister "),
159 (r"\bmrs\b", "missus "),
160 (r"\bst\b", "saint "),
161 (r"\bdr\b", "doctor "),
162 (r"\bprof\b", "professor "),
163 (r"\bcapt\b", "captain "),
164 (r"\bgov\b", "governor "),
165 (r"\bald\b", "alderman "),
166 (r"\bgen\b", "general "),
167 (r"\bsen\b", "senator "),
168 (r"\brep\b", "representative "),
169 (r"\bpres\b", "president "),
170 (r"\brev\b", "reverend "),
171 (r"\bhon\b", "honorable "),
172 (r"\basst\b", "assistant "),
173 (r"\bassoc\b", "associate "),
174 (r"\blt\b", "lieutenant "),
175 (r"\bcol\b", "colonel "),
176 (r"\bjr\b", "junior "),
177 (r"\bsr\b", "senior "),
178 (r"\besq\b", "esquire "),
179 (r"'d been\b", " had been"),
181 (r"'s been\b", " has been"),
182 (r"'d gone\b", " had gone"),
183 (r"'s gone\b", " has gone"),
184 (r"'d done\b", " had done"),
185 (r"'s got\b", " has got"),
186 (r"n't\b", " not"),
188 (r"'re\b", " are"),
189 (r"'s\b", " is"),
190 (r"'d\b", " would"),
191 (r"'ll\b", " will"),
192 (r"'t\b", " not"),
193 (r"'ve\b", " have"),
194 (r"'m\b", " am"),
195 ]
196 .into_iter()
197 .map(|(p, r)| (Regex::new(p).expect("static pattern"), r))
198 .collect()
199 })
200}
201
202fn unit_value(word: &str) -> Option<u64> {
203 Some(match word {
204 "zero" => 0,
205 "one" => 1,
206 "two" => 2,
207 "three" => 3,
208 "four" => 4,
209 "five" => 5,
210 "six" => 6,
211 "seven" => 7,
212 "eight" => 8,
213 "nine" => 9,
214 "ten" => 10,
215 "eleven" => 11,
216 "twelve" => 12,
217 "thirteen" => 13,
218 "fourteen" => 14,
219 "fifteen" => 15,
220 "sixteen" => 16,
221 "seventeen" => 17,
222 "eighteen" => 18,
223 "nineteen" => 19,
224 _ => return None,
225 })
226}
227
228fn tens_value(word: &str) -> Option<u64> {
229 Some(match word {
230 "twenty" => 20,
231 "thirty" => 30,
232 "forty" => 40,
233 "fourty" => 40, "fifty" => 50,
235 "sixty" => 60,
236 "seventy" => 70,
237 "eighty" => 80,
238 "ninety" => 90,
239 _ => return None,
240 })
241}
242
243fn scale_value(word: &str) -> Option<u64> {
244 Some(match word {
245 "thousand" => 1_000,
246 "million" => 1_000_000,
247 "billion" => 1_000_000_000,
248 "trillion" => 1_000_000_000_000,
249 _ => return None,
250 })
251}
252
253fn ordinal_value(word: &str) -> Option<(u64, &'static str)> {
255 let v = match word {
256 "first" => 1,
257 "second" => 2,
258 "third" => 3,
259 "fourth" => 4,
260 "fifth" => 5,
261 "sixth" => 6,
262 "seventh" => 7,
263 "eighth" => 8,
264 "ninth" => 9,
265 "tenth" => 10,
266 "eleventh" => 11,
267 "twelfth" => 12,
268 "thirteenth" => 13,
269 "fourteenth" => 14,
270 "fifteenth" => 15,
271 "sixteenth" => 16,
272 "seventeenth" => 17,
273 "eighteenth" => 18,
274 "nineteenth" => 19,
275 "twentieth" => 20,
276 "thirtieth" => 30,
277 "fortieth" => 40,
278 "fiftieth" => 50,
279 "sixtieth" => 60,
280 "seventieth" => 70,
281 "eightieth" => 80,
282 "ninetieth" => 90,
283 "hundredth" => 100,
284 "thousandth" => 1000,
285 _ => return None,
286 };
287 Some((v, ordinal_suffix(v)))
288}
289
290fn ordinal_suffix(v: u64) -> &'static str {
291 match (v % 100, v % 10) {
292 (11..=13, _) => "th",
293 (_, 1) => "st",
294 (_, 2) => "nd",
295 (_, 3) => "rd",
296 _ => "th",
297 }
298}
299
300fn words_to_digits(text: &str) -> String {
308 #[derive(Default)]
311 struct Acc {
312 total: u64,
313 part: u64,
314 active: bool,
315 }
316
317 impl Acc {
318 fn value(&self) -> u64 {
319 self.total + self.part
320 }
321
322 fn flush(&mut self, out: &mut Vec<String>) {
324 if self.active {
325 out.push(self.value().to_string());
326 *self = Self::default();
327 }
328 }
329 }
330
331 let tokens: Vec<&str> = text.split_whitespace().collect();
332 let mut out: Vec<String> = Vec::with_capacity(tokens.len());
333 let mut acc = Acc::default();
334
335 for (i, token) in tokens.iter().enumerate() {
336 let token = *token;
337 if let Some(v) = unit_value(token) {
338 if acc.active && acc.part % 10 != 0 {
341 acc.flush(&mut out);
342 }
343 acc.part += v;
344 acc.active = true;
345 } else if let Some(v) = tens_value(token) {
346 if acc.active && acc.part != 0 {
347 acc.flush(&mut out);
348 }
349 acc.part += v;
350 acc.active = true;
351 } else if token == "hundred" && acc.active {
352 acc.part = acc.part.max(1) * 100;
353 } else if let Some(scale) = scale_value(token) {
354 if acc.active {
355 acc.total += acc.part.max(1) * scale;
356 acc.part = 0;
357 } else {
358 out.push(token.to_string());
359 }
360 } else if token == "and"
361 && acc.active
362 && tokens
363 .get(i + 1)
364 .is_some_and(|n| unit_value(n).is_some() || tens_value(n).is_some())
365 {
366 } else if let Some((v, _)) = ordinal_value(token) {
368 let value = if !acc.active {
371 v
372 } else if v >= 100 {
373 acc.total + acc.part.max(1) * v
375 } else if acc.part % 10 == 0 {
376 acc.value() + v
377 } else {
378 out.push(acc.value().to_string());
381 v
382 };
383 acc = Acc::default();
384 out.push(format!("{value}{}", ordinal_suffix(value)));
385 } else {
386 acc.flush(&mut out);
387 out.push(token.to_string());
388 }
389 }
390 acc.flush(&mut out);
391 out.join(" ")
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 fn en(s: &str) -> String {
399 english(s)
400 }
401
402 #[test]
403 fn librispeech_reference_and_whisper_output_converge() {
404 let reference = "MISTER QUILTER IS THE APOSTLE OF THE MIDDLE CLASSES AND WE ARE GLAD TO WELCOME HIS GOSPEL";
407 let hypothesis = "Mr. Quilter is the apostle of the middle classes, and we are glad to welcome his gospel.";
408 assert_eq!(en(reference), en(hypothesis));
409 }
410
411 #[test]
412 fn titles_expand() {
413 assert_eq!(
414 en("Dr. Smith and Mrs. Jones"),
415 "doctor smith and missus jones"
416 );
417 }
418
419 #[test]
420 fn contractions_expand_consistently() {
421 assert_eq!(en("it's"), en("it is"));
422 assert_eq!(en("won't"), "will not");
423 assert_eq!(en("they've"), "they have");
424 }
425
426 #[test]
427 fn fillers_are_dropped() {
428 assert_eq!(en("um so uh yes"), "so yes");
429 }
430
431 #[test]
432 fn spelled_numbers_match_digits() {
433 assert_eq!(en("twenty three"), en("23"));
434 assert_eq!(en("one hundred and five"), en("105"));
435 assert_eq!(en("two thousand"), en("2000"));
436 assert_eq!(en("twenty three thousand four hundred"), en("23400"));
437 }
438
439 #[test]
440 fn year_pairs_split_rather_than_summing() {
441 assert_eq!(en("eighteen seventy six"), "18 76");
443 assert_eq!(en("six six"), "6 6");
444 }
445
446 #[test]
447 fn ordinals_become_suffixed_digits() {
448 assert_eq!(en("the first day"), "the 1st day");
449 assert_eq!(en("the twentieth"), "the 20th");
450 assert_eq!(en("the twenty third"), "the 23rd");
452 assert_eq!(en("one hundredth"), "100th");
453 assert_eq!(en("the twenty third of may"), en("the 23rd of may"));
454 }
455
456 #[test]
457 fn numbers_survive_surrounding_words() {
458 assert_eq!(
459 en("he had twenty three apples and left"),
460 "he had 23 apples and left"
461 );
462 }
463
464 #[test]
465 fn scale_word_alone_is_left_as_a_word() {
466 assert_eq!(en("thousands of people"), "thousands of people");
467 assert_eq!(en("million"), "million");
468 }
469
470 #[test]
471 fn digit_commas_and_trailing_periods() {
472 assert_eq!(en("1,234 apples."), "1234 apples");
473 }
474
475 #[test]
476 fn basic_mode_is_language_agnostic() {
477 assert_eq!(basic("Hello, [noise] World! (aside)"), "hello world");
478 }
479
480 #[test]
481 fn normalization_is_idempotent() {
482 let once = en("Mr. Smith had twenty three apples, and he won't share.");
483 assert_eq!(en(&once), once);
484 }
485}