vertext_core/lib.rs
1//! The host-independent part of Vertext.
2//!
3//! The one dependency is `unicode-segmentation` for UAX #29 grapheme cluster
4//! boundaries. It is table-driven and `no_std`-capable, so it crosses
5//! `wasm32` unchanged; hand-rolling cluster boundaries would produce exactly
6//! the near-miss rendering this library exists to end.
7//!
8//! A host turns a [`Layout`] into HTML, a terminal preview, or a GPU scene. The
9//! logical reading direction is always top-to-bottom and a source newline moves
10//! to the column on its left.
11
12use unicode_segmentation::UnicodeSegmentation;
13
14/// Which way successive columns advance. This is a property of the *script*,
15/// not of "vertical text": CJK columns advance right-to-left, traditional
16/// Mongolian advances left-to-right. Hosts must read it from the [`Layout`]
17/// rather than assuming a direction.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Progression {
20 /// `vertical-rl`: `columns[0]` is the rightmost column (CJK).
21 RightToLeft,
22 /// `vertical-lr`: `columns[0]` is the leftmost column (Mongolian).
23 LeftToRight,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct LayoutConfig {
28 /// Maximum number of displayed characters in an upright Latin word slot.
29 pub max_latin_word_width: usize,
30 /// Code mode retains each source space as an empty vertical row.
31 pub preserve_spaces: bool,
32 /// Column advance direction, carried onto the produced [`Layout`].
33 pub progression: Progression,
34}
35
36impl Default for LayoutConfig {
37 fn default() -> Self {
38 Self {
39 max_latin_word_width: 12,
40 preserve_spaces: false,
41 progression: Progression::RightToLeft,
42 }
43 }
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct Layout {
48 /// Columns are in source order; [`Layout::progression`] says which side
49 /// `columns[0]` sits on.
50 pub columns: Vec<Column>,
51 /// Column advance direction. Data, never a constant.
52 pub progression: Progression,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Column {
57 pub slots: Vec<Slot>,
58}
59
60/// A whole document: a sequence of blocks that advance in one direction.
61///
62/// [`Layout`] is the vertical primitive — one run of text as columns of
63/// slots. A document is more than that, because not everything in it wants to
64/// be vertical. Latin-majority prose and program source read horizontally,
65/// and a table is a grid. Those decisions belong here, as data, so that every
66/// adapter reads the same answer instead of each inventing one.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Document {
69 pub blocks: Vec<Block>,
70 pub progression: Progression,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub enum Block {
75 /// Vertical columns of slots — CJK, Mongolian, mixed prose.
76 Vertical(Layout),
77 /// A run set horizontally, because setting it vertically would serve no
78 /// reader: Latin-majority prose, and program source of any language.
79 Horizontal(HorizontalBlock),
80 /// A table. Rows become columns under a vertical progression, so a row
81 /// reads top-to-bottom as one entry and successive rows advance the same
82 /// way the surrounding text does.
83 Table(Table),
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct HorizontalBlock {
88 pub text: String,
89 /// Line length in characters. Line breaking itself is the host's job —
90 /// a terminal, a browser, and a PDF measure text differently, and the
91 /// core has no font metrics to break with honestly.
92 pub wrap_columns: usize,
93 pub kind: HorizontalKind,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum HorizontalKind {
98 /// The measure: 45–75 characters, 66 the long-settled optimum.
99 Prose,
100 /// Program source. 80 is the narrower of Google's and Mozilla's C++
101 /// limits; rustfmt allows 100.
102 Code,
103}
104
105impl HorizontalKind {
106 pub fn default_wrap(self) -> usize {
107 match self {
108 HorizontalKind::Prose => 66,
109 HorizontalKind::Code => 80,
110 }
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct Table {
116 pub rows: Vec<Row>,
117}
118
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct Row {
121 /// Each cell is laid out as its own column of slots, so a Mongolian cell
122 /// keeps its joined run and a Latin cell keeps its word slots.
123 pub cells: Vec<Column>,
124 pub header: bool,
125}
126
127/// Whether a run of text is better set horizontally than vertically.
128///
129/// The measure is **slots**, because a slot is what the layout actually
130/// produces. One ideograph is one slot; one Latin *word* is also one slot,
131/// however many letters it contains. Counting characters instead makes
132/// romanization look like English: a Chinese sentence quoting `bi yabuqu
133/// Ugei` has more Latin letters than Han characters while being, plainly, a
134/// Chinese sentence — and it would be flipped horizontal by a character
135/// count and left alone by a slot count.
136///
137/// This is why a language-teaching document is the honest test case. It is
138/// dense with citation forms, and every one of them is a short word standing
139/// in for a single idea, exactly like the character it glosses.
140///
141/// Punctuation and whitespace do not vote: they are shared by both systems,
142/// and letting them vote would hand the decision to a comma-heavy sentence.
143pub fn prefers_horizontal(text: &str) -> bool {
144 let (vertical, horizontal) = measure_slots(text);
145 horizontal > vertical
146}
147
148/// The slot census behind [`prefers_horizontal`]: vertical slots, then
149/// horizontal ones.
150///
151/// It must agree with [`layout_text`] about what a slot is — the count here
152/// equals the number of [`Slot::Upright`] plus [`Slot::MongolianRun`] slots
153/// that function emits, and the number of [`Slot::LatinWord`] slots. Pinning
154/// the answer for one string is weaker and turns into a puzzle at the next
155/// change; the invariant is that these two ways of counting cannot disagree.
156///
157/// Which script holds the open word is the whole of it. A word ends at a
158/// change of script even where no space separates the two, because that is
159/// where `layout_text` closes its slot and opens the next: `writtenᠢᠢ` is a
160/// Latin word and a bichig run, not one thing. Tracking only *whether* a word
161/// is open loses that boundary, and loses it in both directions — the run
162/// after a Latin word goes uncounted, and so does the word after a run.
163fn measure_slots(text: &str) -> (usize, usize) {
164 let (mut vertical, mut horizontal) = (0usize, 0usize);
165 // Exactly one of these is true while a word is open, and the pair is the
166 // answer to "whose word is it": the suffix separator joins bichig and
167 // nothing else, and a bracket continues a Latin word and nothing else.
168 let mut in_latin = false;
169 let mut in_bichig = false;
170 for cluster in text.graphemes(true) {
171 let Some(base) = cluster.chars().next() else { continue };
172 if is_cjk(base) {
173 vertical += 1;
174 in_latin = false;
175 in_bichig = false;
176 } else if is_mongolian(base) {
177 // A Mongolian run is one slot, so only its start counts — and a
178 // Latin word to the left does not make this its continuation.
179 if !in_bichig {
180 vertical += 1;
181 }
182 in_latin = false;
183 in_bichig = true;
184 } else if is_word_char(base) {
185 // A whole Latin word is one slot; count only where it begins, and
186 // a bichig run to the left does not make this its continuation.
187 if !in_latin {
188 horizontal += 1;
189 }
190 in_latin = true;
191 in_bichig = false;
192 } else if in_latin && is_word_connector(base) {
193 // Inside the word, as `layout_text` reads it: `kedU(n)`,
194 // `gerel.net`. A connector with no Latin word open is punctuation
195 // there and must be punctuation here too, so it falls through.
196 } else if in_bichig && is_suffix_separator(base) {
197 // The word continues across the joint. A stem and its case ending
198 // are one word and one slot — see `is_suffix_separator` — and
199 // counting them twice weighs the same word twice, which is the
200 // measure disagreeing with the layout about what a slot is.
201 } else {
202 in_latin = false;
203 in_bichig = false;
204 }
205 }
206 (vertical, horizontal)
207}
208
209#[derive(Clone, Debug, PartialEq, Eq)]
210pub enum Slot {
211 /// An upright ideograph, kana, or hangul grapheme cluster.
212 Upright(String),
213 /// One normal, horizontally readable Latin word in a vertical slot.
214 LatinWord(String),
215 /// Keep the run intact so a vertical-capable font can perform Mongolian
216 /// joining and vertical substitutions.
217 MongolianRun(String),
218 /// Whitespace from the source, carried through verbatim so a copied
219 /// paragraph matches what was written.
220 Space(String),
221 /// Punctuation that turns a quarter-circle in vertical text: brackets,
222 /// quotes, colons, dashes, ellipses, slashes.
223 ///
224 /// The host rotates the *view*. It must never swap the character for a
225 /// vertical presentation form (U+FE10–FE4F): those look correct and
226 /// silently destroy the document, because copy-paste, find-in-page, and
227 /// screen readers then yield codepoints the author never typed. A
228 /// renderer may decide how text appears; the text itself is content, and
229 /// content is not ours to edit.
230 VerticalPunctuation(String),
231 /// A stop or comma. These do not turn in vertical text — they move to the
232 /// upper-right corner of their em square. Again a view-only change.
233 CornerPunctuation(String),
234 /// Punctuation and other unsupported scripts remain upright for now.
235 Neutral(String),
236}
237
238/// Creates a top-to-bottom layout. Each source newline starts a new column to
239/// the *left*. Whitespace separates Latin words but does not create an empty
240/// slot. Long Latin words use predictable hard hyphens; dictionary hyphenation
241/// is intentionally a host-configurable future enhancement.
242///
243/// The unit of layout is the UAX #29 extended grapheme cluster, not the
244/// Unicode scalar. A variation selector must stay with the ideograph it
245/// selects a glyph for, a combining mark with its base, and a ZWJ emoji
246/// sequence with itself — split across slots they are silently dropped or
247/// rendered as their unjoined parts, which looks like text and is not.
248pub fn layout_text(input: &str, config: &LayoutConfig) -> Layout {
249 let mut columns = vec![Column { slots: Vec::new() }];
250 let mut latin_word = String::new();
251 let mut mongolian_run = String::new();
252 // Connectors seen with no word yet holding them. `-n_a` is one word, so a
253 // leading mark waits to see whether letters follow; if they do it joins
254 // them, and if they do not it becomes punctuation after all.
255 let mut pending_connectors = String::new();
256 // Whether those buffered connectors may still join a word that follows.
257 //
258 // A connector joins letters only when letters hold it on BOTH sides. At the
259 // start of a line, or after a space, `-n_a` is still one word — nothing has
260 // claimed the mark, so a following word may. But after an ideograph the
261 // mark has already been decided: `词尾(n)` is a Chinese sentence with a
262 // bracketed gloss, and the bracket must turn like every other bracket in
263 // that sentence. Without this the same paren lies flat next to Han and
264 // turns next to a space, which is what left a line of prose with some
265 // brackets rotated and some not.
266 let mut connectors_may_join = true;
267 // A suffix separator waiting to learn what follows it. Bichig on the right
268 // claims it into the run; anything else — an ideograph, a newline, the end
269 // of the input — leaves it the narrow space it is also named for. The same
270 // deferral as `pending_connectors`, for the same reason: one pass over the
271 // text, and only the next cluster can tell the two readings apart.
272 let mut pending_separator = String::new();
273
274 let flush_latin = |columns: &mut Vec<Column>, word: &mut String| {
275 if word.is_empty() { return; }
276 for piece in split_latin_word(word, config.max_latin_word_width) {
277 columns.last_mut().unwrap().slots.push(Slot::LatinWord(piece));
278 }
279 word.clear();
280 };
281 let flush_mongolian = |columns: &mut Vec<Column>, run: &mut String| {
282 if !run.is_empty() {
283 columns.last_mut().unwrap().slots.push(Slot::MongolianRun(std::mem::take(run)));
284 }
285 };
286
287 // A cluster is classified by its base scalar — the first one. Trailing
288 // marks, joiners, and variation selectors ride along with it.
289 for cluster in input.graphemes(true) {
290 let base = match cluster.chars().next() {
291 Some(base) => base,
292 None => continue,
293 };
294 // A buffered separator learns here what followed it. This settles
295 // before the branches so every one of them sees a decided state, and
296 // it closes the run first: the mark goes after the stem it failed to
297 // join, never ahead of it.
298 if !pending_separator.is_empty() && !is_mongolian(base) {
299 flush_mongolian(&mut columns, &mut mongolian_run);
300 columns.last_mut().unwrap().slots
301 .push(Slot::Space(std::mem::take(&mut pending_separator)));
302 }
303 if base == '\n' || base == '\r' {
304 // "\r\n" is one cluster and must open one column, not two.
305 flush_latin(&mut columns, &mut latin_word);
306 flush_mongolian(&mut columns, &mut mongolian_run);
307 flush_pending(&mut columns, &mut pending_connectors);
308 connectors_may_join = true;
309 columns.push(Column { slots: Vec::new() });
310 } else if base.is_whitespace() {
311 if is_suffix_separator(base) && !mongolian_run.is_empty() {
312 // Not a space here: the joint that holds a case ending onto
313 // its stem. Hold it until the next cluster says whether a
314 // suffix actually follows.
315 pending_separator.push_str(cluster);
316 continue;
317 }
318 flush_latin(&mut columns, &mut latin_word);
319 flush_mongolian(&mut columns, &mut mongolian_run);
320 flush_pending(&mut columns, &mut pending_connectors);
321 // The space is always kept. It is a character the author typed,
322 // and dropping it means a copied paragraph comes back as
323 // `可在gerel.net检索` — close enough to look fine and wrong to
324 // quote. `preserve_spaces` now decides only whether the space is
325 // made *visible* (code indentation), never whether it survives.
326 columns.last_mut().unwrap().slots.push(Slot::Space(cluster.to_owned()));
327 // A space frees the next mark to join whatever follows it.
328 connectors_may_join = true;
329 } else if is_mongolian(base) {
330 flush_latin(&mut columns, &mut latin_word);
331 // A connector still waiting for a word has just learned that no
332 // Latin word is coming. It must be emitted *here*, before the
333 // Mongolian run opens — left buffered it would surface when the
334 // run flushes, and `= ᠬ` would come back as `ᠬ=`. Reordering
335 // the author's characters is as wrong as replacing them.
336 flush_pending(&mut columns, &mut pending_connectors);
337 // Bichig is not Latin: `ᠱ(S)` is a script paired with its
338 // transliteration, so the bracket belongs to the sentence.
339 connectors_may_join = false;
340 // A separator the previous letter held back has its answer: the
341 // suffix arrived, so the joint goes into the run it joins.
342 mongolian_run.push_str(&std::mem::take(&mut pending_separator));
343 mongolian_run.push_str(cluster);
344 } else if is_word_char(base) {
345 flush_mongolian(&mut columns, &mut mongolian_run);
346 latin_word.push_str(&std::mem::take(&mut pending_connectors));
347 latin_word.push_str(cluster);
348 connectors_may_join = true;
349 } else if is_word_connector(base) && (connectors_may_join || !latin_word.is_empty()) {
350 // Inside a word this mark is a letter: `min-U`, `kedU(n)`, and
351 // `-n_a` are one word each. Held on either side by letters it
352 // joins them; held by neither it is punctuation.
353 //
354 // A mark at the tail of an open word is buffered rather than
355 // appended, because whether it belongs to that word is not yet
356 // known: the letters in `kedU(n)` claim it, but the ideograph in
357 // `(n)形式` does not, and only the next character tells them apart.
358 // Buffering defers the choice to the branch that sees it.
359 //
360 // Unless it closes a bracket the word already opened. `kedU(n)` is
361 // one citation form and its `)` has letters on the left and its own
362 // `(` inside the word -- the pair is balanced, so the mark is the
363 // word's own and needs no lookahead. Deferring it would strand the
364 // closing bracket outside the word at end of input.
365 if closes_open_bracket(base, &latin_word) {
366 latin_word.push_str(cluster);
367 } else {
368 pending_connectors.push_str(cluster);
369 }
370 } else {
371 flush_latin(&mut columns, &mut latin_word);
372 flush_mongolian(&mut columns, &mut mongolian_run);
373 flush_pending(&mut columns, &mut pending_connectors);
374 let slot = if is_corner_punctuation(base) {
375 Slot::CornerPunctuation(cluster.to_owned())
376 } else if has_vertical_form(base) {
377 Slot::VerticalPunctuation(cluster.to_owned())
378 } else if is_cjk(base) {
379 Slot::Upright(cluster.to_owned())
380 } else {
381 Slot::Neutral(cluster.to_owned())
382 };
383 // An ideograph (or any other non-word character) on the left ends a
384 // word. A connector that follows it opens an aside in a sentence
385 // rather than continuing a citation form, so it must not be held
386 // back waiting for letters to join.
387 connectors_may_join = false;
388 columns.last_mut().unwrap().slots.push(slot);
389 }
390 }
391 flush_latin(&mut columns, &mut latin_word);
392 flush_mongolian(&mut columns, &mut mongolian_run);
393 // Nothing followed it, so it was the narrow space after all.
394 if !pending_separator.is_empty() {
395 columns.last_mut().unwrap().slots.push(Slot::Space(pending_separator));
396 }
397 flush_pending(&mut columns, &mut pending_connectors);
398 Layout { columns, progression: config.progression }
399}
400
401/// Whether a closing mark completes a bracket the word already holds open.
402///
403/// `kedU(n)` is one word: its `)` matches a `(` that letters already claimed,
404/// so it joins them with no lookahead. `词尾(n)形式` never gets here for its
405/// `)` — that `(` went out as punctuation, so the word holds nothing open and
406/// the closing mark is punctuation too. Balance is what separates a citation
407/// form from an aside in a sentence.
408fn closes_open_bracket(ch: char, word: &str) -> bool {
409 let opener = match ch {
410 ')' => '(',
411 ']' => '[',
412 '}' => '{',
413 '>' => '<',
414 _ => return false,
415 };
416 let opens = word.chars().filter(|&c| c == opener).count();
417 let closes = word.chars().filter(|&c| c == ch).count();
418 opens > closes
419}
420
421/// Emits buffered connectors that never found a word to join.
422fn flush_pending(columns: &mut Vec<Column>, pending: &mut String) {
423 if pending.is_empty() { return; }
424 for cluster in std::mem::take(pending).graphemes(true) {
425 let base = cluster.chars().next().unwrap_or(' ');
426 let slot = if is_corner_punctuation(base) {
427 Slot::CornerPunctuation(cluster.to_owned())
428 } else if has_vertical_form(base) {
429 Slot::VerticalPunctuation(cluster.to_owned())
430 } else {
431 Slot::Neutral(cluster.to_owned())
432 };
433 columns.last_mut().unwrap().slots.push(slot);
434 }
435}
436
437/// Splits on grapheme-cluster boundaries so a hard hyphen can never land
438/// between a base letter and its combining mark.
439///
440/// A hyphen the author already typed is a break opportunity, and taking it
441/// costs nothing: the pieces still concatenate to the source, so a word broken
442/// there is not edited at all. Counting to the cap is the fallback for a word
443/// that offers no such break — `use-after-free` must not come back as
444/// `use-after-f‐` / `ree`, which reads as a different term.
445///
446/// Only hyphens qualify. The other word connectors in `is_word_connector`
447/// *join* — splitting `gerel.net` at the dot or `kedU(n)` at the paren cuts a
448/// citation form in half.
449fn split_latin_word(word: &str, limit: usize) -> Vec<String> {
450 let limit = limit.max(2);
451 let clusters: Vec<&str> = word.graphemes(true).collect();
452 if clusters.len() <= limit { return vec![word.to_owned()]; }
453
454 // The rightmost hyphen that still fits, so the piece before it is as full
455 // as it can be. The break falls *after* the hyphen — that is where a
456 // hyphenated word is allowed to break, and it leaves the mark on the line
457 // that earned it.
458 let hyphen = clusters[..limit].iter()
459 .rposition(|cluster| matches!(*cluster, "-" | "\u{2010}"))
460 .map(|index| index + 1)
461 // A hyphen in the last position would leave an empty remainder; there
462 // is nothing after it to move to the next piece.
463 .filter(|split| *split < clusters.len());
464
465 match hyphen {
466 Some(split) => {
467 let mut pieces = vec![clusters[..split].concat()];
468 pieces.extend(split_latin_word(&clusters[split..].concat(), limit));
469 pieces
470 }
471 None => {
472 // No break of its own: count, and reserve one slot for the mark
473 // that says the break was ours.
474 let payload = limit - 1;
475 let mut pieces = vec![{
476 let mut piece: String = clusters[..payload].concat();
477 piece.push('‐');
478 piece
479 }];
480 pieces.extend(split_latin_word(&clusters[payload..].concat(), limit));
481 pieces
482 }
483 }
484}
485
486/// Whether this character belongs to the Mongolian script.
487///
488/// Public because the renderer needs the same answer: a horizontal block does
489/// not go through slot layout, so it has to find its own Mongolian runs to mark
490/// them for the stylesheet. One definition of "this is bichig", not two.
491pub fn is_mongolian(ch: char) -> bool { matches!(ch as u32, 0x1800..=0x18AF | 0x11660..=0x1167F) }
492
493/// U+202F NARROW NO-BREAK SPACE — in bichig, the suffix separator.
494///
495/// This mark is not a space between words but a joint inside one. `ᠮᠣᠩᠭᠣᠯ` +
496/// NNBSP + `ᠤᠨ` is the genitive "Mongolia's": the separator holds the stem's
497/// last letter in its final form, opens the suffix in its initial form, and
498/// forbids a break between them — UAX #14 gives it class GL, non-breaking on
499/// both sides. The case suffixes are all attached this way.
500///
501/// Unicode nonetheless gives it `White_Space=Yes`, so `char::is_whitespace`
502/// answers true and an engine that asks only that question sets a case ending
503/// as a separate word: a half-em gap in the column with the suffix stranded
504/// below it, and the joining that carries the grammar cut in two. That one
505/// property is what this function exists to override — and only where bichig
506/// holds the mark on both sides, because U+202F is also the ordinary narrow
507/// space that its name describes.
508///
509/// U+180E MONGOLIAN VOWEL SEPARATOR needs no such rescue. It was `Zs` until
510/// Unicode 6.3 and is `Cf` now, so it is not whitespace to begin with, and it
511/// already rides inside the run as an ordinary character of the Mongolian
512/// block — as do the free variation selectors U+180B–180D, which are `Extend`
513/// and never leave the cluster they modify.
514fn is_suffix_separator(ch: char) -> bool { ch == '\u{202F}' }
515
516fn is_cjk(ch: char) -> bool { matches!(ch as u32,
517 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF |
518 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xAC00..=0xD7AF
519) }
520fn is_latin(ch: char) -> bool { matches!(ch as u32,
521 0x0041..=0x005A | 0x0061..=0x007A | 0x00C0..=0x024F | 0x1E00..=0x1EFF
522) }
523fn is_word_char(ch: char) -> bool { is_latin(ch) || ch.is_ascii_digit() || ch == '_' }
524
525/// Punctuation that behaves as a letter when it sits inside a word.
526///
527/// `min-U`, `kedU(n)`, `yabun_a`, `gerel.net`, `uu/UU` are single words, not a
528/// word and a mark and another word. Mongolian romanization uses these the way
529/// bichig uses the MVS and NNBSP: they join what is on either side, and
530/// splitting them puts a rotated bracket in the middle of a citation form.
531///
532/// Standing alone — with a space or an ideograph on the left — the same
533/// characters are ordinary punctuation and take a vertical form. So the class
534/// is contextual, and only the context decides.
535fn is_word_connector(ch: char) -> bool {
536 // `/` and `\` are deliberately absent: a slash separates alternatives
537 // (`uu/UU`, `ᠤ/ᠦ/ᠥ`) and each alternative wants its own row, so the slash
538 // breaks the word rather than joining it.
539 matches!(ch, ':' | '"' | '\'' | '(' | ')' | '{' | '}' |
540 '=' | '<' | '>' | '[' | ']' | '|' | '-' | '.' | '+' | '_')
541}
542/// Punctuation that has a distinct vertical presentation form.
543///
544/// Two families, one treatment. Brackets and quotes have compatibility forms
545/// in U+FE30–FE44; CJK commas, stops, colons, dashes, and ellipses have
546/// presentation forms in U+FE10–FE19. Both are reached the same way — a
547/// vertical writing mode plus the font's `vert`/`vrt2` feature — so both are
548/// classified together and the font decides. A stop is repositioned into the
549/// corner of its em square; a dash and a colon genuinely rotate. Which of
550/// those happens is the font's business, not ours.
551///
552/// Bare ASCII stays out. A colon in `a:b` must not rotate, and code and
553/// romanization are full of them; the fullwidth `:` in Chinese prose is a
554/// different character with different typography, and it is the one that
555/// wants the vertical form.
556/// Stops and commas, which reposition rather than rotate.
557fn is_corner_punctuation(ch: char) -> bool {
558 // The semicolon sits with the comma and the stop: they are all clause
559 // separators and behave as a family, so treating one of them differently
560 // makes a sentence look mis-set.
561 matches!(ch, ',' | '、' | '。' | '.' | '。' | '、' | ';' | ';')
562}
563
564fn has_vertical_form(ch: char) -> bool {
565 matches!(ch,
566 // Brackets, quotes, and the ASCII marks that stand between clauses.
567 // These reach here only when they are NOT inside a word — see
568 // `is_word_connector`, which claims them first when letters surround
569 // them.
570 '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' |
571 ':' | '"' | '\'' | '=' | '|' |
572 // Arrows point along the text. In a vertical column "onward" is
573 // downward, so a horizontally-pointing arrow has to turn to keep
574 // meaning what it meant. Vertical arrows already point along the
575 // flow and are left alone — turning them would aim them sideways.
576 '→' | '←' | '↔' | '⇒' | '⇐' | '⇔' | '⟶' | '⟵' | '⟷' |
577 '➔' | '➜' | '➝' | '➞' | '⇢' | '⇠' | '↦' | '↤' | '⊸' |
578 '(' | ')' | '[' | ']' | '{' | '}' |
579 '〈' | '〉' | '《' | '》' | '「' | '」' | '『' | '』' |
580 '【' | '】' | '〔' | '〕' | '“' | '”' | '‘' | '’' |
581 // Separators that genuinely turn. Stops, commas, and semicolons are
582 // handled by `is_corner_punctuation`; `!` and `?` stay upright.
583 ':' |
584 // Dashes, ellipses, and connectors that run along the column.
585 // The whole dash family, not just the em dash: a range written `᠑–᠕`
586 // reached here on an en dash, missed this list, and fell through to
587 // `Neutral`, where nothing turns it — so it lay flat across the column
588 // while the em dashes on the same page stood correctly. Every mark
589 // Unicode calls a dash belongs to one class; picking three of them out
590 // by hand is what let the other five drift.
591 //
592 // ASCII `-` is deliberately NOT here. `is_word_connector` claims it
593 // first so that a word breaks at the hyphen it already has, and it only
594 // reaches this function when no word holds it.
595 '—' | '―' | '-' | '–' | '‐' | '‑' | '‒' | '−' | '⸺' | '⸻' |
596 '…' | '‥' | '〜' | '~' | '|' | '‖')
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 #[test]
603 fn a_newline_creates_the_column_to_the_left() {
604 let layout = layout_text("中文\n日本", &LayoutConfig::default());
605 assert_eq!(layout.columns.len(), 2);
606 assert_eq!(layout.columns[0].slots, vec![Slot::Upright("中".into()), Slot::Upright("文".into())]);
607 }
608 #[test]
609 fn long_latin_words_are_bounded() {
610 let layout = layout_text("textremificationalization", &LayoutConfig::default());
611 assert!(layout.columns[0].slots.iter().all(|slot| match slot { Slot::LatinWord(s) => s.chars().count() <= 12, _ => true }));
612 }
613 #[test]
614 fn punctuation_with_a_vertical_form_gets_its_own_slot() {
615 let layout = layout_text("()", &LayoutConfig::default());
616 assert_eq!(layout.columns[0].slots, vec![
617 Slot::VerticalPunctuation("(".into()),
618 Slot::VerticalPunctuation(")".into()),
619 ]);
620 }
621 /// Stops and commas move to the corner of their em square; dashes and
622 /// ellipses turn. Two different treatments, and neither is a rotation of
623 /// the whole line or a change to the characters.
624 /// Semicolons keep company with commas and stops; arrows turn because a
625 /// horizontal arrow must keep pointing "onward" when onward is downward;
626 /// a vertical arrow already does and is left alone.
627 /// The punctuation contract, pinned character by character.
628 ///
629 /// This table is the agreement, not a sample of it. Every mark below was
630 /// decided deliberately and this classification has already churned more
631 /// than once — so it is written out in full and any change to it fails
632 /// here, loudly, instead of quietly altering how someone's document is
633 /// set. If a mark genuinely needs to move, move it *here first*.
634 #[test]
635 fn the_punctuation_contract() {
636 // Turns a quarter-circle. Brackets, quotes, colons, dashes, ellipses,
637 // and the ASCII operators that stand between clauses.
638 for mark in ['(', ')', '[', ']', '{', '}', '<', '>', ':', '"', '\'',
639 '=', '|',
640 '(', ')', '[', ']', '{', '}', '〈', '〉', '《', '》',
641 '「', '」', '『', '』', '【', '】', '〔', '〕',
642 '“', '”', '‘', '’', ':',
643 '—', '―', '-', '…', '‥', '〜', '~', '|', '‖',
644 // The rest of the dash family. The em dash was here from
645 // the start and its siblings were not, so `᠑–᠕` came out
646 // with the dash lying flat across the column while an em
647 // dash two lines above it turned correctly.
648 '–', '‐', '‑', '‒', '−', '⸺', '⸻',
649 '→', '←', '↔', '⇒', '⇐', '⇔', '⟶', '⟵'] {
650 let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
651 assert_eq!(layout.columns[0].slots[1],
652 Slot::VerticalPunctuation(mark.to_string()),
653 "{mark:?} must turn");
654 }
655 // Sits in the corner of its em square. Clause separators travel as a
656 // family; splitting one off makes a sentence look mis-set.
657 for mark in [',', '、', '。', '.', '。', '、', ';', ';'] {
658 let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
659 assert_eq!(layout.columns[0].slots[1],
660 Slot::CornerPunctuation(mark.to_string()),
661 "{mark:?} must go to the corner");
662 }
663 // Stays upright. A turned slash reads as a backslash; `↑`/`↓` already
664 // point along the flow; `!`/`?` are upright by convention.
665 for mark in ['/', '\\', '↑', '↓', '↕', '!', '?', '!', '?', '+', '*', '%'] {
666 let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
667 assert_eq!(layout.columns[0].slots[1],
668 Slot::Neutral(mark.to_string()),
669 "{mark:?} must stay upright");
670 }
671 }
672
673 #[test]
674 fn separators_and_arrows_are_classified_by_behaviour() {
675 let layout = layout_text("好;天→月↓日/水", &LayoutConfig::default());
676 assert_eq!(layout.columns[0].slots, vec![
677 Slot::Upright("好".into()),
678 Slot::CornerPunctuation(";".into()),
679 Slot::Upright("天".into()),
680 Slot::VerticalPunctuation("→".into()),
681 Slot::Upright("月".into()),
682 Slot::Neutral("↓".into()),
683 Slot::Upright("日".into()),
684 Slot::Neutral("/".into()),
685 Slot::Upright("水".into()),
686 ]);
687 }
688 #[test]
689 fn stops_go_to_the_corner_and_dashes_turn() {
690 let layout = layout_text("好,天。—…", &LayoutConfig::default());
691 assert_eq!(layout.columns[0].slots, vec![
692 Slot::Upright("好".into()),
693 Slot::CornerPunctuation(",".into()),
694 Slot::Upright("天".into()),
695 Slot::CornerPunctuation("。".into()),
696 Slot::VerticalPunctuation("—".into()),
697 Slot::VerticalPunctuation("…".into()),
698 ]);
699 }
700 #[test]
701 fn underscores_and_digits_stay_in_a_latin_identifier() {
702 let layout = layout_text("hi_nancy v2", &LayoutConfig::default());
703 assert_eq!(layout.columns[0].slots, vec![
704 Slot::LatinWord("hi_nancy".into()),
705 Slot::Space(" ".into()),
706 Slot::LatinWord("v2".into()),
707 ]);
708 }
709 /// Golden: the README's own sample. Every scalar accounted for, in order.
710 #[test]
711 fn golden_shanchuan_yiyu() {
712 let layout = layout_text("山川异域,风月同天。", &LayoutConfig::default());
713 assert_eq!(layout.progression, Progression::RightToLeft);
714 assert_eq!(layout.columns.len(), 1);
715 let expected: Vec<Slot> = "山川异域"
716 .chars()
717 .map(|c| Slot::Upright(c.to_string()))
718 .chain([Slot::CornerPunctuation(",".into())])
719 .chain("风月同天".chars().map(|c| Slot::Upright(c.to_string())))
720 .chain([Slot::CornerPunctuation("。".into())])
721 .collect();
722 assert_eq!(layout.columns[0].slots, expected);
723 }
724 /// A variation selector selects which glyph the font draws for an
725 /// ideograph. Split into its own slot it selects nothing and the reader
726 /// gets the wrong form of a character in someone's name.
727 #[test]
728 fn a_variation_selector_stays_with_its_ideograph() {
729 let layout = layout_text("葛\u{FE00}城", &LayoutConfig::default());
730 assert_eq!(layout.columns[0].slots, vec![
731 Slot::Upright("葛\u{FE00}".into()),
732 Slot::Upright("城".into()),
733 ]);
734 }
735 #[test]
736 fn combining_marks_stay_with_their_base() {
737 // Devanagari स + virama + त is one cluster; e + combining acute is one
738 // Latin grapheme inside a word.
739 let layout = layout_text("स\u{094D}त e\u{0301}cole", &LayoutConfig::default());
740 assert_eq!(layout.columns[0].slots, vec![
741 Slot::Neutral("स\u{094D}त".into()),
742 Slot::Space(" ".into()),
743 Slot::LatinWord("e\u{0301}cole".into()),
744 ]);
745 }
746 #[test]
747 fn zwj_and_flag_sequences_are_one_slot_each() {
748 let layout = layout_text("🇯🇵👨\u{200D}👩\u{200D}👧", &LayoutConfig::default());
749 assert_eq!(layout.columns[0].slots, vec![
750 Slot::Neutral("🇯🇵".into()),
751 Slot::Neutral("👨\u{200D}👩\u{200D}👧".into()),
752 ]);
753 }
754 #[test]
755 fn a_hard_hyphen_never_splits_a_cluster() {
756 // Twelve clusters, each a base plus a combining acute: the cap counts
757 // clusters, so no piece may end mid-cluster.
758 let word = "e\u{0301}".repeat(14);
759 let layout = layout_text(&word, &LayoutConfig::default());
760 for slot in &layout.columns[0].slots {
761 let Slot::LatinWord(piece) = slot else { panic!("expected Latin slots") };
762 assert!(!piece.starts_with('\u{0301}'), "piece begins with an orphaned mark: {piece:?}");
763 assert!(piece.graphemes(true).count() <= 12);
764 }
765 }
766 #[test]
767 fn a_crlf_newline_opens_one_column() {
768 let layout = layout_text("中\r\n日", &LayoutConfig::default());
769 assert_eq!(layout.columns.len(), 2);
770 assert_eq!(layout.columns[1].slots, vec![Slot::Upright("日".into())]);
771 }
772 #[test]
773 fn orientation_is_decided_by_ink_not_character_count() {
774 // Four ideographs outweigh three Latin words, because they carry more
775 // of the line. A naive character count would call this horizontal.
776 assert!(!prefers_horizontal("山川异域 the of and"));
777 assert!(prefers_horizontal("It is a truth universally acknowledged"));
778 assert!(!prefers_horizontal("春はあけぼの。やうやう白くなりゆく"));
779 // A few Latin words inside CJK stay vertical.
780 assert!(!prefers_horizontal("この API は便利です"));
781 // Mongolian is a vertical script and must never be called horizontal.
782 assert!(!prefers_horizontal("ᠮᠣᠩᠭᠤᠯ ᠤᠯᠤᠰ"));
783 // Program source is Latin-majority.
784 assert!(prefers_horizontal("fn main() { println!(\"hi\"); }"));
785 }
786 #[test]
787 fn punctuation_alone_does_not_decide_orientation() {
788 // No letters at all: nothing votes, so it stays vertical by default.
789 assert!(!prefers_horizontal(",。、;:!?"));
790 assert!(!prefers_horizontal("...,,,;;;"));
791 }
792 #[test]
793 fn horizontal_kinds_carry_their_conventional_measures() {
794 assert_eq!(HorizontalKind::Prose.default_wrap(), 66);
795 assert_eq!(HorizontalKind::Code.default_wrap(), 80);
796 }
797 #[test]
798 fn a_mark_between_letters_is_part_of_the_word() {
799 // `min-U`, `kedU(n)`, `gerel.net` are single citation forms. Splitting
800 // them drops a rotated bracket into the middle of a word.
801 let layout = layout_text("min-U kedU(n) gerel.net a=b:c", &LayoutConfig::default());
802 let words: Vec<&Slot> = layout.columns[0].slots.iter()
803 .filter(|slot| !matches!(slot, Slot::Space(_))).collect();
804 assert_eq!(words, vec![
805 &Slot::LatinWord("min-U".into()),
806 &Slot::LatinWord("kedU(n)".into()),
807 &Slot::LatinWord("gerel.net".into()),
808 &Slot::LatinWord("a=b:c".into()),
809 ]);
810 }
811 /// The other half of the contract above, and the boundary between them.
812 ///
813 /// A connector joins letters only when letters hold it on BOTH sides.
814 /// Pressed against an ideograph it is an ordinary bracket in a Chinese
815 /// sentence and takes its vertical form, exactly as `is_word_connector`
816 /// has always said it should ("with a space or an ideograph on the left
817 /// ... ordinary punctuation").
818 ///
819 /// This is the case a Chinese document teaching Mongolian is made of:
820 /// `不稳定词尾(n)` is a gloss inside prose, while `kedU(n)` in the glossary
821 /// beside it is one citation form. Same characters, different job, and the
822 /// character on the left is what tells them apart. Getting this wrong
823 /// leaves a sentence where some brackets turn and some lie flat.
824 #[test]
825 fn a_mark_against_an_ideograph_is_punctuation() {
826 let layout = layout_text("词尾(n)形式", &LayoutConfig::default());
827 assert_eq!(layout.columns[0].slots, vec![
828 Slot::Upright("词".into()),
829 Slot::Upright("尾".into()),
830 Slot::VerticalPunctuation("(".into()),
831 Slot::LatinWord("n".into()),
832 Slot::VerticalPunctuation(")".into()),
833 Slot::Upright("形".into()),
834 Slot::Upright("式".into()),
835 ]);
836 // A closing bracket followed by an ideograph closes the aside; it must
837 // not swallow the ideograph's side of the boundary either.
838 let mongolian = layout_text("ᠱ(S) 不", &LayoutConfig::default());
839 assert_eq!(mongolian.columns[0].slots, vec![
840 Slot::MongolianRun("ᠱ".into()),
841 Slot::VerticalPunctuation("(".into()),
842 Slot::LatinWord("S".into()),
843 Slot::VerticalPunctuation(")".into()),
844 Slot::Space(" ".into()),
845 Slot::Upright("不".into()),
846 ]);
847 // And the citation form is untouched: letters on both sides still join.
848 let citation = layout_text("kedU(n)", &LayoutConfig::default());
849 assert_eq!(citation.columns[0].slots, vec![Slot::LatinWord("kedU(n)".into())]);
850 }
851 /// A long word breaks at a hyphen it already has, rather than counting to
852 /// the cap and inserting one.
853 ///
854 /// `use-after-free` came back as `use-after-f‐` / `ree`, which reads as a
855 /// different term. A hyphen is already a sanctioned break point, so
856 /// breaking there needs no inserted mark at all — and a break that adds
857 /// nothing leaves the text identical to the source.
858 #[test]
859 fn a_long_word_breaks_at_the_hyphen_it_already_has() {
860 // 15 clusters against the default cap of 12.
861 assert_eq!(split_latin_word("use-after-free", 12),
862 vec!["use-after-".to_owned(), "free".to_owned()]);
863 // Nothing was inserted: the pieces rebuild the source exactly.
864 assert_eq!(split_latin_word("use-after-free", 12).concat(), "use-after-free");
865
866 // The rightmost hyphen that still fits wins, so each piece is as full
867 // as it can be. `-in-` would fit too, but leaves a longer remainder.
868 assert_eq!(split_latin_word("copy-on-write-semantics", 14),
869 vec!["copy-on-write-".to_owned(), "semantics".to_owned()]);
870
871 // A remainder that still overflows keeps breaking, and the tail falls
872 // back to counting when it holds no hyphen of its own.
873 assert_eq!(split_latin_word("well-known-supercalifragilistic", 12),
874 vec!["well-known-".to_owned(), "supercalifr‐".to_owned(), "agilistic".to_owned()]);
875
876 // A hyphen too far right to help is no break opportunity: the prefix
877 // before it still exceeds the cap, so counting takes over.
878 assert_eq!(split_latin_word("supercalifragilistic-x", 12),
879 vec!["supercalifr‐".to_owned(), "agilistic-x".to_owned()]);
880
881 // A trailing hyphen must not produce an empty piece.
882 assert_eq!(split_latin_word("autoconfiguration-", 12),
883 vec!["autoconfigu‐".to_owned(), "ration-".to_owned()]);
884
885 // Only hyphens are break opportunities. The other word connectors join
886 // — splitting `gerel.net` at the dot, or `kedU(n)` at the paren, breaks
887 // a citation form in half.
888 assert_eq!(split_latin_word("gerel.net.example.org", 12),
889 vec!["gerel.net.e‐".to_owned(), "xample.org".to_owned()]);
890
891 // Short enough to leave alone, hyphen or not.
892 assert_eq!(split_latin_word("use-after", 12), vec!["use-after".to_owned()]);
893 }
894
895 /// A hyphen break survives the full layout path, not just the splitter,
896 /// and leaves the source character-for-character intact.
897 #[test]
898 fn breaking_at_a_hyphen_alters_no_character() {
899 let source = "在 use-after-free 中";
900 let layout = layout_text(source, &LayoutConfig::default());
901 let mut rebuilt = String::new();
902 for column in &layout.columns {
903 for slot in &column.slots {
904 match slot {
905 Slot::Upright(s) | Slot::LatinWord(s) | Slot::MongolianRun(s)
906 | Slot::VerticalPunctuation(s) | Slot::CornerPunctuation(s)
907 | Slot::Neutral(s) | Slot::Space(s) => rebuilt.push_str(s),
908 }
909 }
910 }
911 assert_eq!(rebuilt, source, "a hyphen break must insert nothing");
912 assert!(!rebuilt.contains('\u{2010}'), "no break mark was needed here");
913 }
914
915 /// A mark with letters on the right joins them too: `-n_a` is one word.
916 /// The invariant that matters most: layout never edits the text. Every
917 /// slot concatenated back together, in order, must equal the source with
918 /// only whitespace removed. A renderer that rewrites content is not
919 /// rendering it.
920 #[test]
921 fn layout_never_alters_a_single_character() {
922 // Includes marks pressed directly against Mongolian, Han, and Latin
923 // with no space to separate them — the arrangement that exposed a
924 // buffered connector surfacing on the wrong side of a run.
925 let source = "O = ᠥ。辅音 q(阳)/k(阴)= ᠬ,S=ᠱ,=ᠴ,j=ᠵ。规则:ᠱ(S) 不出现在 i 前,\u{201c}shi\u{201d} 音写作 si。";
926 let layout = layout_text(source, &LayoutConfig::default());
927 let mut rebuilt = String::new();
928 for column in &layout.columns {
929 for slot in &column.slots {
930 match slot {
931 Slot::Upright(s) | Slot::LatinWord(s) | Slot::MongolianRun(s)
932 | Slot::VerticalPunctuation(s) | Slot::CornerPunctuation(s)
933 | Slot::Neutral(s) | Slot::Space(s) => rebuilt.push_str(s),
934 }
935 }
936 }
937 // Whitespace included: a dropped space makes `可在 gerel.net 检索`
938 // come back as `可在gerel.net检索`, which is close enough to look
939 // right and wrong to quote.
940 assert_eq!(rebuilt, source, "layout must not add, drop, or swap characters");
941 }
942
943 #[test]
944 fn a_leading_mark_joins_the_word_that_follows() {
945 let layout = layout_text("-n_a / -n_e", &LayoutConfig::default());
946 let marks: Vec<&Slot> = layout.columns[0].slots.iter()
947 .filter(|slot| !matches!(slot, Slot::Space(_))).collect();
948 assert_eq!(marks, vec![
949 &Slot::LatinWord("-n_a".into()),
950 // A slash separates alternatives, so each gets its own row. It
951 // stays upright: a turned slash reads as a backslash.
952 &Slot::Neutral("/".into()),
953 &Slot::LatinWord("-n_e".into()),
954 ]);
955 }
956 /// A slash breaks a word even between letters: `uu/UU` is two forms, and
957 /// each wants its own row.
958 #[test]
959 fn a_slash_always_breaks() {
960 let layout = layout_text("uu/UU", &LayoutConfig::default());
961 assert_eq!(layout.columns[0].slots, vec![
962 Slot::LatinWord("uu".into()),
963 Slot::Neutral("/".into()),
964 Slot::LatinWord("UU".into()),
965 ]);
966 }
967 /// Fullwidth punctuation must never be swallowed by an adjacent Latin
968 /// letter: `q(阳)` is a letter, a bracket, an ideograph, a bracket.
969 #[test]
970 fn fullwidth_marks_never_join_a_latin_word() {
971 let layout = layout_text("q(阳)", &LayoutConfig::default());
972 assert_eq!(layout.columns[0].slots, vec![
973 Slot::LatinWord("q".into()),
974 Slot::VerticalPunctuation("(".into()),
975 Slot::Upright("阳".into()),
976 Slot::VerticalPunctuation(")".into()),
977 ]);
978 }
979 #[test]
980 fn the_same_mark_standing_alone_takes_a_vertical_form() {
981 // Nothing holds it on the left, so it is punctuation again.
982 let layout = layout_text("好(天)= 川", &LayoutConfig::default());
983 let marks: Vec<&Slot> = layout.columns[0].slots.iter()
984 .filter(|slot| !matches!(slot, Slot::Space(_))).collect();
985 assert_eq!(marks, vec![
986 &Slot::Upright("好".into()),
987 &Slot::VerticalPunctuation("(".into()),
988 &Slot::Upright("天".into()),
989 &Slot::VerticalPunctuation(")".into()),
990 &Slot::VerticalPunctuation("=".into()),
991 &Slot::Upright("川".into()),
992 ]);
993 }
994 /// The orientation measure counts slots, not characters. A Chinese
995 /// sentence quoting romanization has more Latin letters than Han
996 /// characters and is still, plainly, a Chinese sentence.
997 #[test]
998 fn romanization_does_not_flip_a_chinese_sentence_horizontal() {
999 assert!(!prefers_horizontal(
1000 "3. 将来否定 = 词典形 + Ugei:bi yabuqu Ugei(我不去),不是 *yabun_a Ugei。"));
1001 assert!(!prefers_horizontal("4. 疑问词 uu/UU 也和谐:iren_e UU。"));
1002 assert!(!prefers_horizontal(
1003 "Ugei 否定\u{201d}有\u{201d},bisi 否定\u{201d}是\u{201d}:mori Ugei(没有马)vs tere mori bisi(那不是马)。"));
1004 // Genuine English still goes horizontal.
1005 assert!(prefers_horizontal("It is a truth universally acknowledged, that a single man"));
1006 }
1007
1008 #[test]
1009 fn progression_is_carried_as_data() {
1010 let config = LayoutConfig { progression: Progression::LeftToRight, ..Default::default() };
1011 assert_eq!(layout_text("ᠮᠣᠩᠭᠤᠯ", &config).progression, Progression::LeftToRight);
1012 }
1013 /// The suffix separator holds a case ending onto its stem, and the layout
1014 /// must keep them in one run.
1015 ///
1016 /// `ᠮᠣᠩᠭᠣᠯ` + U+202F + `ᠤᠨ` is one word, the genitive "Mongolia's". Because
1017 /// Unicode gives U+202F `White_Space=Yes`, the whitespace branch used to
1018 /// close the run, emit a `Space` slot, and open a second run — which on
1019 /// the page is a half-em gap with the case ending stranded below it, read
1020 /// by anyone who reads the script as two words instead of one.
1021 #[test]
1022 fn a_suffix_separator_is_part_of_the_word_not_a_space() {
1023 let genitive = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ";
1024 assert_eq!(layout_text(genitive, &LayoutConfig::default()).columns[0].slots,
1025 vec![Slot::MongolianRun(genitive.into())],
1026 "the joint must stay inside the run it joins");
1027 // Every case ending attaches the same way, and a word may carry more
1028 // than one joint.
1029 let dative = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠳᠤ\u{202F}ᠪᠠᠨ";
1030 assert_eq!(layout_text(dative, &LayoutConfig::default()).columns[0].slots,
1031 vec![Slot::MongolianRun(dative.into())]);
1032 // A word space between two suffixed words is still a word space: the
1033 // fix must not swallow the boundary it does not own.
1034 let phrase = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ ᠲᠡᠦᠬᠡ\u{202F}ᠶᠢ";
1035 assert_eq!(layout_text(phrase, &LayoutConfig::default()).columns[0].slots, vec![
1036 Slot::MongolianRun("ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ".into()),
1037 Slot::Space(" ".into()),
1038 Slot::MongolianRun("ᠲᠡᠦᠬᠡ\u{202F}ᠶᠢ".into()),
1039 ]);
1040 }
1041
1042 /// The other half of the contract: U+202F joins only where bichig holds it
1043 /// on both sides. Elsewhere it is the narrow space its name describes —
1044 /// French uses it before a colon, and typography uses it to group digits —
1045 /// so it keeps its own slot there, exactly as any other space would.
1046 #[test]
1047 fn a_narrow_space_outside_bichig_is_still_a_space() {
1048 // Nothing Mongolian on the left.
1049 assert_eq!(layout_text("好\u{202F}ᠤᠨ", &LayoutConfig::default()).columns[0].slots, vec![
1050 Slot::Upright("好".into()),
1051 Slot::Space("\u{202F}".into()),
1052 Slot::MongolianRun("ᠤᠨ".into()),
1053 ]);
1054 // Nothing Mongolian on the right: the run closes and the mark falls
1055 // back to being the space it also is.
1056 assert_eq!(layout_text("ᠮᠣᠩᠭᠣᠯ\u{202F}好", &LayoutConfig::default()).columns[0].slots, vec![
1057 Slot::MongolianRun("ᠮᠣᠩᠭᠣᠯ".into()),
1058 Slot::Space("\u{202F}".into()),
1059 Slot::Upright("好".into()),
1060 ]);
1061 // A newline is not a suffix either, and the mark must not follow the
1062 // column break: it belongs to the column the stem is in.
1063 let broken = layout_text("ᠮᠣᠩᠭᠣᠯ\u{202F}\nᠤᠨ", &LayoutConfig::default());
1064 assert_eq!(broken.columns[0].slots, vec![
1065 Slot::MongolianRun("ᠮᠣᠩᠭᠣᠯ".into()),
1066 Slot::Space("\u{202F}".into()),
1067 ]);
1068 assert_eq!(broken.columns[1].slots, vec![Slot::MongolianRun("ᠤᠨ".into())]);
1069 // Nothing follows it at all.
1070 assert_eq!(layout_text("ᠮᠣᠩᠭᠣᠯ\u{202F}", &LayoutConfig::default()).columns[0].slots, vec![
1071 Slot::MongolianRun("ᠮᠣᠩᠭᠣᠯ".into()),
1072 Slot::Space("\u{202F}".into()),
1073 ]);
1074 // Latin on both sides is not bichig: the mark never joins the word.
1075 assert_eq!(layout_text("Chapitre\u{202F}: 1", &LayoutConfig::default()).columns[0].slots, vec![
1076 Slot::LatinWord("Chapitre".into()),
1077 Slot::Space("\u{202F}".into()),
1078 Slot::VerticalPunctuation(":".into()),
1079 Slot::Space(" ".into()),
1080 Slot::LatinWord("1".into()),
1081 ]);
1082 }
1083
1084 /// `layout_never_alters_a_single_character`, over text full of joints.
1085 /// Whether a separator joined a word or stood as a space, it must come
1086 /// back in place and in order — a buffered mark that resurfaces on the
1087 /// wrong side of a run is the same defect as a dropped one.
1088 #[test]
1089 fn suffix_separators_survive_the_round_trip() {
1090 let source = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ ᠲᠡᠦᠬᠡ\u{202F}ᠶᠢ 读\u{202F}好 ᠪᠢ\u{202F}\nᠮᠣᠩᠭᠣᠯ\u{202F}";
1091 let layout = layout_text(source, &LayoutConfig::default());
1092 let mut rebuilt = String::new();
1093 for (index, column) in layout.columns.iter().enumerate() {
1094 if index > 0 { rebuilt.push('\n'); }
1095 for slot in &column.slots {
1096 match slot {
1097 Slot::Upright(s) | Slot::LatinWord(s) | Slot::MongolianRun(s)
1098 | Slot::VerticalPunctuation(s) | Slot::CornerPunctuation(s)
1099 | Slot::Neutral(s) | Slot::Space(s) => rebuilt.push_str(s),
1100 }
1101 }
1102 }
1103 assert_eq!(rebuilt, source, "a joint must not be added, dropped, or moved");
1104 }
1105
1106 /// The orientation measure counts slots, so it has to count a suffixed
1107 /// word the way the layout does: once.
1108 #[test]
1109 fn the_orientation_measure_counts_a_suffixed_word_once() {
1110 let genitive = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ";
1111 assert_eq!(layout_text(genitive, &LayoutConfig::default()).columns[0].slots.len(), 1);
1112 // Two Latin slots against one Mongolian slot. Counted as two the word
1113 // would have tied the line and held it vertical — the same word
1114 // weighed twice. The unsuffixed stem in the same sentence has always
1115 // gone this way, so the fix only made the two agree; it is not a
1116 // judgment that bichig belongs on a horizontal line.
1117 assert!(prefers_horizontal("ᠮᠣᠩᠭᠣᠯ is written"));
1118 assert!(prefers_horizontal(&format!("{genitive} is written")));
1119 // And bichig on its own is never called horizontal, joints or not.
1120 assert!(!prefers_horizontal("ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ ᠲᠡᠦᠬᠡ\u{202F}ᠶᠢ"));
1121 }
1122
1123 #[test]
1124 fn code_mode_keeps_indentation_as_blank_rows() {
1125 let config = LayoutConfig { max_latin_word_width: 24, preserve_spaces: true, ..Default::default() };
1126 let layout = layout_text(" let", &config);
1127 assert_eq!(layout.columns[0].slots, vec![
1128 Slot::Space(" ".into()),
1129 Slot::Space(" ".into()),
1130 Slot::LatinWord("let".into()),
1131 ]);
1132 }
1133
1134 /// The measure and the layout may not disagree about what a slot is.
1135 ///
1136 /// This is the invariant, not the answer for any one string. Pinning
1137 /// `prefers_horizontal("ᠢᠢis written") == false` records a symptom and
1138 /// becomes a puzzle at the next change; a disagreement between these two
1139 /// counts is always a defect. Issue #4 was exactly such a disagreement,
1140 /// and it ran in both directions: a Latin word swallowed after a bichig
1141 /// run, and a bichig run swallowed after a Latin word.
1142 ///
1143 /// Consecutive `LatinWord` slots count as one, because that is
1144 /// hyphenation: `internationalization` lays out as two slots and is one
1145 /// word with one opinion about direction. Consecutive `Upright` slots are
1146 /// not merged — each ideograph really is its own slot and its own vote.
1147 #[test]
1148 fn the_measure_counts_the_slots_the_layout_produces() {
1149 let cases = [
1150 // The pair from issue #4: identical content, opposite order.
1151 "ᠰᠠᠶᠢᠨ(sayin) good",
1152 "sayin(ᠰᠠᠶᠢᠨ) good",
1153 // Script boundaries with no space between, both directions.
1154 "writtenᠢᠢ",
1155 "ᠢᠢis written",
1156 "ᠢᠢ is written",
1157 "the ᠮᠣᠩᠭᠣᠬscript",
1158 "ᠢᠢ好",
1159 "好is written",
1160 // The suffix separator, joining and not joining.
1161 "ᠮᠣᠩᠭᠣᠬ\u{202F}ᠤᠨ",
1162 "ᠢᠢ\u{202F}is written",
1163 "ᠢᠢ\u{202F}ᠶᠨ is written",
1164 // Connectors inside a word, and one with no word to join.
1165 "kedU(n)",
1166 "gerel.net",
1167 "min-U yabun_a uu/UU",
1168 "ᠰᠠᠶᠢᠨ(",
1169 // Hyphenation: one word, two slots, one vote.
1170 "use-after-free",
1171 "internationalization",
1172 // Plain cases in both systems.
1173 "hello world",
1174 "山川异域,风月同天",
1175 "ᠢᠢ",
1176 "",
1177 ];
1178
1179 for text in cases {
1180 let layout = layout_text(text, &LayoutConfig::default());
1181 let (mut vertical, mut horizontal) = (0usize, 0usize);
1182 let mut previous_was_latin = false;
1183 for slot in layout.columns.iter().flat_map(|column| column.slots.iter()) {
1184 match slot {
1185 Slot::Upright(_) | Slot::MongolianRun(_) => {
1186 vertical += 1;
1187 previous_was_latin = false;
1188 }
1189 Slot::LatinWord(_) => {
1190 if !previous_was_latin {
1191 horizontal += 1;
1192 }
1193 previous_was_latin = true;
1194 }
1195 _ => previous_was_latin = false,
1196 }
1197 }
1198 assert_eq!(
1199 measure_slots(text),
1200 (vertical, horizontal),
1201 "measure and layout disagree on {text:?}"
1202 );
1203 }
1204 }
1205
1206 /// A word and its transliteration read the same way whichever comes first.
1207 ///
1208 /// This is the consequence a reader sees, and the reason issue #4 was not
1209 /// the low-severity miscount it first looked like: a glossary written
1210 /// bichig-first and one written Latin-first are the same content, and a
1211 /// list that mixes the two orders had its direction flip line by line.
1212 /// The value itself is not pinned — the pair agreeing is the property.
1213 #[test]
1214 fn a_transliteration_pair_reads_the_same_way_in_either_order() {
1215 assert_eq!(
1216 prefers_horizontal("ᠰᠠᠶᠢᠨ(sayin) good"),
1217 prefers_horizontal("sayin(ᠰᠠᠶᠢᠨ) good"),
1218 );
1219 }
1220}