Skip to main content

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}