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 (mut vertical, mut horizontal) = (0usize, 0usize);
145    let mut in_word = false;
146    for cluster in text.graphemes(true) {
147        let Some(base) = cluster.chars().next() else { continue };
148        if is_cjk(base) {
149            vertical += 1;
150            in_word = false;
151        } else if is_mongolian(base) {
152            // A Mongolian run is one slot, so only its start counts.
153            if !in_word {
154                vertical += 1;
155            }
156            in_word = true;
157        } else if is_word_char(base) || (in_word && is_word_connector(base)) {
158            // A whole Latin word is one slot; count only where it begins.
159            if !in_word {
160                horizontal += 1;
161            }
162            in_word = true;
163        } else {
164            in_word = false;
165        }
166    }
167    horizontal > vertical
168}
169
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub enum Slot {
172    /// An upright ideograph, kana, or hangul grapheme cluster.
173    Upright(String),
174    /// One normal, horizontally readable Latin word in a vertical slot.
175    LatinWord(String),
176    /// Keep the run intact so a vertical-capable font can perform Mongolian
177    /// joining and vertical substitutions.
178    MongolianRun(String),
179    /// Whitespace from the source, carried through verbatim so a copied
180    /// paragraph matches what was written.
181    Space(String),
182    /// Punctuation that turns a quarter-circle in vertical text: brackets,
183    /// quotes, colons, dashes, ellipses, slashes.
184    ///
185    /// The host rotates the *view*. It must never swap the character for a
186    /// vertical presentation form (U+FE10–FE4F): those look correct and
187    /// silently destroy the document, because copy-paste, find-in-page, and
188    /// screen readers then yield codepoints the author never typed. A
189    /// renderer may decide how text appears; the text itself is content, and
190    /// content is not ours to edit.
191    VerticalPunctuation(String),
192    /// A stop or comma. These do not turn in vertical text — they move to the
193    /// upper-right corner of their em square. Again a view-only change.
194    CornerPunctuation(String),
195    /// Punctuation and other unsupported scripts remain upright for now.
196    Neutral(String),
197}
198
199/// Creates a top-to-bottom layout. Each source newline starts a new column to
200/// the *left*. Whitespace separates Latin words but does not create an empty
201/// slot. Long Latin words use predictable hard hyphens; dictionary hyphenation
202/// is intentionally a host-configurable future enhancement.
203///
204/// The unit of layout is the UAX #29 extended grapheme cluster, not the
205/// Unicode scalar. A variation selector must stay with the ideograph it
206/// selects a glyph for, a combining mark with its base, and a ZWJ emoji
207/// sequence with itself — split across slots they are silently dropped or
208/// rendered as their unjoined parts, which looks like text and is not.
209pub fn layout_text(input: &str, config: &LayoutConfig) -> Layout {
210    let mut columns = vec![Column { slots: Vec::new() }];
211    let mut latin_word = String::new();
212    let mut mongolian_run = String::new();
213    // Connectors seen with no word yet holding them. `-n_a` is one word, so a
214    // leading mark waits to see whether letters follow; if they do it joins
215    // them, and if they do not it becomes punctuation after all.
216    let mut pending_connectors = String::new();
217    // Whether those buffered connectors may still join a word that follows.
218    //
219    // A connector joins letters only when letters hold it on BOTH sides. At the
220    // start of a line, or after a space, `-n_a` is still one word — nothing has
221    // claimed the mark, so a following word may. But after an ideograph the
222    // mark has already been decided: `词尾(n)` is a Chinese sentence with a
223    // bracketed gloss, and the bracket must turn like every other bracket in
224    // that sentence. Without this the same paren lies flat next to Han and
225    // turns next to a space, which is what left a line of prose with some
226    // brackets rotated and some not.
227    let mut connectors_may_join = true;
228
229    let flush_latin = |columns: &mut Vec<Column>, word: &mut String| {
230        if word.is_empty() { return; }
231        for piece in split_latin_word(word, config.max_latin_word_width) {
232            columns.last_mut().unwrap().slots.push(Slot::LatinWord(piece));
233        }
234        word.clear();
235    };
236    let flush_mongolian = |columns: &mut Vec<Column>, run: &mut String| {
237        if !run.is_empty() {
238            columns.last_mut().unwrap().slots.push(Slot::MongolianRun(std::mem::take(run)));
239        }
240    };
241
242    // A cluster is classified by its base scalar — the first one. Trailing
243    // marks, joiners, and variation selectors ride along with it.
244    for cluster in input.graphemes(true) {
245        let base = match cluster.chars().next() {
246            Some(base) => base,
247            None => continue,
248        };
249        if base == '\n' || base == '\r' {
250            // "\r\n" is one cluster and must open one column, not two.
251            flush_latin(&mut columns, &mut latin_word);
252            flush_mongolian(&mut columns, &mut mongolian_run);
253            flush_pending(&mut columns, &mut pending_connectors);
254            connectors_may_join = true;
255            columns.push(Column { slots: Vec::new() });
256        } else if base.is_whitespace() {
257            flush_latin(&mut columns, &mut latin_word);
258            flush_mongolian(&mut columns, &mut mongolian_run);
259            flush_pending(&mut columns, &mut pending_connectors);
260            // The space is always kept. It is a character the author typed,
261            // and dropping it means a copied paragraph comes back as
262            // `可在gerel.net检索` — close enough to look fine and wrong to
263            // quote. `preserve_spaces` now decides only whether the space is
264            // made *visible* (code indentation), never whether it survives.
265            columns.last_mut().unwrap().slots.push(Slot::Space(cluster.to_owned()));
266            // A space frees the next mark to join whatever follows it.
267            connectors_may_join = true;
268        } else if is_mongolian(base) {
269            flush_latin(&mut columns, &mut latin_word);
270            // A connector still waiting for a word has just learned that no
271            // Latin word is coming. It must be emitted *here*, before the
272            // Mongolian run opens — left buffered it would surface when the
273            // run flushes, and `= ᠬ` would come back as `ᠬ=`. Reordering
274            // the author's characters is as wrong as replacing them.
275            flush_pending(&mut columns, &mut pending_connectors);
276            // Bichig is not Latin: `ᠱ(S)` is a script paired with its
277            // transliteration, so the bracket belongs to the sentence.
278            connectors_may_join = false;
279            mongolian_run.push_str(cluster);
280        } else if is_word_char(base) {
281            flush_mongolian(&mut columns, &mut mongolian_run);
282            latin_word.push_str(&std::mem::take(&mut pending_connectors));
283            latin_word.push_str(cluster);
284            connectors_may_join = true;
285        } else if is_word_connector(base) && (connectors_may_join || !latin_word.is_empty()) {
286            // Inside a word this mark is a letter: `min-U`, `kedU(n)`, and
287            // `-n_a` are one word each. Held on either side by letters it
288            // joins them; held by neither it is punctuation.
289            //
290            // A mark at the tail of an open word is buffered rather than
291            // appended, because whether it belongs to that word is not yet
292            // known: the letters in `kedU(n)` claim it, but the ideograph in
293            // `(n)形式` does not, and only the next character tells them apart.
294            // Buffering defers the choice to the branch that sees it.
295            //
296            // Unless it closes a bracket the word already opened. `kedU(n)` is
297            // one citation form and its `)` has letters on the left and its own
298            // `(` inside the word -- the pair is balanced, so the mark is the
299            // word's own and needs no lookahead. Deferring it would strand the
300            // closing bracket outside the word at end of input.
301            if closes_open_bracket(base, &latin_word) {
302                latin_word.push_str(cluster);
303            } else {
304                pending_connectors.push_str(cluster);
305            }
306        } else {
307            flush_latin(&mut columns, &mut latin_word);
308            flush_mongolian(&mut columns, &mut mongolian_run);
309            flush_pending(&mut columns, &mut pending_connectors);
310            let slot = if is_corner_punctuation(base) {
311                Slot::CornerPunctuation(cluster.to_owned())
312            } else if has_vertical_form(base) {
313                Slot::VerticalPunctuation(cluster.to_owned())
314            } else if is_cjk(base) {
315                Slot::Upright(cluster.to_owned())
316            } else {
317                Slot::Neutral(cluster.to_owned())
318            };
319            // An ideograph (or any other non-word character) on the left ends a
320            // word. A connector that follows it opens an aside in a sentence
321            // rather than continuing a citation form, so it must not be held
322            // back waiting for letters to join.
323            connectors_may_join = false;
324            columns.last_mut().unwrap().slots.push(slot);
325        }
326    }
327    flush_latin(&mut columns, &mut latin_word);
328    flush_mongolian(&mut columns, &mut mongolian_run);
329    flush_pending(&mut columns, &mut pending_connectors);
330    Layout { columns, progression: config.progression }
331}
332
333/// Whether a closing mark completes a bracket the word already holds open.
334///
335/// `kedU(n)` is one word: its `)` matches a `(` that letters already claimed,
336/// so it joins them with no lookahead. `词尾(n)形式` never gets here for its
337/// `)` — that `(` went out as punctuation, so the word holds nothing open and
338/// the closing mark is punctuation too. Balance is what separates a citation
339/// form from an aside in a sentence.
340fn closes_open_bracket(ch: char, word: &str) -> bool {
341    let opener = match ch {
342        ')' => '(',
343        ']' => '[',
344        '}' => '{',
345        '>' => '<',
346        _ => return false,
347    };
348    let opens = word.chars().filter(|&c| c == opener).count();
349    let closes = word.chars().filter(|&c| c == ch).count();
350    opens > closes
351}
352
353/// Emits buffered connectors that never found a word to join.
354fn flush_pending(columns: &mut Vec<Column>, pending: &mut String) {
355    if pending.is_empty() { return; }
356    for cluster in std::mem::take(pending).graphemes(true) {
357        let base = cluster.chars().next().unwrap_or(' ');
358        let slot = if is_corner_punctuation(base) {
359            Slot::CornerPunctuation(cluster.to_owned())
360        } else if has_vertical_form(base) {
361            Slot::VerticalPunctuation(cluster.to_owned())
362        } else {
363            Slot::Neutral(cluster.to_owned())
364        };
365        columns.last_mut().unwrap().slots.push(slot);
366    }
367}
368
369/// Splits on grapheme-cluster boundaries so a hard hyphen can never land
370/// between a base letter and its combining mark.
371///
372/// A hyphen the author already typed is a break opportunity, and taking it
373/// costs nothing: the pieces still concatenate to the source, so a word broken
374/// there is not edited at all. Counting to the cap is the fallback for a word
375/// that offers no such break — `use-after-free` must not come back as
376/// `use-after-f‐` / `ree`, which reads as a different term.
377///
378/// Only hyphens qualify. The other word connectors in `is_word_connector`
379/// *join* — splitting `gerel.net` at the dot or `kedU(n)` at the paren cuts a
380/// citation form in half.
381fn split_latin_word(word: &str, limit: usize) -> Vec<String> {
382    let limit = limit.max(2);
383    let clusters: Vec<&str> = word.graphemes(true).collect();
384    if clusters.len() <= limit { return vec![word.to_owned()]; }
385
386    // The rightmost hyphen that still fits, so the piece before it is as full
387    // as it can be. The break falls *after* the hyphen — that is where a
388    // hyphenated word is allowed to break, and it leaves the mark on the line
389    // that earned it.
390    let hyphen = clusters[..limit].iter()
391        .rposition(|cluster| matches!(*cluster, "-" | "\u{2010}"))
392        .map(|index| index + 1)
393        // A hyphen in the last position would leave an empty remainder; there
394        // is nothing after it to move to the next piece.
395        .filter(|split| *split < clusters.len());
396
397    match hyphen {
398        Some(split) => {
399            let mut pieces = vec![clusters[..split].concat()];
400            pieces.extend(split_latin_word(&clusters[split..].concat(), limit));
401            pieces
402        }
403        None => {
404            // No break of its own: count, and reserve one slot for the mark
405            // that says the break was ours.
406            let payload = limit - 1;
407            let mut pieces = vec![{
408                let mut piece: String = clusters[..payload].concat();
409                piece.push('‐');
410                piece
411            }];
412            pieces.extend(split_latin_word(&clusters[payload..].concat(), limit));
413            pieces
414        }
415    }
416}
417
418fn is_mongolian(ch: char) -> bool { matches!(ch as u32, 0x1800..=0x18AF | 0x11660..=0x1167F) }
419fn is_cjk(ch: char) -> bool { matches!(ch as u32,
420    0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF |
421    0x3040..=0x30FF | 0x31F0..=0x31FF | 0xAC00..=0xD7AF
422) }
423fn is_latin(ch: char) -> bool { matches!(ch as u32,
424    0x0041..=0x005A | 0x0061..=0x007A | 0x00C0..=0x024F | 0x1E00..=0x1EFF
425) }
426fn is_word_char(ch: char) -> bool { is_latin(ch) || ch.is_ascii_digit() || ch == '_' }
427
428/// Punctuation that behaves as a letter when it sits inside a word.
429///
430/// `min-U`, `kedU(n)`, `yabun_a`, `gerel.net`, `uu/UU` are single words, not a
431/// word and a mark and another word. Mongolian romanization uses these the way
432/// bichig uses the MVS and NNBSP: they join what is on either side, and
433/// splitting them puts a rotated bracket in the middle of a citation form.
434///
435/// Standing alone — with a space or an ideograph on the left — the same
436/// characters are ordinary punctuation and take a vertical form. So the class
437/// is contextual, and only the context decides.
438fn is_word_connector(ch: char) -> bool {
439    // `/` and `\` are deliberately absent: a slash separates alternatives
440    // (`uu/UU`, `ᠤ/ᠦ/ᠥ`) and each alternative wants its own row, so the slash
441    // breaks the word rather than joining it.
442    matches!(ch, ':' | '"' | '\'' | '(' | ')' | '{' | '}' |
443        '=' | '<' | '>' | '[' | ']' | '|' | '-' | '.' | '+' | '_')
444}
445/// Punctuation that has a distinct vertical presentation form.
446///
447/// Two families, one treatment. Brackets and quotes have compatibility forms
448/// in U+FE30–FE44; CJK commas, stops, colons, dashes, and ellipses have
449/// presentation forms in U+FE10–FE19. Both are reached the same way — a
450/// vertical writing mode plus the font's `vert`/`vrt2` feature — so both are
451/// classified together and the font decides. A stop is repositioned into the
452/// corner of its em square; a dash and a colon genuinely rotate. Which of
453/// those happens is the font's business, not ours.
454///
455/// Bare ASCII stays out. A colon in `a:b` must not rotate, and code and
456/// romanization are full of them; the fullwidth `:` in Chinese prose is a
457/// different character with different typography, and it is the one that
458/// wants the vertical form.
459/// Stops and commas, which reposition rather than rotate.
460fn is_corner_punctuation(ch: char) -> bool {
461    // The semicolon sits with the comma and the stop: they are all clause
462    // separators and behave as a family, so treating one of them differently
463    // makes a sentence look mis-set.
464    matches!(ch, ',' | '、' | '。' | '.' | '。' | '、' | ';' | ';')
465}
466
467fn has_vertical_form(ch: char) -> bool {
468    matches!(ch,
469        // Brackets, quotes, and the ASCII marks that stand between clauses.
470        // These reach here only when they are NOT inside a word — see
471        // `is_word_connector`, which claims them first when letters surround
472        // them.
473        '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' |
474        ':' | '"' | '\'' | '=' | '|' |
475        // Arrows point along the text. In a vertical column "onward" is
476        // downward, so a horizontally-pointing arrow has to turn to keep
477        // meaning what it meant. Vertical arrows already point along the
478        // flow and are left alone — turning them would aim them sideways.
479        '→' | '←' | '↔' | '⇒' | '⇐' | '⇔' | '⟶' | '⟵' | '⟷' |
480        '➔' | '➜' | '➝' | '➞' | '⇢' | '⇠' | '↦' | '↤' | '⊸' |
481        '(' | ')' | '[' | ']' | '{' | '}' |
482        '〈' | '〉' | '《' | '》' | '「' | '」' | '『' | '』' |
483        '【' | '】' | '〔' | '〕' | '“' | '”' | '‘' | '’' |
484        // Separators that genuinely turn. Stops, commas, and semicolons are
485        // handled by `is_corner_punctuation`; `!` and `?` stay upright.
486        ':' |
487        // Dashes, ellipses, and connectors that run along the column.
488        '—' | '―' | '-' | '…' | '‥' | '〜' | '~' | '|' | '‖')
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    #[test]
495    fn a_newline_creates_the_column_to_the_left() {
496        let layout = layout_text("中文\n日本", &LayoutConfig::default());
497        assert_eq!(layout.columns.len(), 2);
498        assert_eq!(layout.columns[0].slots, vec![Slot::Upright("中".into()), Slot::Upright("文".into())]);
499    }
500    #[test]
501    fn long_latin_words_are_bounded() {
502        let layout = layout_text("textremificationalization", &LayoutConfig::default());
503        assert!(layout.columns[0].slots.iter().all(|slot| match slot { Slot::LatinWord(s) => s.chars().count() <= 12, _ => true }));
504    }
505    #[test]
506    fn punctuation_with_a_vertical_form_gets_its_own_slot() {
507        let layout = layout_text("()", &LayoutConfig::default());
508        assert_eq!(layout.columns[0].slots, vec![
509            Slot::VerticalPunctuation("(".into()),
510            Slot::VerticalPunctuation(")".into()),
511        ]);
512    }
513    /// Stops and commas move to the corner of their em square; dashes and
514    /// ellipses turn. Two different treatments, and neither is a rotation of
515    /// the whole line or a change to the characters.
516    /// Semicolons keep company with commas and stops; arrows turn because a
517    /// horizontal arrow must keep pointing "onward" when onward is downward;
518    /// a vertical arrow already does and is left alone.
519    /// The punctuation contract, pinned character by character.
520    ///
521    /// This table is the agreement, not a sample of it. Every mark below was
522    /// decided deliberately and this classification has already churned more
523    /// than once — so it is written out in full and any change to it fails
524    /// here, loudly, instead of quietly altering how someone's document is
525    /// set. If a mark genuinely needs to move, move it *here first*.
526    #[test]
527    fn the_punctuation_contract() {
528        // Turns a quarter-circle. Brackets, quotes, colons, dashes, ellipses,
529        // and the ASCII operators that stand between clauses.
530        for mark in ['(', ')', '[', ']', '{', '}', '<', '>', ':', '"', '\'',
531                     '=', '|',
532                     '(', ')', '[', ']', '{', '}', '〈', '〉', '《', '》',
533                     '「', '」', '『', '』', '【', '】', '〔', '〕',
534                     '“', '”', '‘', '’', ':',
535                     '—', '―', '-', '…', '‥', '〜', '~', '|', '‖',
536                     '→', '←', '↔', '⇒', '⇐', '⇔', '⟶', '⟵'] {
537            let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
538            assert_eq!(layout.columns[0].slots[1],
539                Slot::VerticalPunctuation(mark.to_string()),
540                "{mark:?} must turn");
541        }
542        // Sits in the corner of its em square. Clause separators travel as a
543        // family; splitting one off makes a sentence look mis-set.
544        for mark in [',', '、', '。', '.', '。', '、', ';', ';'] {
545            let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
546            assert_eq!(layout.columns[0].slots[1],
547                Slot::CornerPunctuation(mark.to_string()),
548                "{mark:?} must go to the corner");
549        }
550        // Stays upright. A turned slash reads as a backslash; `↑`/`↓` already
551        // point along the flow; `!`/`?` are upright by convention.
552        for mark in ['/', '\\', '↑', '↓', '↕', '!', '?', '!', '?', '+', '*', '%'] {
553            let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
554            assert_eq!(layout.columns[0].slots[1],
555                Slot::Neutral(mark.to_string()),
556                "{mark:?} must stay upright");
557        }
558    }
559
560    #[test]
561    fn separators_and_arrows_are_classified_by_behaviour() {
562        let layout = layout_text("好;天→月↓日/水", &LayoutConfig::default());
563        assert_eq!(layout.columns[0].slots, vec![
564            Slot::Upright("好".into()),
565            Slot::CornerPunctuation(";".into()),
566            Slot::Upright("天".into()),
567            Slot::VerticalPunctuation("→".into()),
568            Slot::Upright("月".into()),
569            Slot::Neutral("↓".into()),
570            Slot::Upright("日".into()),
571            Slot::Neutral("/".into()),
572            Slot::Upright("水".into()),
573        ]);
574    }
575    #[test]
576    fn stops_go_to_the_corner_and_dashes_turn() {
577        let layout = layout_text("好,天。—…", &LayoutConfig::default());
578        assert_eq!(layout.columns[0].slots, vec![
579            Slot::Upright("好".into()),
580            Slot::CornerPunctuation(",".into()),
581            Slot::Upright("天".into()),
582            Slot::CornerPunctuation("。".into()),
583            Slot::VerticalPunctuation("—".into()),
584            Slot::VerticalPunctuation("…".into()),
585        ]);
586    }
587    #[test]
588    fn underscores_and_digits_stay_in_a_latin_identifier() {
589        let layout = layout_text("hi_nancy v2", &LayoutConfig::default());
590        assert_eq!(layout.columns[0].slots, vec![
591            Slot::LatinWord("hi_nancy".into()),
592            Slot::Space(" ".into()),
593            Slot::LatinWord("v2".into()),
594        ]);
595    }
596    /// Golden: the README's own sample. Every scalar accounted for, in order.
597    #[test]
598    fn golden_shanchuan_yiyu() {
599        let layout = layout_text("山川异域,风月同天。", &LayoutConfig::default());
600        assert_eq!(layout.progression, Progression::RightToLeft);
601        assert_eq!(layout.columns.len(), 1);
602        let expected: Vec<Slot> = "山川异域"
603            .chars()
604            .map(|c| Slot::Upright(c.to_string()))
605            .chain([Slot::CornerPunctuation(",".into())])
606            .chain("风月同天".chars().map(|c| Slot::Upright(c.to_string())))
607            .chain([Slot::CornerPunctuation("。".into())])
608            .collect();
609        assert_eq!(layout.columns[0].slots, expected);
610    }
611    /// A variation selector selects which glyph the font draws for an
612    /// ideograph. Split into its own slot it selects nothing and the reader
613    /// gets the wrong form of a character in someone's name.
614    #[test]
615    fn a_variation_selector_stays_with_its_ideograph() {
616        let layout = layout_text("葛\u{FE00}城", &LayoutConfig::default());
617        assert_eq!(layout.columns[0].slots, vec![
618            Slot::Upright("葛\u{FE00}".into()),
619            Slot::Upright("城".into()),
620        ]);
621    }
622    #[test]
623    fn combining_marks_stay_with_their_base() {
624        // Devanagari स + virama + त is one cluster; e + combining acute is one
625        // Latin grapheme inside a word.
626        let layout = layout_text("स\u{094D}त e\u{0301}cole", &LayoutConfig::default());
627        assert_eq!(layout.columns[0].slots, vec![
628            Slot::Neutral("स\u{094D}त".into()),
629            Slot::Space(" ".into()),
630            Slot::LatinWord("e\u{0301}cole".into()),
631        ]);
632    }
633    #[test]
634    fn zwj_and_flag_sequences_are_one_slot_each() {
635        let layout = layout_text("🇯🇵👨\u{200D}👩\u{200D}👧", &LayoutConfig::default());
636        assert_eq!(layout.columns[0].slots, vec![
637            Slot::Neutral("🇯🇵".into()),
638            Slot::Neutral("👨\u{200D}👩\u{200D}👧".into()),
639        ]);
640    }
641    #[test]
642    fn a_hard_hyphen_never_splits_a_cluster() {
643        // Twelve clusters, each a base plus a combining acute: the cap counts
644        // clusters, so no piece may end mid-cluster.
645        let word = "e\u{0301}".repeat(14);
646        let layout = layout_text(&word, &LayoutConfig::default());
647        for slot in &layout.columns[0].slots {
648            let Slot::LatinWord(piece) = slot else { panic!("expected Latin slots") };
649            assert!(!piece.starts_with('\u{0301}'), "piece begins with an orphaned mark: {piece:?}");
650            assert!(piece.graphemes(true).count() <= 12);
651        }
652    }
653    #[test]
654    fn a_crlf_newline_opens_one_column() {
655        let layout = layout_text("中\r\n日", &LayoutConfig::default());
656        assert_eq!(layout.columns.len(), 2);
657        assert_eq!(layout.columns[1].slots, vec![Slot::Upright("日".into())]);
658    }
659    #[test]
660    fn orientation_is_decided_by_ink_not_character_count() {
661        // Four ideographs outweigh three Latin words, because they carry more
662        // of the line. A naive character count would call this horizontal.
663        assert!(!prefers_horizontal("山川异域 the of and"));
664        assert!(prefers_horizontal("It is a truth universally acknowledged"));
665        assert!(!prefers_horizontal("春はあけぼの。やうやう白くなりゆく"));
666        // A few Latin words inside CJK stay vertical.
667        assert!(!prefers_horizontal("この API は便利です"));
668        // Mongolian is a vertical script and must never be called horizontal.
669        assert!(!prefers_horizontal("ᠮᠣᠩᠭᠤᠯ ᠤᠯᠤᠰ"));
670        // Program source is Latin-majority.
671        assert!(prefers_horizontal("fn main() { println!(\"hi\"); }"));
672    }
673    #[test]
674    fn punctuation_alone_does_not_decide_orientation() {
675        // No letters at all: nothing votes, so it stays vertical by default.
676        assert!(!prefers_horizontal(",。、;:!?"));
677        assert!(!prefers_horizontal("...,,,;;;"));
678    }
679    #[test]
680    fn horizontal_kinds_carry_their_conventional_measures() {
681        assert_eq!(HorizontalKind::Prose.default_wrap(), 66);
682        assert_eq!(HorizontalKind::Code.default_wrap(), 80);
683    }
684    #[test]
685    fn a_mark_between_letters_is_part_of_the_word() {
686        // `min-U`, `kedU(n)`, `gerel.net` are single citation forms. Splitting
687        // them drops a rotated bracket into the middle of a word.
688        let layout = layout_text("min-U kedU(n) gerel.net a=b:c", &LayoutConfig::default());
689        let words: Vec<&Slot> = layout.columns[0].slots.iter()
690            .filter(|slot| !matches!(slot, Slot::Space(_))).collect();
691        assert_eq!(words, vec![
692            &Slot::LatinWord("min-U".into()),
693            &Slot::LatinWord("kedU(n)".into()),
694            &Slot::LatinWord("gerel.net".into()),
695            &Slot::LatinWord("a=b:c".into()),
696        ]);
697    }
698    /// The other half of the contract above, and the boundary between them.
699    ///
700    /// A connector joins letters only when letters hold it on BOTH sides.
701    /// Pressed against an ideograph it is an ordinary bracket in a Chinese
702    /// sentence and takes its vertical form, exactly as `is_word_connector`
703    /// has always said it should ("with a space or an ideograph on the left
704    /// ... ordinary punctuation").
705    ///
706    /// This is the case a Chinese document teaching Mongolian is made of:
707    /// `不稳定词尾(n)` is a gloss inside prose, while `kedU(n)` in the glossary
708    /// beside it is one citation form. Same characters, different job, and the
709    /// character on the left is what tells them apart. Getting this wrong
710    /// leaves a sentence where some brackets turn and some lie flat.
711    #[test]
712    fn a_mark_against_an_ideograph_is_punctuation() {
713        let layout = layout_text("词尾(n)形式", &LayoutConfig::default());
714        assert_eq!(layout.columns[0].slots, vec![
715            Slot::Upright("词".into()),
716            Slot::Upright("尾".into()),
717            Slot::VerticalPunctuation("(".into()),
718            Slot::LatinWord("n".into()),
719            Slot::VerticalPunctuation(")".into()),
720            Slot::Upright("形".into()),
721            Slot::Upright("式".into()),
722        ]);
723        // A closing bracket followed by an ideograph closes the aside; it must
724        // not swallow the ideograph's side of the boundary either.
725        let mongolian = layout_text("ᠱ(S) 不", &LayoutConfig::default());
726        assert_eq!(mongolian.columns[0].slots, vec![
727            Slot::MongolianRun("ᠱ".into()),
728            Slot::VerticalPunctuation("(".into()),
729            Slot::LatinWord("S".into()),
730            Slot::VerticalPunctuation(")".into()),
731            Slot::Space(" ".into()),
732            Slot::Upright("不".into()),
733        ]);
734        // And the citation form is untouched: letters on both sides still join.
735        let citation = layout_text("kedU(n)", &LayoutConfig::default());
736        assert_eq!(citation.columns[0].slots, vec![Slot::LatinWord("kedU(n)".into())]);
737    }
738    /// A long word breaks at a hyphen it already has, rather than counting to
739    /// the cap and inserting one.
740    ///
741    /// `use-after-free` came back as `use-after-f‐` / `ree`, which reads as a
742    /// different term. A hyphen is already a sanctioned break point, so
743    /// breaking there needs no inserted mark at all — and a break that adds
744    /// nothing leaves the text identical to the source.
745    #[test]
746    fn a_long_word_breaks_at_the_hyphen_it_already_has() {
747        // 15 clusters against the default cap of 12.
748        assert_eq!(split_latin_word("use-after-free", 12),
749            vec!["use-after-".to_owned(), "free".to_owned()]);
750        // Nothing was inserted: the pieces rebuild the source exactly.
751        assert_eq!(split_latin_word("use-after-free", 12).concat(), "use-after-free");
752
753        // The rightmost hyphen that still fits wins, so each piece is as full
754        // as it can be. `-in-` would fit too, but leaves a longer remainder.
755        assert_eq!(split_latin_word("copy-on-write-semantics", 14),
756            vec!["copy-on-write-".to_owned(), "semantics".to_owned()]);
757
758        // A remainder that still overflows keeps breaking, and the tail falls
759        // back to counting when it holds no hyphen of its own.
760        assert_eq!(split_latin_word("well-known-supercalifragilistic", 12),
761            vec!["well-known-".to_owned(), "supercalifr‐".to_owned(), "agilistic".to_owned()]);
762
763        // A hyphen too far right to help is no break opportunity: the prefix
764        // before it still exceeds the cap, so counting takes over.
765        assert_eq!(split_latin_word("supercalifragilistic-x", 12),
766            vec!["supercalifr‐".to_owned(), "agilistic-x".to_owned()]);
767
768        // A trailing hyphen must not produce an empty piece.
769        assert_eq!(split_latin_word("autoconfiguration-", 12),
770            vec!["autoconfigu‐".to_owned(), "ration-".to_owned()]);
771
772        // Only hyphens are break opportunities. The other word connectors join
773        // — splitting `gerel.net` at the dot, or `kedU(n)` at the paren, breaks
774        // a citation form in half.
775        assert_eq!(split_latin_word("gerel.net.example.org", 12),
776            vec!["gerel.net.e‐".to_owned(), "xample.org".to_owned()]);
777
778        // Short enough to leave alone, hyphen or not.
779        assert_eq!(split_latin_word("use-after", 12), vec!["use-after".to_owned()]);
780    }
781
782    /// A hyphen break survives the full layout path, not just the splitter,
783    /// and leaves the source character-for-character intact.
784    #[test]
785    fn breaking_at_a_hyphen_alters_no_character() {
786        let source = "在 use-after-free 中";
787        let layout = layout_text(source, &LayoutConfig::default());
788        let mut rebuilt = String::new();
789        for column in &layout.columns {
790            for slot in &column.slots {
791                match slot {
792                    Slot::Upright(s) | Slot::LatinWord(s) | Slot::MongolianRun(s)
793                    | Slot::VerticalPunctuation(s) | Slot::CornerPunctuation(s)
794                    | Slot::Neutral(s) | Slot::Space(s) => rebuilt.push_str(s),
795                }
796            }
797        }
798        assert_eq!(rebuilt, source, "a hyphen break must insert nothing");
799        assert!(!rebuilt.contains('\u{2010}'), "no break mark was needed here");
800    }
801
802    /// A mark with letters on the right joins them too: `-n_a` is one word.
803    /// The invariant that matters most: layout never edits the text. Every
804    /// slot concatenated back together, in order, must equal the source with
805    /// only whitespace removed. A renderer that rewrites content is not
806    /// rendering it.
807    #[test]
808    fn layout_never_alters_a_single_character() {
809        // Includes marks pressed directly against Mongolian, Han, and Latin
810        // with no space to separate them — the arrangement that exposed a
811        // buffered connector surfacing on the wrong side of a run.
812        let source = "O = ᠥ。辅音 q(阳)/k(阴)= ᠬ,S=ᠱ,=ᠴ,j=ᠵ。规则:ᠱ(S) 不出现在 i 前,\u{201c}shi\u{201d} 音写作 si。";
813        let layout = layout_text(source, &LayoutConfig::default());
814        let mut rebuilt = String::new();
815        for column in &layout.columns {
816            for slot in &column.slots {
817                match slot {
818                    Slot::Upright(s) | Slot::LatinWord(s) | Slot::MongolianRun(s)
819                    | Slot::VerticalPunctuation(s) | Slot::CornerPunctuation(s)
820                    | Slot::Neutral(s) | Slot::Space(s) => rebuilt.push_str(s),
821                }
822            }
823        }
824        // Whitespace included: a dropped space makes `可在 gerel.net 检索`
825        // come back as `可在gerel.net检索`, which is close enough to look
826        // right and wrong to quote.
827        assert_eq!(rebuilt, source, "layout must not add, drop, or swap characters");
828    }
829
830    #[test]
831    fn a_leading_mark_joins_the_word_that_follows() {
832        let layout = layout_text("-n_a / -n_e", &LayoutConfig::default());
833        let marks: Vec<&Slot> = layout.columns[0].slots.iter()
834            .filter(|slot| !matches!(slot, Slot::Space(_))).collect();
835        assert_eq!(marks, vec![
836            &Slot::LatinWord("-n_a".into()),
837            // A slash separates alternatives, so each gets its own row. It
838            // stays upright: a turned slash reads as a backslash.
839            &Slot::Neutral("/".into()),
840            &Slot::LatinWord("-n_e".into()),
841        ]);
842    }
843    /// A slash breaks a word even between letters: `uu/UU` is two forms, and
844    /// each wants its own row.
845    #[test]
846    fn a_slash_always_breaks() {
847        let layout = layout_text("uu/UU", &LayoutConfig::default());
848        assert_eq!(layout.columns[0].slots, vec![
849            Slot::LatinWord("uu".into()),
850            Slot::Neutral("/".into()),
851            Slot::LatinWord("UU".into()),
852        ]);
853    }
854    /// Fullwidth punctuation must never be swallowed by an adjacent Latin
855    /// letter: `q(阳)` is a letter, a bracket, an ideograph, a bracket.
856    #[test]
857    fn fullwidth_marks_never_join_a_latin_word() {
858        let layout = layout_text("q(阳)", &LayoutConfig::default());
859        assert_eq!(layout.columns[0].slots, vec![
860            Slot::LatinWord("q".into()),
861            Slot::VerticalPunctuation("(".into()),
862            Slot::Upright("阳".into()),
863            Slot::VerticalPunctuation(")".into()),
864        ]);
865    }
866    #[test]
867    fn the_same_mark_standing_alone_takes_a_vertical_form() {
868        // Nothing holds it on the left, so it is punctuation again.
869        let layout = layout_text("好(天)= 川", &LayoutConfig::default());
870        let marks: Vec<&Slot> = layout.columns[0].slots.iter()
871            .filter(|slot| !matches!(slot, Slot::Space(_))).collect();
872        assert_eq!(marks, vec![
873            &Slot::Upright("好".into()),
874            &Slot::VerticalPunctuation("(".into()),
875            &Slot::Upright("天".into()),
876            &Slot::VerticalPunctuation(")".into()),
877            &Slot::VerticalPunctuation("=".into()),
878            &Slot::Upright("川".into()),
879        ]);
880    }
881    /// The orientation measure counts slots, not characters. A Chinese
882    /// sentence quoting romanization has more Latin letters than Han
883    /// characters and is still, plainly, a Chinese sentence.
884    #[test]
885    fn romanization_does_not_flip_a_chinese_sentence_horizontal() {
886        assert!(!prefers_horizontal(
887            "3. 将来否定 = 词典形 + Ugei:bi yabuqu Ugei(我不去),不是 *yabun_a Ugei。"));
888        assert!(!prefers_horizontal("4. 疑问词 uu/UU 也和谐:iren_e UU。"));
889        assert!(!prefers_horizontal(
890            "Ugei 否定\u{201d}有\u{201d},bisi 否定\u{201d}是\u{201d}:mori Ugei(没有马)vs tere mori bisi(那不是马)。"));
891        // Genuine English still goes horizontal.
892        assert!(prefers_horizontal("It is a truth universally acknowledged, that a single man"));
893    }
894
895    #[test]
896    fn progression_is_carried_as_data() {
897        let config = LayoutConfig { progression: Progression::LeftToRight, ..Default::default() };
898        assert_eq!(layout_text("ᠮᠣᠩᠭᠤᠯ", &config).progression, Progression::LeftToRight);
899    }
900    #[test]
901    fn code_mode_keeps_indentation_as_blank_rows() {
902        let config = LayoutConfig { max_latin_word_width: 24, preserve_spaces: true, ..Default::default() };
903        let layout = layout_text("  let", &config);
904        assert_eq!(layout.columns[0].slots, vec![
905            Slot::Space(" ".into()),
906            Slot::Space(" ".into()),
907            Slot::LatinWord("let".into()),
908        ]);
909    }
910}