1use std::collections::{HashMap, HashSet};
16
17use crate::generated::enums::WrittenUnit;
18use crate::shaper::Shaper;
19use crate::tables::{Fvs, NormalizeData, Position, UnitEntry};
20use crate::unicode::is_mongolian_word_char;
21use crate::Error;
22
23const MAX_KEY_LEN: usize = 3;
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28struct UnitKey {
29 len: u8,
30 units: [WrittenUnit; MAX_KEY_LEN],
31}
32
33impl UnitKey {
34 fn new(units: &[WrittenUnit]) -> UnitKey {
35 debug_assert!(!units.is_empty() && units.len() <= MAX_KEY_LEN);
36 let mut padded = [units[0]; MAX_KEY_LEN];
39 padded[..units.len()].copy_from_slice(units);
40 UnitKey {
41 len: units.len() as u8,
42 units: padded,
43 }
44 }
45}
46
47type Encoding = (u32, Option<Fvs>);
49
50pub(crate) struct NormalizeTable {
52 pub canonical_version: &'static str,
53 max_len: usize,
54 table: HashMap<(Position, UnitKey), Encoding>,
55 feminine: HashMap<(Position, UnitKey), Encoding>,
56 velar_fem_units: HashSet<WrittenUnit>,
57 masculine_cps: HashSet<u32>,
58 pub known_units: HashSet<WrittenUnit>,
61 pub sorted_vocabulary: Vec<&'static str>,
64 pub positioned_units: HashSet<(WrittenUnit, Position)>,
66}
67
68fn sorted_vocabulary(units: &HashSet<WrittenUnit>) -> Vec<&'static str> {
70 let mut names: Vec<&'static str> = units.iter().map(|unit| unit.as_str()).collect();
71 names.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
72 names
73}
74
75fn index_entries(entries: &'static [UnitEntry]) -> HashMap<(Position, UnitKey), Encoding> {
76 entries
77 .iter()
78 .map(|entry| {
79 (
80 (entry.position, UnitKey::new(entry.units)),
81 (entry.cp, entry.fvs),
82 )
83 })
84 .collect()
85}
86
87impl NormalizeTable {
88 pub fn new(data: &'static NormalizeData) -> NormalizeTable {
89 let mut known_units: HashSet<WrittenUnit> = data
90 .unit_table
91 .iter()
92 .flat_map(|entry| entry.units.iter().copied())
93 .collect();
94 known_units.extend([WrittenUnit::Mvs, WrittenUnit::Nirugu, WrittenUnit::Zwj]);
95 let sorted_vocabulary = sorted_vocabulary(&known_units);
96 NormalizeTable {
97 canonical_version: data.canonical_version,
98 max_len: data.unit_enc_max_len,
99 table: index_entries(data.unit_table),
100 feminine: index_entries(data.velar_fem),
101 velar_fem_units: data.velar_fem_units.iter().copied().collect(),
102 masculine_cps: data.masc_to_fem.iter().map(|(masc, _)| *masc).collect(),
103 known_units,
104 sorted_vocabulary,
105 positioned_units: data.positioned_units.iter().copied().collect(),
106 }
107 }
108
109 #[cfg(any(test, feature = "testing"))]
112 pub fn empty(canonical_version: &'static str) -> NormalizeTable {
113 let known_units: HashSet<WrittenUnit> =
114 [WrittenUnit::Mvs, WrittenUnit::Nirugu, WrittenUnit::Zwj]
115 .into_iter()
116 .collect();
117 NormalizeTable {
118 canonical_version,
119 max_len: 1,
120 table: HashMap::new(),
121 feminine: HashMap::new(),
122 velar_fem_units: HashSet::new(),
123 masculine_cps: HashSet::new(),
124 known_units: known_units.clone(),
125 sorted_vocabulary: sorted_vocabulary(&known_units),
126 positioned_units: HashSet::new(),
127 }
128 }
129
130 fn get(&self, position: Position, units: &[WrittenUnit]) -> Option<Encoding> {
131 self.table.get(&(position, UnitKey::new(units))).copied()
132 }
133
134 fn get_feminine(&self, position: Position, units: &[WrittenUnit]) -> Option<Encoding> {
135 self.feminine.get(&(position, UnitKey::new(units))).copied()
136 }
137}
138
139pub(crate) fn structural_char(unit: WrittenUnit) -> Option<char> {
141 match unit {
142 WrittenUnit::Mvs => Some('\u{180E}'),
143 WrittenUnit::Nirugu => Some('\u{180A}'),
144 WrittenUnit::Zwj => Some('\u{200D}'),
145 _ => None,
146 }
147}
148
149pub(crate) fn is_joiner(unit: WrittenUnit) -> bool {
151 matches!(unit, WrittenUnit::Nirugu | WrittenUnit::Zwj)
152}
153
154fn structural_text(units: &[WrittenUnit]) -> String {
155 units
156 .iter()
157 .map(|unit| structural_char(*unit).expect("structural token"))
158 .collect()
159}
160
161enum Part {
162 Structural(WrittenUnit),
163 Chain(Vec<WrittenUnit>),
164}
165
166fn split_parts(shape: &[WrittenUnit]) -> Vec<Part> {
168 let mut parts = Vec::new();
169 let mut chain = Vec::new();
170 for &unit in shape {
171 if unit.is_structural() {
172 if !chain.is_empty() {
173 parts.push(Part::Chain(std::mem::take(&mut chain)));
174 }
175 parts.push(Part::Structural(unit));
176 } else {
177 chain.push(unit);
178 }
179 }
180 if !chain.is_empty() {
181 parts.push(Part::Chain(chain));
182 }
183 parts
184}
185
186pub(crate) fn slot_position(start: usize, length: usize, unit_count: usize) -> Position {
188 if start == 0 && start + length == unit_count {
189 Position::Isol
190 } else if start == 0 {
191 Position::Init
192 } else if start + length == unit_count {
193 Position::Fina
194 } else {
195 Position::Medi
196 }
197}
198
199fn letter_position(letter_index: usize, total: usize) -> Position {
201 if total == 1 {
202 Position::Isol
203 } else if letter_index == 0 {
204 Position::Init
205 } else if letter_index == total - 1 {
206 Position::Fina
207 } else {
208 Position::Medi
209 }
210}
211
212fn unit_partition(
216 table: &NormalizeTable,
217 chain: &[WrittenUnit],
218 joined_left: bool,
219 joined_right: bool,
220) -> Option<String> {
221 let unit_count = chain.len();
222 let pad_left = usize::from(joined_left);
223 let pad_right = usize::from(joined_right);
224 let padded_count = unit_count + pad_left + pad_right;
225 let mut letters: Vec<Encoding> = Vec::new();
226 let mut unit_at: Vec<Option<WrittenUnit>> = Vec::new();
227 let mut index = 0;
228 while index < unit_count {
229 let span = table.max_len.min(unit_count - index);
230 let mut hit: Option<(Encoding, usize)> = None;
231 let position = slot_position(index + pad_left, 1, padded_count);
233 if let Some(encoding) = table.get(position, &chain[index..index + 1]) {
234 hit = Some((encoding, 1));
235 }
236 if hit.is_none() {
238 for length in (2..=span).rev() {
239 let position = slot_position(index + pad_left, length, padded_count);
240 if let Some(encoding) = table.get(position, &chain[index..index + length]) {
241 hit = Some((encoding, length));
242 break;
243 }
244 }
245 }
246 let (encoding, length) = hit?;
247 letters.push(encoding);
248 unit_at.push((length == 1).then_some(chain[index]));
249 index += length;
250 }
251 apply_velar_fem(table, &mut letters, &unit_at, pad_left, pad_right);
252 let mut text = String::new();
253 for (cp, fvs) in letters {
254 text.push(char::from_u32(cp).expect("table code points are scalar values"));
255 if let Some(fvs) = fvs {
256 text.push(fvs.as_char());
257 }
258 }
259 Some(text)
260}
261
262fn apply_velar_fem(
266 table: &NormalizeTable,
267 letters: &mut [Encoding],
268 unit_at: &[Option<WrittenUnit>],
269 pad_left: usize,
270 pad_right: usize,
271) {
272 let total = letters.len();
276 let padded_total = total + pad_left + pad_right;
277 for (letter_index, unit) in unit_at.iter().enumerate() {
278 let Some(unit) = *unit else {
279 continue;
280 };
281 if !table.velar_fem_units.contains(&unit) {
282 continue;
283 }
284 let position = letter_position(letter_index + pad_left, padded_total);
291 if !matches!(position, Position::Init | Position::Medi) {
292 continue;
293 }
294 let target_index = letter_index + 1;
295 if target_index >= total {
296 continue;
297 }
298 let Some(target_unit) = unit_at[target_index] else {
299 continue; };
301 let (cp, _) = letters[target_index];
302 if !table.masculine_cps.contains(&cp) {
303 continue; }
305 let target_position = letter_position(target_index + pad_left, padded_total);
306 let Some(feminine) = table.get_feminine(target_position, &[target_unit]) else {
307 continue; };
309 letters[target_index] = feminine;
310 }
311}
312
313impl Shaper {
314 pub(crate) fn table(&self) -> &NormalizeTable {
316 &self.normalize
317 }
318
319 pub fn canonical_version(&self) -> Option<&'static str> {
323 Some(self.normalize.canonical_version)
324 }
325
326 pub(crate) fn canonical_for_shape(&self, shape: &[WrittenUnit]) -> Result<String, Error> {
338 let parts = split_parts(shape);
339 let mut suffix_text = String::new();
343 let mut suffix_target: Vec<WrittenUnit> = Vec::new();
344 for index in (0..parts.len()).rev() {
345 match &parts[index] {
346 Part::Structural(unit) => {
347 let text = structural_char(*unit)
348 .expect("structural token")
349 .to_string();
350 suffix_text.insert_str(0, &text);
351 suffix_target.insert(0, *unit);
352 }
353 Part::Chain(body) => {
354 let table = self.table();
355 let mut prefix_tokens: Vec<WrittenUnit> = Vec::new();
359 let mut scan = index;
360 while scan > 0 {
361 scan -= 1;
362 match &parts[scan] {
363 Part::Structural(unit) => prefix_tokens.insert(0, *unit),
364 Part::Chain(_) => break,
365 }
366 }
367 let mut chain_canonical: Option<String> = None;
368 if prefix_tokens.last() == Some(&WrittenUnit::Mvs) {
371 let candidate = if body.as_slice() == [WrittenUnit::Aa] {
372 String::from('\u{1820}')
373 } else {
374 self.encode_chain_canonical(table, body, &[], "", &[])?
375 };
376 if !candidate.is_empty() {
377 let prefix_text = structural_text(&prefix_tokens);
378 let mut want = prefix_tokens.clone();
379 want.extend_from_slice(body);
380 want.extend_from_slice(&suffix_target);
381 if self.shape(&format!("{prefix_text}{candidate}{suffix_text}"))?
382 == self.without_redundant_zwj(&want)
383 {
384 chain_canonical = Some(candidate);
385 }
386 }
387 }
388 let chain_canonical = match chain_canonical {
389 Some(text) => text,
390 None => self.encode_chain_canonical(
391 table,
392 body,
393 &prefix_tokens,
394 &suffix_text,
395 &suffix_target,
396 )?,
397 };
398 suffix_text.insert_str(0, &chain_canonical);
399 let mut target = body.clone();
400 target.extend_from_slice(&suffix_target);
401 suffix_target = target;
402 }
403 }
404 }
405 Ok(suffix_text)
406 }
407
408 fn encode_chain_canonical(
411 &self,
412 table: &NormalizeTable,
413 chain: &[WrittenUnit],
414 prefix_tokens: &[WrittenUnit],
415 suffix_text: &str,
416 suffix_target: &[WrittenUnit],
417 ) -> Result<String, Error> {
418 Ok(self
419 .unit_encode_chain(table, chain, prefix_tokens, suffix_text, suffix_target)?
420 .unwrap_or_default())
421 }
422
423 fn unit_encode_chain(
426 &self,
427 table: &NormalizeTable,
428 chain: &[WrittenUnit],
429 prefix_tokens: &[WrittenUnit],
430 suffix_text: &str,
431 suffix_target: &[WrittenUnit],
432 ) -> Result<Option<String>, Error> {
433 let joined_left = prefix_tokens.last().is_some_and(|unit| is_joiner(*unit));
434 let joined_right = suffix_target.first().is_some_and(|unit| is_joiner(*unit));
435 let Some(text) = unit_partition(table, chain, joined_left, joined_right) else {
436 return Ok(None);
437 };
438 let prefix_text = structural_text(prefix_tokens);
439 let mut verify_target = prefix_tokens.to_vec();
447 verify_target.extend_from_slice(chain);
448 verify_target.extend_from_slice(suffix_target);
449 if self.shape(&format!("{prefix_text}{text}{suffix_text}"))?
450 == self.without_redundant_zwj(&verify_target)
451 {
452 Ok(Some(text))
453 } else {
454 Ok(None)
455 }
456 }
457
458 fn normalize_impl(&self, text: &str, strict: bool) -> Result<String, Error> {
459 if text.is_empty() {
460 return Ok(String::new());
461 }
462 let target = self.shape(text)?;
463 if target.is_empty() {
464 return Ok(String::new());
467 }
468 let canonical = self.canonical_for_shape(&target)?;
469 if canonical.is_empty() || self.shape(&canonical)? != target {
470 if strict {
471 return Err(Error::NormalizationFallback {
472 text: text.to_owned(),
473 written_units: target,
474 });
475 }
476 return Ok(text.to_owned());
477 }
478 Ok(canonical)
479 }
480
481 pub fn normalize(&self, text: &str) -> Result<String, Error> {
489 self.normalize_impl(text, true)
490 }
491
492 pub fn normalize_allow_fallback(&self, text: &str) -> Result<String, Error> {
495 self.normalize_impl(text, false)
496 }
497
498 fn normalize_text_impl(&self, text: &str, strict: bool) -> Result<String, Error> {
499 if text.is_empty() {
500 return Ok(String::new());
501 }
502 let mut out = String::with_capacity(text.len());
503 let mut run = String::new();
504 let mut run_is_mongolian: Option<bool> = None;
505 for ch in text.chars() {
506 let is_mongolian = is_mongolian_word_char(ch);
507 match run_is_mongolian {
508 Some(current) if current != is_mongolian => {
509 self.flush_run(&mut out, &run, current, strict)?;
510 run.clear();
511 run_is_mongolian = Some(is_mongolian);
512 }
513 Some(_) => {}
514 None => run_is_mongolian = Some(is_mongolian),
515 }
516 run.push(ch);
517 }
518 if let Some(current) = run_is_mongolian {
519 self.flush_run(&mut out, &run, current, strict)?;
520 }
521 Ok(out)
522 }
523
524 fn flush_run(
525 &self,
526 out: &mut String,
527 run: &str,
528 is_mongolian: bool,
529 strict: bool,
530 ) -> Result<(), Error> {
531 if is_mongolian {
532 out.push_str(&self.normalize_impl(run, strict)?);
533 } else {
534 out.push_str(run);
535 }
536 Ok(())
537 }
538
539 pub fn normalize_text(&self, text: &str) -> Result<String, Error> {
542 self.normalize_text_impl(text, true)
543 }
544
545 pub fn normalize_text_allow_fallback(&self, text: &str) -> Result<String, Error> {
547 self.normalize_text_impl(text, false)
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::Locale;
555
556 const SAIN: &str = "\u{1830}\u{1820}\u{1822}\u{1828}";
557
558 #[test]
559 fn strict_mode_raises_when_canonicalization_falls_back() {
560 let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
561 let error = shaper.normalize(SAIN).unwrap_err();
562 assert_eq!(
563 error,
564 Error::NormalizationFallback {
565 text: SAIN.to_owned(),
566 written_units: vec![
567 WrittenUnit::S,
568 WrittenUnit::A,
569 WrittenUnit::I,
570 WrittenUnit::I,
571 WrittenUnit::A
572 ],
573 }
574 );
575 assert_eq!(
576 error.to_string(),
577 "normalization fallback: no canonical encoding for written units S+A+I+I+A"
578 );
579 }
580
581 #[test]
582 fn allow_fallback_preserves_input_when_canonicalization_falls_back() {
583 let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
584 assert_eq!(shaper.normalize_allow_fallback(SAIN).unwrap(), SAIN);
585 }
586
587 #[test]
588 fn strict_mode_reports_a_fallback_inside_mixed_text() {
589 let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
590 let text = format!("Hello {SAIN} world");
591 assert!(matches!(
592 shaper.normalize_text(&text),
593 Err(Error::NormalizationFallback { .. })
594 ));
595 }
596
597 #[test]
598 fn allow_fallback_preserves_a_fallback_inside_mixed_text() {
599 let shaper = Shaper::with_empty_normalize_table(Locale::Mng);
600 let text = format!("Hello {SAIN} world");
601 assert_eq!(shaper.normalize_text_allow_fallback(&text).unwrap(), text);
602 }
603
604 #[test]
605 fn empty_and_structural_shapes_normalize_without_a_letter() {
606 let shaper = Shaper::new(Locale::Mng);
607 assert_eq!(shaper.canonical_version(), Some("mng-canonical/3"));
608 assert_eq!(shaper.normalize("").unwrap(), "");
609 assert_eq!(shaper.normalize("\u{180B}").unwrap(), ""); assert_eq!(shaper.normalize("\u{180A}").unwrap(), "\u{180A}"); }
612
613 #[test]
614 fn positions_of_partition_slots_and_letters() {
615 assert_eq!(slot_position(0, 1, 1), Position::Isol);
616 assert_eq!(slot_position(0, 2, 2), Position::Isol);
617 assert_eq!(slot_position(0, 1, 3), Position::Init);
618 assert_eq!(slot_position(1, 1, 3), Position::Medi);
619 assert_eq!(slot_position(1, 2, 3), Position::Fina);
620 assert_eq!(letter_position(0, 1), Position::Isol);
621 assert_eq!(letter_position(0, 2), Position::Init);
622 assert_eq!(letter_position(1, 2), Position::Fina);
623 assert_eq!(letter_position(1, 3), Position::Medi);
624 assert_eq!(
625 UnitKey::new(&[WrittenUnit::A]),
626 UnitKey::new(&[WrittenUnit::A])
627 );
628 assert_ne!(
629 UnitKey::new(&[WrittenUnit::A]),
630 UnitKey::new(&[WrittenUnit::A, WrittenUnit::A])
631 );
632 }
633}