use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Progression {
RightToLeft,
LeftToRight,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LayoutConfig {
pub max_latin_word_width: usize,
pub preserve_spaces: bool,
pub progression: Progression,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
max_latin_word_width: 12,
preserve_spaces: false,
progression: Progression::RightToLeft,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
pub columns: Vec<Column>,
pub progression: Progression,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Column {
pub slots: Vec<Slot>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Document {
pub blocks: Vec<Block>,
pub progression: Progression,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Block {
Vertical(Layout),
Horizontal(HorizontalBlock),
Table(Table),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HorizontalBlock {
pub text: String,
pub wrap_columns: usize,
pub kind: HorizontalKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HorizontalKind {
Prose,
Code,
}
impl HorizontalKind {
pub fn default_wrap(self) -> usize {
match self {
HorizontalKind::Prose => 66,
HorizontalKind::Code => 80,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Table {
pub rows: Vec<Row>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Row {
pub cells: Vec<Column>,
pub header: bool,
}
pub fn prefers_horizontal(text: &str) -> bool {
let (vertical, horizontal) = measure_slots(text);
horizontal > vertical
}
fn measure_slots(text: &str) -> (usize, usize) {
let (mut vertical, mut horizontal) = (0usize, 0usize);
let mut in_latin = false;
let mut in_bichig = false;
for cluster in text.graphemes(true) {
let Some(base) = cluster.chars().next() else { continue };
if is_cjk(base) {
vertical += 1;
in_latin = false;
in_bichig = false;
} else if is_mongolian(base) {
if !in_bichig {
vertical += 1;
}
in_latin = false;
in_bichig = true;
} else if is_word_char(base) {
if !in_latin {
horizontal += 1;
}
in_latin = true;
in_bichig = false;
} else if in_latin && is_word_connector(base) {
} else if in_bichig && is_suffix_separator(base) {
} else {
in_latin = false;
in_bichig = false;
}
}
(vertical, horizontal)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Slot {
Upright(String),
LatinWord(String),
MongolianRun(String),
Space(String),
VerticalPunctuation(String),
CornerPunctuation(String),
Neutral(String),
}
pub fn layout_text(input: &str, config: &LayoutConfig) -> Layout {
let mut columns = vec![Column { slots: Vec::new() }];
let mut latin_word = String::new();
let mut mongolian_run = String::new();
let mut pending_connectors = String::new();
let mut connectors_may_join = true;
let mut pending_separator = String::new();
let flush_latin = |columns: &mut Vec<Column>, word: &mut String| {
if word.is_empty() { return; }
for piece in split_latin_word(word, config.max_latin_word_width) {
columns.last_mut().unwrap().slots.push(Slot::LatinWord(piece));
}
word.clear();
};
let flush_mongolian = |columns: &mut Vec<Column>, run: &mut String| {
if !run.is_empty() {
columns.last_mut().unwrap().slots.push(Slot::MongolianRun(std::mem::take(run)));
}
};
for cluster in input.graphemes(true) {
let base = match cluster.chars().next() {
Some(base) => base,
None => continue,
};
if !pending_separator.is_empty() && !is_mongolian(base) {
flush_mongolian(&mut columns, &mut mongolian_run);
columns.last_mut().unwrap().slots
.push(Slot::Space(std::mem::take(&mut pending_separator)));
}
if base == '\n' || base == '\r' {
flush_latin(&mut columns, &mut latin_word);
flush_mongolian(&mut columns, &mut mongolian_run);
flush_pending(&mut columns, &mut pending_connectors);
connectors_may_join = true;
columns.push(Column { slots: Vec::new() });
} else if base.is_whitespace() {
if is_suffix_separator(base) && !mongolian_run.is_empty() {
pending_separator.push_str(cluster);
continue;
}
flush_latin(&mut columns, &mut latin_word);
flush_mongolian(&mut columns, &mut mongolian_run);
flush_pending(&mut columns, &mut pending_connectors);
columns.last_mut().unwrap().slots.push(Slot::Space(cluster.to_owned()));
connectors_may_join = true;
} else if is_mongolian(base) {
flush_latin(&mut columns, &mut latin_word);
flush_pending(&mut columns, &mut pending_connectors);
connectors_may_join = false;
mongolian_run.push_str(&std::mem::take(&mut pending_separator));
mongolian_run.push_str(cluster);
} else if is_word_char(base) {
flush_mongolian(&mut columns, &mut mongolian_run);
latin_word.push_str(&std::mem::take(&mut pending_connectors));
latin_word.push_str(cluster);
connectors_may_join = true;
} else if is_word_connector(base) && (connectors_may_join || !latin_word.is_empty()) {
if closes_open_bracket(base, &latin_word) {
latin_word.push_str(cluster);
} else {
pending_connectors.push_str(cluster);
}
} else {
flush_latin(&mut columns, &mut latin_word);
flush_mongolian(&mut columns, &mut mongolian_run);
flush_pending(&mut columns, &mut pending_connectors);
let slot = if is_corner_punctuation(base) {
Slot::CornerPunctuation(cluster.to_owned())
} else if has_vertical_form(base) {
Slot::VerticalPunctuation(cluster.to_owned())
} else if is_cjk(base) {
Slot::Upright(cluster.to_owned())
} else {
Slot::Neutral(cluster.to_owned())
};
connectors_may_join = false;
columns.last_mut().unwrap().slots.push(slot);
}
}
flush_latin(&mut columns, &mut latin_word);
flush_mongolian(&mut columns, &mut mongolian_run);
if !pending_separator.is_empty() {
columns.last_mut().unwrap().slots.push(Slot::Space(pending_separator));
}
flush_pending(&mut columns, &mut pending_connectors);
Layout { columns, progression: config.progression }
}
fn closes_open_bracket(ch: char, word: &str) -> bool {
let opener = match ch {
')' => '(',
']' => '[',
'}' => '{',
'>' => '<',
_ => return false,
};
let opens = word.chars().filter(|&c| c == opener).count();
let closes = word.chars().filter(|&c| c == ch).count();
opens > closes
}
fn flush_pending(columns: &mut Vec<Column>, pending: &mut String) {
if pending.is_empty() { return; }
for cluster in std::mem::take(pending).graphemes(true) {
let base = cluster.chars().next().unwrap_or(' ');
let slot = if is_corner_punctuation(base) {
Slot::CornerPunctuation(cluster.to_owned())
} else if has_vertical_form(base) {
Slot::VerticalPunctuation(cluster.to_owned())
} else {
Slot::Neutral(cluster.to_owned())
};
columns.last_mut().unwrap().slots.push(slot);
}
}
fn split_latin_word(word: &str, limit: usize) -> Vec<String> {
let limit = limit.max(2);
let clusters: Vec<&str> = word.graphemes(true).collect();
if clusters.len() <= limit { return vec![word.to_owned()]; }
let hyphen = clusters[..limit].iter()
.rposition(|cluster| matches!(*cluster, "-" | "\u{2010}"))
.map(|index| index + 1)
.filter(|split| *split < clusters.len());
match hyphen {
Some(split) => {
let mut pieces = vec![clusters[..split].concat()];
pieces.extend(split_latin_word(&clusters[split..].concat(), limit));
pieces
}
None => {
let payload = limit - 1;
let mut pieces = vec![{
let mut piece: String = clusters[..payload].concat();
piece.push('‐');
piece
}];
pieces.extend(split_latin_word(&clusters[payload..].concat(), limit));
pieces
}
}
}
pub fn is_mongolian(ch: char) -> bool { matches!(ch as u32, 0x1800..=0x18AF | 0x11660..=0x1167F) }
fn is_suffix_separator(ch: char) -> bool { ch == '\u{202F}' }
fn is_cjk(ch: char) -> bool { matches!(ch as u32,
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF |
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xAC00..=0xD7AF
) }
fn is_latin(ch: char) -> bool { matches!(ch as u32,
0x0041..=0x005A | 0x0061..=0x007A | 0x00C0..=0x024F | 0x1E00..=0x1EFF
) }
fn is_word_char(ch: char) -> bool { is_latin(ch) || ch.is_ascii_digit() || ch == '_' }
fn is_word_connector(ch: char) -> bool {
matches!(ch, ':' | '"' | '\'' | '(' | ')' | '{' | '}' |
'=' | '<' | '>' | '[' | ']' | '|' | '-' | '.' | '+' | '_')
}
fn is_corner_punctuation(ch: char) -> bool {
matches!(ch, ',' | '、' | '。' | '.' | '。' | '、' | ';' | ';')
}
fn has_vertical_form(ch: char) -> bool {
matches!(ch,
'(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' |
':' | '"' | '\'' | '=' | '|' |
'→' | '←' | '↔' | '⇒' | '⇐' | '⇔' | '⟶' | '⟵' | '⟷' |
'➔' | '➜' | '➝' | '➞' | '⇢' | '⇠' | '↦' | '↤' | '⊸' |
'(' | ')' | '[' | ']' | '{' | '}' |
'〈' | '〉' | '《' | '》' | '「' | '」' | '『' | '』' |
'【' | '】' | '〔' | '〕' | '“' | '”' | '‘' | '’' |
':' |
'—' | '―' | '-' | '–' | '‐' | '‑' | '‒' | '−' | '⸺' | '⸻' |
'…' | '‥' | '〜' | '~' | '|' | '‖')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_newline_creates_the_column_to_the_left() {
let layout = layout_text("中文\n日本", &LayoutConfig::default());
assert_eq!(layout.columns.len(), 2);
assert_eq!(layout.columns[0].slots, vec![Slot::Upright("中".into()), Slot::Upright("文".into())]);
}
#[test]
fn long_latin_words_are_bounded() {
let layout = layout_text("textremificationalization", &LayoutConfig::default());
assert!(layout.columns[0].slots.iter().all(|slot| match slot { Slot::LatinWord(s) => s.chars().count() <= 12, _ => true }));
}
#[test]
fn punctuation_with_a_vertical_form_gets_its_own_slot() {
let layout = layout_text("()", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::VerticalPunctuation("(".into()),
Slot::VerticalPunctuation(")".into()),
]);
}
#[test]
fn the_punctuation_contract() {
for mark in ['(', ')', '[', ']', '{', '}', '<', '>', ':', '"', '\'',
'=', '|',
'(', ')', '[', ']', '{', '}', '〈', '〉', '《', '》',
'「', '」', '『', '』', '【', '】', '〔', '〕',
'“', '”', '‘', '’', ':',
'—', '―', '-', '…', '‥', '〜', '~', '|', '‖',
'–', '‐', '‑', '‒', '−', '⸺', '⸻',
'→', '←', '↔', '⇒', '⇐', '⇔', '⟶', '⟵'] {
let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
assert_eq!(layout.columns[0].slots[1],
Slot::VerticalPunctuation(mark.to_string()),
"{mark:?} must turn");
}
for mark in [',', '、', '。', '.', '。', '、', ';', ';'] {
let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
assert_eq!(layout.columns[0].slots[1],
Slot::CornerPunctuation(mark.to_string()),
"{mark:?} must go to the corner");
}
for mark in ['/', '\\', '↑', '↓', '↕', '!', '?', '!', '?', '+', '*', '%'] {
let layout = layout_text(&format!("好{mark}好"), &LayoutConfig::default());
assert_eq!(layout.columns[0].slots[1],
Slot::Neutral(mark.to_string()),
"{mark:?} must stay upright");
}
}
#[test]
fn separators_and_arrows_are_classified_by_behaviour() {
let layout = layout_text("好;天→月↓日/水", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::Upright("好".into()),
Slot::CornerPunctuation(";".into()),
Slot::Upright("天".into()),
Slot::VerticalPunctuation("→".into()),
Slot::Upright("月".into()),
Slot::Neutral("↓".into()),
Slot::Upright("日".into()),
Slot::Neutral("/".into()),
Slot::Upright("水".into()),
]);
}
#[test]
fn stops_go_to_the_corner_and_dashes_turn() {
let layout = layout_text("好,天。—…", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::Upright("好".into()),
Slot::CornerPunctuation(",".into()),
Slot::Upright("天".into()),
Slot::CornerPunctuation("。".into()),
Slot::VerticalPunctuation("—".into()),
Slot::VerticalPunctuation("…".into()),
]);
}
#[test]
fn underscores_and_digits_stay_in_a_latin_identifier() {
let layout = layout_text("hi_nancy v2", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::LatinWord("hi_nancy".into()),
Slot::Space(" ".into()),
Slot::LatinWord("v2".into()),
]);
}
#[test]
fn golden_shanchuan_yiyu() {
let layout = layout_text("山川异域,风月同天。", &LayoutConfig::default());
assert_eq!(layout.progression, Progression::RightToLeft);
assert_eq!(layout.columns.len(), 1);
let expected: Vec<Slot> = "山川异域"
.chars()
.map(|c| Slot::Upright(c.to_string()))
.chain([Slot::CornerPunctuation(",".into())])
.chain("风月同天".chars().map(|c| Slot::Upright(c.to_string())))
.chain([Slot::CornerPunctuation("。".into())])
.collect();
assert_eq!(layout.columns[0].slots, expected);
}
#[test]
fn a_variation_selector_stays_with_its_ideograph() {
let layout = layout_text("葛\u{FE00}城", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::Upright("葛\u{FE00}".into()),
Slot::Upright("城".into()),
]);
}
#[test]
fn combining_marks_stay_with_their_base() {
let layout = layout_text("स\u{094D}त e\u{0301}cole", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::Neutral("स\u{094D}त".into()),
Slot::Space(" ".into()),
Slot::LatinWord("e\u{0301}cole".into()),
]);
}
#[test]
fn zwj_and_flag_sequences_are_one_slot_each() {
let layout = layout_text("🇯🇵👨\u{200D}👩\u{200D}👧", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::Neutral("🇯🇵".into()),
Slot::Neutral("👨\u{200D}👩\u{200D}👧".into()),
]);
}
#[test]
fn a_hard_hyphen_never_splits_a_cluster() {
let word = "e\u{0301}".repeat(14);
let layout = layout_text(&word, &LayoutConfig::default());
for slot in &layout.columns[0].slots {
let Slot::LatinWord(piece) = slot else { panic!("expected Latin slots") };
assert!(!piece.starts_with('\u{0301}'), "piece begins with an orphaned mark: {piece:?}");
assert!(piece.graphemes(true).count() <= 12);
}
}
#[test]
fn a_crlf_newline_opens_one_column() {
let layout = layout_text("中\r\n日", &LayoutConfig::default());
assert_eq!(layout.columns.len(), 2);
assert_eq!(layout.columns[1].slots, vec![Slot::Upright("日".into())]);
}
#[test]
fn orientation_is_decided_by_ink_not_character_count() {
assert!(!prefers_horizontal("山川异域 the of and"));
assert!(prefers_horizontal("It is a truth universally acknowledged"));
assert!(!prefers_horizontal("春はあけぼの。やうやう白くなりゆく"));
assert!(!prefers_horizontal("この API は便利です"));
assert!(!prefers_horizontal("ᠮᠣᠩᠭᠤᠯ ᠤᠯᠤᠰ"));
assert!(prefers_horizontal("fn main() { println!(\"hi\"); }"));
}
#[test]
fn punctuation_alone_does_not_decide_orientation() {
assert!(!prefers_horizontal(",。、;:!?"));
assert!(!prefers_horizontal("...,,,;;;"));
}
#[test]
fn horizontal_kinds_carry_their_conventional_measures() {
assert_eq!(HorizontalKind::Prose.default_wrap(), 66);
assert_eq!(HorizontalKind::Code.default_wrap(), 80);
}
#[test]
fn a_mark_between_letters_is_part_of_the_word() {
let layout = layout_text("min-U kedU(n) gerel.net a=b:c", &LayoutConfig::default());
let words: Vec<&Slot> = layout.columns[0].slots.iter()
.filter(|slot| !matches!(slot, Slot::Space(_))).collect();
assert_eq!(words, vec![
&Slot::LatinWord("min-U".into()),
&Slot::LatinWord("kedU(n)".into()),
&Slot::LatinWord("gerel.net".into()),
&Slot::LatinWord("a=b:c".into()),
]);
}
#[test]
fn a_mark_against_an_ideograph_is_punctuation() {
let layout = layout_text("词尾(n)形式", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::Upright("词".into()),
Slot::Upright("尾".into()),
Slot::VerticalPunctuation("(".into()),
Slot::LatinWord("n".into()),
Slot::VerticalPunctuation(")".into()),
Slot::Upright("形".into()),
Slot::Upright("式".into()),
]);
let mongolian = layout_text("ᠱ(S) 不", &LayoutConfig::default());
assert_eq!(mongolian.columns[0].slots, vec![
Slot::MongolianRun("ᠱ".into()),
Slot::VerticalPunctuation("(".into()),
Slot::LatinWord("S".into()),
Slot::VerticalPunctuation(")".into()),
Slot::Space(" ".into()),
Slot::Upright("不".into()),
]);
let citation = layout_text("kedU(n)", &LayoutConfig::default());
assert_eq!(citation.columns[0].slots, vec![Slot::LatinWord("kedU(n)".into())]);
}
#[test]
fn a_long_word_breaks_at_the_hyphen_it_already_has() {
assert_eq!(split_latin_word("use-after-free", 12),
vec!["use-after-".to_owned(), "free".to_owned()]);
assert_eq!(split_latin_word("use-after-free", 12).concat(), "use-after-free");
assert_eq!(split_latin_word("copy-on-write-semantics", 14),
vec!["copy-on-write-".to_owned(), "semantics".to_owned()]);
assert_eq!(split_latin_word("well-known-supercalifragilistic", 12),
vec!["well-known-".to_owned(), "supercalifr‐".to_owned(), "agilistic".to_owned()]);
assert_eq!(split_latin_word("supercalifragilistic-x", 12),
vec!["supercalifr‐".to_owned(), "agilistic-x".to_owned()]);
assert_eq!(split_latin_word("autoconfiguration-", 12),
vec!["autoconfigu‐".to_owned(), "ration-".to_owned()]);
assert_eq!(split_latin_word("gerel.net.example.org", 12),
vec!["gerel.net.e‐".to_owned(), "xample.org".to_owned()]);
assert_eq!(split_latin_word("use-after", 12), vec!["use-after".to_owned()]);
}
#[test]
fn breaking_at_a_hyphen_alters_no_character() {
let source = "在 use-after-free 中";
let layout = layout_text(source, &LayoutConfig::default());
let mut rebuilt = String::new();
for column in &layout.columns {
for slot in &column.slots {
match slot {
Slot::Upright(s) | Slot::LatinWord(s) | Slot::MongolianRun(s)
| Slot::VerticalPunctuation(s) | Slot::CornerPunctuation(s)
| Slot::Neutral(s) | Slot::Space(s) => rebuilt.push_str(s),
}
}
}
assert_eq!(rebuilt, source, "a hyphen break must insert nothing");
assert!(!rebuilt.contains('\u{2010}'), "no break mark was needed here");
}
#[test]
fn layout_never_alters_a_single_character() {
let source = "O = ᠥ。辅音 q(阳)/k(阴)= ᠬ,S=ᠱ,=ᠴ,j=ᠵ。规则:ᠱ(S) 不出现在 i 前,\u{201c}shi\u{201d} 音写作 si。";
let layout = layout_text(source, &LayoutConfig::default());
let mut rebuilt = String::new();
for column in &layout.columns {
for slot in &column.slots {
match slot {
Slot::Upright(s) | Slot::LatinWord(s) | Slot::MongolianRun(s)
| Slot::VerticalPunctuation(s) | Slot::CornerPunctuation(s)
| Slot::Neutral(s) | Slot::Space(s) => rebuilt.push_str(s),
}
}
}
assert_eq!(rebuilt, source, "layout must not add, drop, or swap characters");
}
#[test]
fn a_leading_mark_joins_the_word_that_follows() {
let layout = layout_text("-n_a / -n_e", &LayoutConfig::default());
let marks: Vec<&Slot> = layout.columns[0].slots.iter()
.filter(|slot| !matches!(slot, Slot::Space(_))).collect();
assert_eq!(marks, vec![
&Slot::LatinWord("-n_a".into()),
&Slot::Neutral("/".into()),
&Slot::LatinWord("-n_e".into()),
]);
}
#[test]
fn a_slash_always_breaks() {
let layout = layout_text("uu/UU", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::LatinWord("uu".into()),
Slot::Neutral("/".into()),
Slot::LatinWord("UU".into()),
]);
}
#[test]
fn fullwidth_marks_never_join_a_latin_word() {
let layout = layout_text("q(阳)", &LayoutConfig::default());
assert_eq!(layout.columns[0].slots, vec![
Slot::LatinWord("q".into()),
Slot::VerticalPunctuation("(".into()),
Slot::Upright("阳".into()),
Slot::VerticalPunctuation(")".into()),
]);
}
#[test]
fn the_same_mark_standing_alone_takes_a_vertical_form() {
let layout = layout_text("好(天)= 川", &LayoutConfig::default());
let marks: Vec<&Slot> = layout.columns[0].slots.iter()
.filter(|slot| !matches!(slot, Slot::Space(_))).collect();
assert_eq!(marks, vec![
&Slot::Upright("好".into()),
&Slot::VerticalPunctuation("(".into()),
&Slot::Upright("天".into()),
&Slot::VerticalPunctuation(")".into()),
&Slot::VerticalPunctuation("=".into()),
&Slot::Upright("川".into()),
]);
}
#[test]
fn romanization_does_not_flip_a_chinese_sentence_horizontal() {
assert!(!prefers_horizontal(
"3. 将来否定 = 词典形 + Ugei:bi yabuqu Ugei(我不去),不是 *yabun_a Ugei。"));
assert!(!prefers_horizontal("4. 疑问词 uu/UU 也和谐:iren_e UU。"));
assert!(!prefers_horizontal(
"Ugei 否定\u{201d}有\u{201d},bisi 否定\u{201d}是\u{201d}:mori Ugei(没有马)vs tere mori bisi(那不是马)。"));
assert!(prefers_horizontal("It is a truth universally acknowledged, that a single man"));
}
#[test]
fn progression_is_carried_as_data() {
let config = LayoutConfig { progression: Progression::LeftToRight, ..Default::default() };
assert_eq!(layout_text("ᠮᠣᠩᠭᠤᠯ", &config).progression, Progression::LeftToRight);
}
#[test]
fn a_suffix_separator_is_part_of_the_word_not_a_space() {
let genitive = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ";
assert_eq!(layout_text(genitive, &LayoutConfig::default()).columns[0].slots,
vec![Slot::MongolianRun(genitive.into())],
"the joint must stay inside the run it joins");
let dative = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠳᠤ\u{202F}ᠪᠠᠨ";
assert_eq!(layout_text(dative, &LayoutConfig::default()).columns[0].slots,
vec![Slot::MongolianRun(dative.into())]);
let phrase = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ ᠲᠡᠦᠬᠡ\u{202F}ᠶᠢ";
assert_eq!(layout_text(phrase, &LayoutConfig::default()).columns[0].slots, vec![
Slot::MongolianRun("ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ".into()),
Slot::Space(" ".into()),
Slot::MongolianRun("ᠲᠡᠦᠬᠡ\u{202F}ᠶᠢ".into()),
]);
}
#[test]
fn a_narrow_space_outside_bichig_is_still_a_space() {
assert_eq!(layout_text("好\u{202F}ᠤᠨ", &LayoutConfig::default()).columns[0].slots, vec![
Slot::Upright("好".into()),
Slot::Space("\u{202F}".into()),
Slot::MongolianRun("ᠤᠨ".into()),
]);
assert_eq!(layout_text("ᠮᠣᠩᠭᠣᠯ\u{202F}好", &LayoutConfig::default()).columns[0].slots, vec![
Slot::MongolianRun("ᠮᠣᠩᠭᠣᠯ".into()),
Slot::Space("\u{202F}".into()),
Slot::Upright("好".into()),
]);
let broken = layout_text("ᠮᠣᠩᠭᠣᠯ\u{202F}\nᠤᠨ", &LayoutConfig::default());
assert_eq!(broken.columns[0].slots, vec![
Slot::MongolianRun("ᠮᠣᠩᠭᠣᠯ".into()),
Slot::Space("\u{202F}".into()),
]);
assert_eq!(broken.columns[1].slots, vec![Slot::MongolianRun("ᠤᠨ".into())]);
assert_eq!(layout_text("ᠮᠣᠩᠭᠣᠯ\u{202F}", &LayoutConfig::default()).columns[0].slots, vec![
Slot::MongolianRun("ᠮᠣᠩᠭᠣᠯ".into()),
Slot::Space("\u{202F}".into()),
]);
assert_eq!(layout_text("Chapitre\u{202F}: 1", &LayoutConfig::default()).columns[0].slots, vec![
Slot::LatinWord("Chapitre".into()),
Slot::Space("\u{202F}".into()),
Slot::VerticalPunctuation(":".into()),
Slot::Space(" ".into()),
Slot::LatinWord("1".into()),
]);
}
#[test]
fn suffix_separators_survive_the_round_trip() {
let source = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ ᠲᠡᠦᠬᠡ\u{202F}ᠶᠢ 读\u{202F}好 ᠪᠢ\u{202F}\nᠮᠣᠩᠭᠣᠯ\u{202F}";
let layout = layout_text(source, &LayoutConfig::default());
let mut rebuilt = String::new();
for (index, column) in layout.columns.iter().enumerate() {
if index > 0 { rebuilt.push('\n'); }
for slot in &column.slots {
match slot {
Slot::Upright(s) | Slot::LatinWord(s) | Slot::MongolianRun(s)
| Slot::VerticalPunctuation(s) | Slot::CornerPunctuation(s)
| Slot::Neutral(s) | Slot::Space(s) => rebuilt.push_str(s),
}
}
}
assert_eq!(rebuilt, source, "a joint must not be added, dropped, or moved");
}
#[test]
fn the_orientation_measure_counts_a_suffixed_word_once() {
let genitive = "ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ";
assert_eq!(layout_text(genitive, &LayoutConfig::default()).columns[0].slots.len(), 1);
assert!(prefers_horizontal("ᠮᠣᠩᠭᠣᠯ is written"));
assert!(prefers_horizontal(&format!("{genitive} is written")));
assert!(!prefers_horizontal("ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ ᠲᠡᠦᠬᠡ\u{202F}ᠶᠢ"));
}
#[test]
fn code_mode_keeps_indentation_as_blank_rows() {
let config = LayoutConfig { max_latin_word_width: 24, preserve_spaces: true, ..Default::default() };
let layout = layout_text(" let", &config);
assert_eq!(layout.columns[0].slots, vec![
Slot::Space(" ".into()),
Slot::Space(" ".into()),
Slot::LatinWord("let".into()),
]);
}
#[test]
fn the_measure_counts_the_slots_the_layout_produces() {
let cases = [
"ᠰᠠᠶᠢᠨ(sayin) good",
"sayin(ᠰᠠᠶᠢᠨ) good",
"writtenᠢᠢ",
"ᠢᠢis written",
"ᠢᠢ is written",
"the ᠮᠣᠩᠭᠣᠬscript",
"ᠢᠢ好",
"好is written",
"ᠮᠣᠩᠭᠣᠬ\u{202F}ᠤᠨ",
"ᠢᠢ\u{202F}is written",
"ᠢᠢ\u{202F}ᠶᠨ is written",
"kedU(n)",
"gerel.net",
"min-U yabun_a uu/UU",
"ᠰᠠᠶᠢᠨ(",
"use-after-free",
"internationalization",
"hello world",
"山川异域,风月同天",
"ᠢᠢ",
"",
];
for text in cases {
let layout = layout_text(text, &LayoutConfig::default());
let (mut vertical, mut horizontal) = (0usize, 0usize);
let mut previous_was_latin = false;
for slot in layout.columns.iter().flat_map(|column| column.slots.iter()) {
match slot {
Slot::Upright(_) | Slot::MongolianRun(_) => {
vertical += 1;
previous_was_latin = false;
}
Slot::LatinWord(_) => {
if !previous_was_latin {
horizontal += 1;
}
previous_was_latin = true;
}
_ => previous_was_latin = false,
}
}
assert_eq!(
measure_slots(text),
(vertical, horizontal),
"measure and layout disagree on {text:?}"
);
}
}
#[test]
fn a_transliteration_pair_reads_the_same_way_in_either_order() {
assert_eq!(
prefers_horizontal("ᠰᠠᠶᠢᠨ(sayin) good"),
prefers_horizontal("sayin(ᠰᠠᠶᠢᠨ) good"),
);
}
}