1use crate::code_type::{CodeSeries, CodeType};
9use crate::dispatch::{letter_from_rule, letter_to_rule, shape_from_rule, shape_to_rule};
10use crate::error::MongolConvertError;
11use crate::letter::from_translator::LetterFromTranslator;
12use crate::letter::rule::WORD_CONNECTOR;
13use crate::letter::to_translator::LetterToTranslator;
14use crate::shape::punctuation_gap;
15use crate::shape::softbank_emoji;
16use crate::shape::translator::ShapeTranslator;
17use crate::strings;
18use crate::unicode::zvvnmod::is_zvvnmod_code;
19use crate::utn57_shape;
20use std::borrow::Cow;
21use std::fmt;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum Warning {
27 Utn57(String),
36 RepairedSuffixSeparator { byte_offset: usize, original: char },
39}
40
41impl fmt::Display for Warning {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Warning::Utn57(reason) => write!(f, "UTN #57: {reason}"),
45 Warning::RepairedSuffixSeparator { byte_offset, original } => write!(
46 f,
47 "repaired possible suffix separator at input byte {byte_offset}: U+{:04X} -> U+202F",
48 *original as u32
49 ),
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Translation {
58 pub text: String,
60 pub warnings: Vec<Warning>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct TranslationOptions {
68 pub repair_suffix_separators: bool,
73 pub restore_menk_shape_emoji: bool,
76}
77
78impl Default for TranslationOptions {
79 fn default() -> Self {
80 Self {
81 repair_suffix_separators: false,
82 restore_menk_shape_emoji: true,
83 }
84 }
85}
86
87pub fn translate_with_options(
90 from: CodeType,
91 to: CodeType,
92 input: &str,
93 options: &TranslationOptions,
94) -> Result<Translation, MongolConvertError> {
95 let (input, mut warnings) = if options.repair_suffix_separators {
96 crate::repair::suffix_separators(from, input)?
97 } else {
98 (Cow::Borrowed(input), Vec::new())
99 };
100 let mut result = translate_inner(from, to, &input, options)?;
101 warnings.append(&mut result.warnings);
102 result.warnings = warnings;
103 Ok(result)
104}
105
106impl Translation {
107 fn plain(text: String) -> Self {
108 Self {
109 text,
110 warnings: Vec::new(),
111 }
112 }
113}
114
115pub fn translate(from: CodeType, to: CodeType, input: &str) -> Result<String, MongolConvertError> {
120 translate_with_warnings(from, to, input).map(|translation| translation.text)
121}
122
123pub fn translate_with_warnings(
129 from: CodeType,
130 to: CodeType,
131 input: &str,
132) -> Result<Translation, MongolConvertError> {
133 translate_inner(from, to, input, &TranslationOptions::default())
134}
135
136fn translate_inner(
137 from: CodeType,
138 to: CodeType,
139 input: &str,
140 options: &TranslationOptions,
141) -> Result<Translation, MongolConvertError> {
142 if strings::is_blank(input) {
143 return Ok(Translation::plain(input.to_string()));
144 }
145 if from == to {
146 return Ok(Translation::plain(
147 normalize_menk_shape_source(from, input, options).into_owned(),
148 ));
149 }
150 let hub = if from == CodeType::Zvvnmod {
151 input.to_string()
152 } else {
153 translate_from(from, input, options)?
154 };
155 if to == CodeType::Zvvnmod {
156 return Ok(Translation::plain(hub));
157 }
158 translate_to(to, &hub)
159}
160
161const HUB_NIRUGU: &str = "\u{E0E5}";
170
171const UNICODE_NIRUGU: &str = "\u{180A}";
173
174const HUB_G_O_ISOL: char = '\u{E096}';
184
185const LEGACY_G_O_FINA: char = '\u{E09C}';
187
188fn promote_word_initial_g_o(hub: &str) -> String {
194 let chars: Vec<char> = hub.chars().collect();
195 let mut out = String::with_capacity(hub.len());
196 for (index, &c) in chars.iter().enumerate() {
197 if c == LEGACY_G_O_FINA && !joined_on_the_left(&chars[..index]) {
198 out.push(HUB_G_O_ISOL);
199 } else {
200 out.push(c);
201 }
202 }
203 out
204}
205
206fn joined_on_the_left(before: &[char]) -> bool {
207 before
208 .iter()
209 .rev()
210 .find(|c| !is_transparent_mark(**c))
211 .is_some_and(|c| joins_to_the_right(*c))
212}
213
214fn is_transparent_mark(c: char) -> bool {
215 matches!(c, '\u{180B}'..='\u{180D}' | '\u{E140}'..='\u{E144}')
216}
217
218fn joins_to_the_right(c: char) -> bool {
219 is_zvvnmod_code(c) || matches!(c, HUB_G_O_ISOL | '\u{E0E5}' | '\u{180A}' | '\u{200D}')
221}
222
223fn normalize_menk_shape_source<'a>(
224 ct: CodeType,
225 s: &'a str,
226 options: &TranslationOptions,
227) -> Cow<'a, str> {
228 if ct == CodeType::MenkShape && options.restore_menk_shape_emoji {
229 softbank_emoji::restore_menk_shape(s)
230 } else {
231 Cow::Borrowed(s)
232 }
233}
234
235fn translate_from(
236 ct: CodeType,
237 s: &str,
238 options: &TranslationOptions,
239) -> Result<String, MongolConvertError> {
240 if ct == CodeType::Oyun {
241 return Err(MongolConvertError::Unsupported(ct));
242 }
243 if ct == CodeType::Utn57Shape {
244 let utn57 = utn57_shape::decode(s)?;
246 return translate_from(CodeType::Utn57, &utn57, options);
247 }
248 if ct == CodeType::Utn57 {
249 return zvvnmod_utn57::convert_utn57_to_zvvnmod(s)
251 .map_err(|error| MongolConvertError::Utn57(error.to_string()));
252 }
253 let hub = match ct.code_series() {
254 CodeSeries::Shape => {
257 let source = normalize_menk_shape_source(ct, s, options);
260 let plain = match punctuation_gap::of(ct) {
261 Some(gap) => gap.strip(&source),
262 None => source.into_owned(),
263 };
264 ShapeTranslator::new(shape_from_rule(ct)?).translate(&plain)?
265 }
266 CodeSeries::Letter => LetterFromTranslator::new(letter_from_rule(ct)?).translate(s)?,
267 };
268 Ok(promote_word_initial_g_o(
269 &hub.replace(UNICODE_NIRUGU, HUB_NIRUGU),
270 ))
271}
272
273fn translate_to(ct: CodeType, s: &str) -> Result<Translation, MongolConvertError> {
274 if ct == CodeType::Oyun {
275 return Err(MongolConvertError::Unsupported(ct));
276 }
277 if ct == CodeType::Utn57Shape {
278 let utn57 = translate_to(CodeType::Utn57, s)?;
280 return Ok(Translation {
281 text: utn57_shape::encode(&utn57.text)?,
282 warnings: utn57.warnings,
283 });
284 }
285 let hub = s.replace(UNICODE_NIRUGU, HUB_NIRUGU);
288 if ct == CodeType::Utn57 {
289 let conversion = zvvnmod_utn57::convert_zvvnmod_to_utn57_with_warnings(&hub)
290 .map_err(|error| MongolConvertError::Utn57(error.to_string()))?;
291 return Ok(Translation {
292 text: conversion.text,
293 warnings: conversion
294 .warnings
295 .iter()
296 .map(|warning| Warning::Utn57(warning.to_string()))
297 .collect(),
298 });
299 }
300 let legacy = hub
302 .replace(HUB_NIRUGU, UNICODE_NIRUGU)
303 .replace(HUB_G_O_ISOL, &LEGACY_G_O_FINA.to_string());
304 let text = match ct.code_series() {
305 CodeSeries::Shape => {
308 let flattened = legacy.replace(WORD_CONNECTOR, " ");
309 let shaped = ShapeTranslator::new(shape_to_rule(ct)?).translate(&flattened)?;
310 match punctuation_gap::of(ct) {
313 Some(gap) => gap.insert(&shaped),
314 None => shaped,
315 }
316 }
317 CodeSeries::Letter => LetterToTranslator::new(letter_to_rule(ct)?).translate(&legacy)?,
318 };
319 Ok(Translation::plain(text))
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn the_hub_g_o_isol_is_the_utn57_crate_inventory_code() {
328 assert_eq!(u32::from(HUB_G_O_ISOL), zvvnmod_utn57::G_O_ISOL.0);
329 assert_eq!(u32::from(LEGACY_G_O_FINA), zvvnmod_utn57::G_O_FINA.0);
330 }
331
332 #[test]
333 fn only_a_word_initial_g_o_is_promoted() {
334 let cases = [
335 ("\u{E09C}", "\u{E096}"),
336 (" \u{E09C} ", " \u{E096} "),
337 ("\u{1802}\u{E09C}", "\u{1802}\u{E096}"),
338 ("\u{202F}\u{E09C}", "\u{202F}\u{E096}"),
339 ("\u{E00C}\u{202F}\u{E09C}", "\u{E00C}\u{202F}\u{E096}"),
340 ("\u{E000}\u{E005}\u{E09C}", "\u{E000}\u{E005}\u{E09C}"),
342 ("\u{E0E5}\u{E09C}", "\u{E0E5}\u{E09C}"),
343 ("\u{180A}\u{E09C}", "\u{180A}\u{E09C}"),
344 ("\u{200D}\u{E09C}", "\u{200D}\u{E09C}"),
345 ("\u{E006}\u{E140}\u{E09C}", "\u{E006}\u{E140}\u{E09C}"),
347 ("\u{E006}\u{180B}\u{E09C}", "\u{E006}\u{180B}\u{E09C}"),
348 ("\u{180B}\u{E09C}", "\u{180B}\u{E096}"),
349 ("\u{E096}", "\u{E096}"),
351 ("\u{E093}", "\u{E093}"),
352 ];
353 for (hub, expected) in cases {
354 assert_eq!(promote_word_initial_g_o(hub), expected, "{hub:?}");
355 }
356 }
357
358 #[test]
359 fn the_hub_nirugu_is_the_utn57_crate_inventory_code() {
360 let hub = HUB_NIRUGU.chars().next().unwrap();
361 assert_eq!(u32::from(hub), zvvnmod_utn57::NIRUGU.0);
362 assert_eq!(UNICODE_NIRUGU, "\u{180A}");
363 }
364}