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}