Skip to main content

mongol_convert/
router.rs

1//! Top-level routing. Port of `service/TranslateService.java` (no Spring DI).
2//!
3//! Hub-and-spoke through Zvvnmod: decode `from` to the hub (unless it is the hub), then encode the
4//! hub to `to` (unless it is the hub). Short-circuit exactly as Java: identity or blank input is
5//! returned unchanged. Oyun stays Unsupported in both directions. UTN #57 goes both ways, handed
6//! to the pure-Rust `zvvnmod-utn57` crate in process rather than through the letter/shape tables.
7
8use 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/// Something a conversion did beyond what its input said, including optional heuristic repairs.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum Warning {
27    /// The UTN #57 encoder spelled a hub run with a ZWJ the hub did not carry.
28    ///
29    /// The hub is positional, and a run that begins or ends with a joined-form glyph — a medial at
30    /// the start, a medial or initial at the end — can only be joined to nothing by inventing a
31    /// joiner. When the source meant a whole word, that is a gap in the hub's inventory: the glyph
32    /// the source needed in that position does not exist there, the way `G i O f` did not until
33    /// `E096` (Satsrag/mongol-convert#32). The reason is the encoder's own message and names the run's
34    /// codes, so the missing glyph can be read off it.
35    Utn57(String),
36    /// An opt-in heuristic replaced a space before a recognised suffix with NNBSP.
37    /// The offset is a UTF-8 byte offset in the original input, before any repairs or conversion.
38    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/// A finished conversion: the text, and whatever the conversion had to do beyond what the input
55/// said. See [`translate_with_warnings`].
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Translation {
58    /// The converted text, after any explicitly enabled input repairs.
59    pub text: String,
60    /// Input repairs in source order, followed by conversion warnings.
61    pub warnings: Vec<Warning>,
62}
63
64/// Optional processing before conversion. Default settings preserve the input behavior of
65/// [`translate`].
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct TranslationOptions {
68    /// Replace single spaces/NBSP before a small allowlist of detached suffixes with NNBSP.
69    /// Supported for MenkLetter and Delehi sources, including same-encoding conversions.
70    /// This is a heuristic: it neither analyses grammar nor splits concatenated words. Each
71    /// replacement is reported as [`Warning::RepairedSuffixSeparator`].
72    pub repair_suffix_separators: bool,
73    /// Restore legacy SoftBank/iOS emoji back to MenkShape PUA before decoding MenkShape input.
74    /// This is enabled by default because chat apps can rewrite MenkShape PUA into modern emoji.
75    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
87/// Convert with optional input repair. Repairs run before source decoding, even when `from == to`.
88/// With default options this is identical to [`translate_with_warnings`].
89pub 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
115/// Convert `input` from one Mongolian encoding to another. UTF-8 in/out.
116///
117/// The text-only form of [`translate_with_warnings`]: a conversion that had to go beyond its input
118/// still succeeds here, silently.
119pub fn translate(from: CodeType, to: CodeType, input: &str) -> Result<String, MongolConvertError> {
120    translate_with_warnings(from, to, input).map(|translation| translation.text)
121}
122
123/// Convert `input` from one Mongolian encoding to another, and say what the conversion had to do
124/// beyond what the input said. UTF-8 in/out.
125///
126/// The text is the one [`translate`] returns. The warnings are [`Warning`]s; today only the
127/// UTN #57 target raises any, for a hub run it could spell only with an invented ZWJ.
128pub 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
161/// The nirugu, as the Zvvnmod hub spells it.
162///
163/// ZVVNMOD's own inventory has a code for it and the UTN #57 crate uses that one, so the hub does
164/// too: it is the hub's own spelling, the way every other hub code is. The legacy tables were
165/// dumped from Java, which knew the nirugu only as Unicode's `U+180A` — MenkShape maps that to
166/// `E23E`, the Unicode encodings keep it as it is — so each side is handed the spelling it knows,
167/// the way the suffix boundary already is below. Left untranslated, `E0E5` reached MenkShape
168/// output and rendered as a missing glyph (Satsrag/mongol-convert#29).
169const HUB_NIRUGU: &str = "\u{E0E5}";
170
171/// The nirugu as the legacy tables and the Unicode encodings spell it.
172const UNICODE_NIRUGU: &str = "\u{180A}";
173
174/// The hub's word-initial G + O-final ligature, `E096` (`G i O f`).
175///
176/// ZVVNMOD's font never had this glyph. Every other bowed consonant carries both a word-initial
177/// and a medial ligature with a final O; G has only the medial `G m O f`, `E09C`, so Menksoft —
178/// and the Java tables dumped from it — wrote that at the start of a word too. The hub is
179/// positional and the UTN #57 encoder reads it that way: a medial glyph with nothing to its left
180/// can only be spelled with an invented ZWJ (Satsrag/mongol-convert#32). So the hub promotes a
181/// word-initial `E09C` to `E096`, which `zvvnmod-utn57` knows as `G_O_ISOL`, and demotes it again
182/// for the legacy tables, whose ink for `E09C` is exactly the initial ligature's.
183const HUB_G_O_ISOL: char = '\u{E096}';
184
185/// The medial ligature the legacy tables spell the whole word with.
186const LEGACY_G_O_FINA: char = '\u{E09C}';
187
188/// Promote every `E09C` that starts a word to `E096`.
189///
190/// "Starts a word" is what the UTN #57 encoder will see: nothing to its left that joins — no hub
191/// shape, no nirugu, no ZWJ. FVS marks and the legacy controls `E140..=E144` are transparent, as
192/// they are for the encoder, which drops the latter and passes the former through.
193fn 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    // The Java set does not know the hub's own codes, E0E5 and E096.
220    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        // The written-unit spelling of a UTN #57 text: read it as that text.
245        let utn57 = utn57_shape::decode(s)?;
246        return translate_from(CodeType::Utn57, &utn57, options);
247    }
248    if ct == CodeType::Utn57 {
249        // Already hub-spelled: the UTN #57 crate reads and writes E0E5 itself.
250        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        // Menksoft's punctuation gap is spacing, not content: it comes back out before the text
255        // reaches the hub, so both sides agree on what the word is.
256        CodeSeries::Shape => {
257            // The punctuation gap is spacing, not content: it comes back out before the text
258            // reaches the hub, so both sides agree on what the word is.
259            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        // The UTN #57 conversion, then its shape; the warnings are that conversion's.
279        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    // A hub written by hand, or by an older release, may still spell the nirugu the Unicode way.
286    // Both readings are accepted; only the hub spelling is ever produced.
287    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    // The legacy tables predate E096; they write its ink for E09C.
301    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        // The shape encodings have no NNBSP of their own: they spell a suffix boundary with an
306        // ordinary space and always have, so the hub's connector is flattened back for them.
307        CodeSeries::Shape => {
308            let flattened = legacy.replace(WORD_CONNECTOR, " ");
309            let shaped = ShapeTranslator::new(shape_to_rule(ct)?).translate(&flattened)?;
310            // The shape encodings' punctuation glyphs have no side bearing of their own, so the
311            // gap is written in with the space each of them uses.
312            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            // Joined on the left: a hub shape, the nirugu either way, a ZWJ.
341            ("\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            // FVS and the legacy controls are transparent: the shape before them still joins.
346            ("\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            // Already promoted, or something else entirely.
350            ("\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}