mongol_norm/written_units.rs
1//! Written-unit input APIs — a port of `normalize_written_units`,
2//! `normalize_positioned_written_units` and `_parse_written_units` from `mongol_norm/shaper.py`.
3
4use crate::duplicates::collapse;
5use crate::generated::enums::WrittenUnit;
6use crate::normalize::{is_joiner, slot_position};
7use crate::shaper::Shaper;
8use crate::tables::{Position, UnitPosition};
9use crate::Error;
10
11/// One record of [`Shaper::normalize_positioned_written_units`]: a written unit with its
12/// authoritative HUD inventory position (`Control` for `Mvs` / `Nirugu`).
13///
14/// This is an *input* type: both fields are public and it is deliberately not
15/// `#[non_exhaustive]` (unlike the crate's result types), so callers can build records with a
16/// struct literal or [`PositionedWrittenUnit::new`] and destructure them freely.
17#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
18pub struct PositionedWrittenUnit {
19 /// The written unit.
20 pub unit: WrittenUnit,
21 /// Its HUD inventory position.
22 pub position: UnitPosition,
23}
24
25impl PositionedWrittenUnit {
26 /// Build a record.
27 pub const fn new(unit: WrittenUnit, position: UnitPosition) -> PositionedWrittenUnit {
28 PositionedWrittenUnit { unit, position }
29 }
30}
31
32/// The positioned API accepts at most this many records (Python parity).
33pub const MAX_POSITIONED_RECORDS: usize = 1024;
34
35enum PositionedPart {
36 Control(WrittenUnit),
37 Chain(Vec<(WrittenUnit, Position)>),
38}
39
40/// Does `part` cursively join the chain next to it? Only a joiner control does; a neighbouring
41/// chain never does (Python tests `parts[i][0] in _JOINER_TOKENS`, and a chain part's tag is the
42/// literal `"chain"`).
43fn is_joiner_part(part: &PositionedPart) -> bool {
44 matches!(part, PositionedPart::Control(unit) if is_joiner(*unit))
45}
46
47impl Shaper {
48 /// Encode an ordered written-unit sequence (e.g. the output of [`Shaper::shape`]) as
49 /// canonical Unicode. Letter positions are inferred from order and the structural tokens;
50 /// ZWJ is emitted only where `Zwj` is present in the request. The result is accepted only if
51 /// it reshapes to exactly the requested sequence.
52 ///
53 /// An empty sequence returns `""` — without consulting the table, so it succeeds on every
54 /// locale.
55 ///
56 /// # Errors
57 ///
58 /// - [`Error::NormalizeUnsupported`] — this locale has no bundled normalize table.
59 /// - [`Error::UnsupportedWrittenUnit`] — `units[index]` is outside the table's vocabulary
60 /// (for example the Todo unit `E` on an MNG shaper). The first offender is reported.
61 /// - [`Error::NoCanonicalEncoding`] — the table covers every unit, but the sequence has no
62 /// encoding that reshapes back to it.
63 pub fn normalize_written_units(&self, units: &[WrittenUnit]) -> Result<String, Error> {
64 if units.is_empty() {
65 return Ok(String::new());
66 }
67 let table = self.table()?;
68 for (index, unit) in units.iter().enumerate() {
69 if !table.known_units.contains(unit) {
70 return Err(Error::UnsupportedWrittenUnit { index, unit: *unit });
71 }
72 }
73 // Duplicate encodings are accepted on input — `Dd`, medial `H`/`Hx` are real units in
74 // the standard and in callers' data — and folded before encoding, so they get the same
75 // canonical text as the sequence they render identically to.
76 let units = collapse(units);
77 let canonical = self.canonical_for_shape(&units)?;
78 if canonical.is_empty() || self.shape(&canonical)? != units {
79 return Err(Error::NoCanonicalEncoding);
80 }
81 Ok(canonical)
82 }
83
84 /// Encode explicit HUD-position records as canonical Unicode (the API `zvvnmod-utn57`
85 /// uses). A complete multi-record chain runs `init…fina`; an incomplete edge gets an implicit
86 /// ZWJ; a single `init` record is encoded bare except `O:init`, which takes a trailing ZWJ;
87 /// single `medi` / `fina` records get the joining context their position needs. `Mvs` and
88 /// `Nirugu` require `Control`; explicit `Zwj` is rejected; at most
89 /// [`MAX_POSITIONED_RECORDS`] records.
90 ///
91 /// # Validation order
92 ///
93 /// Checks run in a fixed order, and it is observable — callers such as `zvvnmod-utn57`
94 /// dispatch on the variant, so the *first* failing check decides which one they see:
95 ///
96 /// 1. record limit — 2000 records containing a `Zwj` is [`Error::TooManyRecords`], not
97 /// [`Error::ExplicitZwj`], and an over-limit request on a table-less locale is
98 /// [`Error::TooManyRecords`], not [`Error::NormalizeUnsupported`];
99 /// 2. explicit `Zwj` anywhere in the request;
100 /// 3. empty request — returns `""` before the table is consulted, so it succeeds on every
101 /// locale;
102 /// 4. the normalize table;
103 /// 5. per record, the control/inventory check (`Mvs` and `Nirugu` need `Control`; every other
104 /// `(unit, position)` must be in the HUD inventory), reporting the first offender;
105 /// 6. per multi-record chain, that the declared positions match the padded chain;
106 /// 7. delegation to [`Shaper::normalize_written_units`].
107 ///
108 /// # Errors
109 ///
110 /// - [`Error::TooManyRecords`], [`Error::ExplicitZwj`], [`Error::NormalizeUnsupported`],
111 /// [`Error::ControlRequiresControlPosition`], [`Error::UnsupportedPositionedUnit`],
112 /// [`Error::ChainPositionMismatch`] — as numbered above.
113 /// - Anything [`Shaper::normalize_written_units`] returns. **Its indices point into the
114 /// expanded written-unit sequence — after implicit ZWJs were inserted — not into
115 /// `records`.**
116 pub fn normalize_positioned_written_units(
117 &self,
118 records: &[PositionedWrittenUnit],
119 ) -> Result<String, Error> {
120 if records.len() > MAX_POSITIONED_RECORDS {
121 return Err(Error::TooManyRecords {
122 max: MAX_POSITIONED_RECORDS,
123 });
124 }
125 if records.iter().any(|record| record.unit == WrittenUnit::Zwj) {
126 return Err(Error::ExplicitZwj);
127 }
128 if records.is_empty() {
129 return Ok(String::new());
130 }
131 let table = self.table()?;
132 for (index, record) in records.iter().enumerate() {
133 if matches!(record.unit, WrittenUnit::Mvs | WrittenUnit::Nirugu) {
134 if record.position != UnitPosition::Control {
135 return Err(Error::ControlRequiresControlPosition {
136 index,
137 unit: record.unit,
138 });
139 }
140 continue;
141 }
142 let supported = record
143 .position
144 .as_position()
145 .is_some_and(|position| table.positioned_units.contains(&(record.unit, position)));
146 if !supported {
147 return Err(Error::UnsupportedPositionedUnit {
148 index,
149 unit: record.unit,
150 position: record.position,
151 });
152 }
153 }
154
155 let mut parts: Vec<PositionedPart> = Vec::new();
156 let mut chain: Vec<(WrittenUnit, Position)> = Vec::new();
157 for record in records {
158 match record.position.as_position() {
159 None => {
160 if !chain.is_empty() {
161 parts.push(PositionedPart::Chain(std::mem::take(&mut chain)));
162 }
163 parts.push(PositionedPart::Control(record.unit));
164 }
165 Some(position) => chain.push((record.unit, position)),
166 }
167 }
168 if !chain.is_empty() {
169 parts.push(PositionedPart::Chain(chain));
170 }
171 let mut written: Vec<WrittenUnit> = Vec::new();
172 for index in 0..parts.len() {
173 let body = match &parts[index] {
174 PositionedPart::Control(unit) => {
175 written.push(*unit);
176 continue;
177 }
178 PositionedPart::Chain(body) => body,
179 };
180 let joined_left = index > 0 && is_joiner_part(&parts[index - 1]);
181 let joined_right = index + 1 < parts.len() && is_joiner_part(&parts[index + 1]);
182 if body.len() == 1 {
183 let (unit, position) = body[0];
184 // `records.len()`, not `body.len()`: the trailing ZWJ is for a request that is
185 // NOTHING but `O:init`. `[Nirugu:control, O:init]` also has a one-unit body, but
186 // the nirugu already supplies the joining context — it must not get the ZWJ.
187 if records.len() == 1 && unit == WrittenUnit::O && position == Position::Init {
188 written.extend([unit, WrittenUnit::Zwj]);
189 continue;
190 }
191 if matches!(position, Position::Medi | Position::Fina) && !joined_left {
192 written.push(WrittenUnit::Zwj);
193 }
194 written.push(unit);
195 if position == Position::Medi && !joined_right {
196 written.push(WrittenUnit::Zwj);
197 }
198 continue;
199 }
200 // One flag per side folds together the two ways a side can be non-initial/non-final:
201 // a joiner control already sits there, or the chain simply does not start at `init` /
202 // end at `fina` (so a ZWJ has to be invented). Either way the chain's positions are
203 // computed as if one extra unit padded that side, which is exactly `padded_count`.
204 let padded_left = joined_left || body[0].1 != Position::Init;
205 let padded_right = joined_right || body[body.len() - 1].1 != Position::Fina;
206 let padded_count = body.len() + usize::from(padded_left) + usize::from(padded_right);
207 // `length = 1` because each record is one written unit occupying one slot; multi-unit
208 // letters are the *encoder's* concern (`unit_partition`), not the caller's request.
209 for (offset, (_unit, position)) in body.iter().enumerate() {
210 let expected = slot_position(offset + usize::from(padded_left), 1, padded_count);
211 if *position != expected {
212 return Err(Error::ChainPositionMismatch);
213 }
214 }
215 if padded_left && !joined_left {
216 written.push(WrittenUnit::Zwj);
217 }
218 written.extend(body.iter().map(|(unit, _)| *unit));
219 if padded_right && !joined_right {
220 written.push(WrittenUnit::Zwj);
221 }
222 }
223 self.normalize_written_units(&written)
224 }
225
226 /// Parse the CLI's written-unit spelling: explicit `+`-separated names (`B+Aa`) or a
227 /// compact PascalCase string with exactly one segmentation over this shaper's known units
228 /// (`BZwj`). One trailing newline is tolerated; an ambiguous compact string is rejected.
229 ///
230 /// Every name — explicit or compact — must belong to *this shaper's normalize table*, not
231 /// merely to [`WrittenUnit`]: `E` is a Todo unit, so an MNG shaper rejects it. That keeps the
232 /// reported index identical to Python's, which defers the same check to
233 /// `normalize_written_units`.
234 ///
235 /// # Errors
236 ///
237 /// - [`Error::NormalizeUnsupported`] — this locale has no bundled normalize table.
238 /// - [`Error::InvalidUnitSpec`] — the spec contains whitespace or an empty `+` field, or the
239 /// compact string has more than one segmentation.
240 /// - [`Error::UnknownWrittenUnit`] — a name is outside the table's vocabulary; on the compact
241 /// path an unsegmentable string is reported whole, at index 0.
242 pub fn parse_written_units(&self, text: &str) -> Result<Vec<WrittenUnit>, Error> {
243 let table = self.table()?;
244 parse_unit_names(text, &table.sorted_vocabulary)?
245 .into_iter()
246 .enumerate()
247 .map(|(index, name)| {
248 // Membership is tested against the TABLE's vocabulary, not merely "is this a
249 // `WrittenUnit` variant": Python leaves every name unresolved here and lets
250 // `normalize_written_units` reject the first one missing from `known_units`, so a
251 // unit the enum knows but this locale's table does not (e.g. the Todo unit `E`)
252 // must fail at Python's index too. — see shaper.py::normalize_written_units
253 name.parse::<WrittenUnit>()
254 .ok()
255 .filter(|unit| table.known_units.contains(unit))
256 .ok_or_else(|| Error::UnknownWrittenUnit {
257 index,
258 unit: name.to_owned(),
259 })
260 })
261 .collect()
262 }
263}
264
265impl Shaper {
266 /// Every written unit this shaper's normalize table can encode, plus the structural
267 /// controls (`Mvs`, `Nirugu`, `Zwj`), sorted by name.
268 ///
269 /// These are exactly the names [`Shaper::normalize_written_units`] accepts; the Python
270 /// bindings use the list to validate their input with Python-formatted messages.
271 ///
272 /// # Errors
273 ///
274 /// [`Error::NormalizeUnsupported`] — this locale has no bundled normalize table.
275 pub fn known_written_units(&self) -> Result<Vec<WrittenUnit>, Error> {
276 let table = self.table()?;
277 let mut units: Vec<WrittenUnit> = table.known_units.iter().copied().collect();
278 units.sort_by_key(|unit| unit.as_str());
279 Ok(units)
280 }
281
282 /// The HUD positioned inventory: every `(unit, position)` pair that
283 /// [`Shaper::normalize_positioned_written_units`] accepts for a letter record (letter
284 /// positions only — controls are not part of the inventory), sorted by unit name then
285 /// position.
286 ///
287 /// # Errors
288 ///
289 /// [`Error::NormalizeUnsupported`] — this locale has no bundled normalize table.
290 pub fn positioned_written_units(&self) -> Result<Vec<(WrittenUnit, Position)>, Error> {
291 let table = self.table()?;
292 let mut records: Vec<(WrittenUnit, Position)> =
293 table.positioned_units.iter().copied().collect();
294 records.sort_by_key(|(unit, position)| (unit.as_str(), position.as_str()));
295 Ok(records)
296 }
297}
298
299fn strip_one_newline(text: &str) -> &str {
300 text.strip_suffix("\r\n")
301 .or_else(|| text.strip_suffix('\n'))
302 .or_else(|| text.strip_suffix('\r'))
303 .unwrap_or(text)
304}
305
306/// Python `_parse_written_units`: explicit `+` names, or a unique compact segmentation over
307/// `vocabulary`. Returns borrowed names — slices of `text` on the `+` path, vocabulary entries on
308/// the compact path — which the caller maps to units.
309///
310/// `vocabulary` is expected in Python's `(-len, name)` order (see
311/// [`crate::normalize::NormalizeTable::sorted_vocabulary`]), but that order is *not* semantically load-bearing: the
312/// per-offset count saturates at 2 and `choice` is cleared by any second match, so the final
313/// `(count, choice)` pair is independent of iteration order. Sorting only lets the longest
314/// candidates hit the `count == 2` early `break` sooner.
315pub(crate) fn parse_unit_names<'v>(
316 text: &'v str,
317 vocabulary: &[&'v str],
318) -> Result<Vec<&'v str>, Error> {
319 let text = strip_one_newline(text);
320 if text.is_empty() {
321 return Ok(Vec::new());
322 }
323 if text.chars().any(char::is_whitespace) {
324 return Err(Error::InvalidUnitSpec(
325 "written units cannot be empty or contain whitespace".to_owned(),
326 ));
327 }
328 if text.contains('+') {
329 let units: Vec<&str> = text.split('+').collect();
330 if units
331 .iter()
332 .any(|unit| unit.is_empty() || *unit != unit.trim())
333 {
334 return Err(Error::InvalidUnitSpec(
335 "written units cannot be empty or contain whitespace; separate explicit units \
336 with '+' (for example A+Aa+B+Zwj)"
337 .to_owned(),
338 ));
339 }
340 return Ok(units);
341 }
342 // Right-to-left DP over byte offsets. Invariant: `parse_counts[i]` is the number of ways
343 // `text[i..]` segments into vocabulary units, saturated at 2 — 0 = impossible, 1 = exactly
344 // one segmentation, 2 = "two or more", i.e. ambiguous. The empty suffix has one (the empty)
345 // segmentation, hence `parse_counts[n] = 1`. `choices[i]` is the unit that starts `text[i..]`
346 // in its unique parse, plus where that unit ends.
347 let n = text.len();
348 let mut parse_counts = vec![0u8; n + 1];
349 let mut choices: Vec<Option<(&'v str, usize)>> = vec![None; n + 1];
350 parse_counts[n] = 1;
351 for offset in (0..n).rev() {
352 if !text.is_char_boundary(offset) {
353 continue; // inside a multi-byte char: no unit can start here (count stays 0)
354 }
355 let mut count = 0u8;
356 let mut choice = None;
357 for unit in vocabulary.iter() {
358 let end = offset + unit.len();
359 if end > n || !text[offset..].starts_with(unit) || parse_counts[end] == 0 {
360 continue;
361 }
362 if count == 0 && parse_counts[end] == 1 {
363 choice = Some((*unit, end));
364 } else {
365 choice = None;
366 }
367 count = (count + parse_counts[end]).min(2);
368 if count == 2 {
369 break;
370 }
371 }
372 parse_counts[offset] = count;
373 if count == 1 {
374 // `count` lands on exactly 1 only when a single match contributed, and that match
375 // took the `count == 0 && parse_counts[end] == 1` branch — any second match would
376 // both clear `choice` and push `count` to 2. So `choice` is `Some` here, which is
377 // what the `expect` below relies on.
378 choices[offset] = choice;
379 }
380 }
381 match parse_counts[0] {
382 0 => Err(Error::UnknownWrittenUnit {
383 index: 0,
384 unit: text.to_owned(),
385 }),
386 1 => {
387 let mut units = Vec::new();
388 let mut offset = 0;
389 while offset < n {
390 let (unit, end) = choices[offset].expect("a unique parse records its choice");
391 units.push(unit);
392 offset = end;
393 }
394 Ok(units)
395 }
396 _ => Err(Error::InvalidUnitSpec(
397 "compact written-unit sequence is ambiguous; separate units with '+'".to_owned(),
398 )),
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn ambiguous_compact_units_require_plus_separators() {
408 // Python: `_parse_written_units("AAA", {"A", "AA"})` → "ambiguous … +"
409 let error = parse_unit_names("AAA", &["AA", "A"]).unwrap_err();
410 assert!(
411 matches!(&error, Error::InvalidUnitSpec(m) if m.contains("ambiguous") && m.contains('+'))
412 );
413 assert_eq!(parse_unit_names("AA", &["AA", "A"]).unwrap_err(), error);
414 assert_eq!(
415 parse_unit_names("AAB", &["AA", "A", "B"]).unwrap_err(),
416 error
417 );
418 assert_eq!(parse_unit_names("AAB", &["AA", "B"]).unwrap(), ["AA", "B"]);
419 }
420
421 /// The `(-len, name)` order is an optimisation, not semantics: any permutation of the same
422 /// vocabulary yields the same result.
423 #[test]
424 fn vocabulary_order_does_not_change_the_parse() {
425 // {AA, B} parses "AAB" uniquely; {A, AA} makes "AAA" ambiguous. Both hold in any order.
426 for vocabulary in [["AA", "B"], ["B", "AA"]] {
427 assert_eq!(parse_unit_names("AAB", &vocabulary).unwrap(), ["AA", "B"]);
428 }
429 for vocabulary in [["AA", "A"], ["A", "AA"]] {
430 assert!(parse_unit_names("AAA", &vocabulary).is_err());
431 assert_eq!(parse_unit_names("A", &vocabulary).unwrap(), ["A"]);
432 }
433 }
434
435 #[test]
436 fn multibyte_input_never_splits_a_char() {
437 assert_eq!(
438 parse_unit_names("A\u{1820}", &["A"]).unwrap_err(),
439 Error::UnknownWrittenUnit {
440 index: 0,
441 unit: "A\u{1820}".to_owned()
442 }
443 );
444 }
445
446 #[test]
447 fn trailing_transport_newline_is_stripped_once() {
448 assert_eq!(parse_unit_names("A\r\n", &["A"]).unwrap(), ["A"]);
449 assert_eq!(parse_unit_names("A\n", &["A"]).unwrap(), ["A"]);
450 assert_eq!(parse_unit_names("\n", &["A"]).unwrap(), Vec::<&str>::new());
451 assert!(parse_unit_names("A\n\n", &["A"]).is_err());
452 }
453}