use std::borrow::Cow;
use unicode_bidi::{bidi_class, BidiClass, BidiDataSource, BidiInfo, HardcodedBidiData, Level};
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct VisualChar {
pub ch: char,
pub item: Option<usize>,
}
pub(crate) fn mirror_char(c: char) -> char {
crate::bidi_mirroring::mirrored(c).unwrap_or(c)
}
fn paired_bracket(c: char) -> Option<(char, bool)> {
HardcodedBidiData
.bidi_matched_opening_bracket(c)
.map(|pair| (pair.opening, pair.is_open))
}
pub(crate) fn is_combining_mark(c: char) -> bool {
unicode_normalization::char::is_combining_mark(c)
|| unicode_normalization::char::canonical_combining_class(c) != 0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Strong {
Left,
Right,
}
fn letter_direction(c: char) -> Option<Strong> {
match class_of(c) {
BidiClass::L => Some(Strong::Left),
BidiClass::R | BidiClass::AL => Some(Strong::Right),
_ => None,
}
}
pub(crate) const GLYPH_JOINER: char = '\u{FFFE}';
fn class_of(c: char) -> BidiClass {
if c == GLYPH_JOINER {
BidiClass::BN
} else {
bidi_class(c)
}
}
pub(crate) fn push_glyph_characters(out: &mut String, label: &str) {
let mut chars = label.chars();
let several = chars.next().is_some() && chars.next().is_some();
if !several || !label.chars().any(crate::text_utils::is_rtl_char) {
out.push_str(label);
return;
}
for (i, ch) in label.chars().enumerate() {
if i > 0 {
out.push(GLYPH_JOINER);
}
out.push(ch);
}
}
pub(crate) fn strip_glyph_joiners(text: &mut String) {
if text.contains(GLYPH_JOINER) {
text.retain(|c| c != GLYPH_JOINER);
}
}
fn reverse_clusters(positions: &mut [usize], ch_at: impl Fn(usize) -> char) {
let mut clusters: Vec<(usize, usize)> = Vec::new();
for (i, &p) in positions.iter().enumerate() {
let continues = i > 0
&& (is_combining_mark(ch_at(p))
|| ch_at(p) == GLYPH_JOINER
|| ch_at(positions[i - 1]) == GLYPH_JOINER);
if !continues {
clusters.push((i, i + 1));
} else if let Some(last) = clusters.last_mut() {
last.1 = i + 1;
}
}
let reordered: Vec<usize> = clusters
.iter()
.rev()
.flat_map(|&(start, end)| positions[start..end].iter().copied())
.collect();
positions.copy_from_slice(&reordered);
}
fn reverse_runs_at_least(
positions: &mut [usize],
level_at: &[u8],
min: u8,
ch_at: impl Fn(usize) -> char,
) {
let mut start = 0;
while start < positions.len() {
if level_at[positions[start]] < min {
start += 1;
continue;
}
let mut end = start + 1;
while end < positions.len() && level_at[positions[end]] >= min {
end += 1;
}
reverse_clusters(&mut positions[start..end], &ch_at);
start = end;
}
}
fn mirror_at_odd_levels(chars: &mut [char], levels: &[u8]) {
for (c, &level) in chars.iter_mut().zip(levels) {
if level % 2 == 1 {
*c = mirror_char(*c);
}
}
}
struct AnalysisChar {
ch: char,
position: Option<usize>,
}
fn resolved_levels(sequence: &[AnalysisChar], rtl_base: bool, out_len: usize) -> Vec<u8> {
let (level, mark) = if rtl_base {
(Level::rtl(), '\u{200F}')
} else {
(Level::ltr(), '\u{200E}')
};
let mut text = String::with_capacity(sequence.len() * 2 + 3);
text.push(mark);
let is_digit = |c: char| matches!(class_of(c), BidiClass::EN | BidiClass::AN);
let is_dash =
|c: char| class_of(c) == BidiClass::ES || matches!(c, '\u{2010}'..='\u{2015}' | '\u{2212}');
for (i, a) in sequence.iter().enumerate() {
text.push(match class_of(a.ch) {
BidiClass::B | BidiClass::S => ' ',
_ if a.ch == GLYPH_JOINER => '\u{200B}',
_ if is_dash(a.ch)
&& i > 0
&& is_digit(sequence[i - 1].ch)
&& sequence.get(i + 1).is_some_and(|n| is_digit(n.ch)) =>
{
','
}
_ => a.ch,
});
}
let info = BidiInfo::new(&text, Some(level));
let mut out = vec![level.number(); out_len];
let Some(para) = info.paragraphs.first() else {
return out;
};
let levels = info.reordered_levels_per_char(para, para.range.clone());
for (a, l) in sequence.iter().zip(levels.iter().skip(1)) {
if let Some(p) = a.position {
out[p] = l.number();
}
}
out
}
struct BracketPair {
open: usize,
close: usize,
level: u8,
alternative: Option<u8>,
}
fn bracket_pairs(chars: &[char], rtl_base: bool) -> Vec<BracketPair> {
let base_level = u8::from(rtl_base);
let (base_dir, opposite_dir) = if rtl_base {
(Strong::Right, Strong::Left)
} else {
(Strong::Left, Strong::Right)
};
let mut stack: Vec<(char, usize)> = Vec::new();
let mut pairs: Vec<BracketPair> = Vec::new();
for (i, &c) in chars.iter().enumerate() {
let Some((opening, is_open)) = paired_bracket(c) else {
continue;
};
if is_open {
stack.push((opening, i));
continue;
}
let Some(depth) = stack.iter().rposition(|&(open, _)| open == opening) else {
continue;
};
let open = stack[depth].1;
stack.truncate(depth);
let mut inner: Vec<Strong> = chars[open + 1..i]
.iter()
.filter_map(|&c| letter_direction(c))
.collect();
if inner.is_empty()
&& chars[open + 1..i]
.iter()
.any(|&c| matches!(class_of(c), BidiClass::EN | BidiClass::AN))
{
inner.push(opposite_dir);
}
let (level, alternative) = if inner.contains(&base_dir) {
(base_level, None)
} else if !inner.is_empty() {
let context = if rtl_base {
chars[..open]
.iter()
.rev()
.find_map(|&c| letter_direction(c))
} else {
chars[i + 1..].iter().find_map(|&c| letter_direction(c))
};
if context == Some(opposite_dir) {
(base_level + 1, Some(base_level))
} else {
(base_level, Some(base_level + 1))
}
} else {
(u8::MAX, None)
};
pairs.push(BracketPair {
open,
close: i,
level,
alternative,
});
}
pairs
}
fn phantom_before_pair(chars: &[char], pair: &BracketPair, level: u8) -> char {
if level % 2 == 1 {
match chars[pair.close + 1..]
.iter()
.copied()
.find_map(|c| letter_class(c).filter(|&k| k != BidiClass::L))
{
Some(BidiClass::AL) => '\u{061C}',
_ => '\u{200F}',
}
} else {
'\u{200E}'
}
}
fn letter_class(c: char) -> Option<BidiClass> {
match class_of(c) {
class @ (BidiClass::L | BidiClass::R | BidiClass::AL) => Some(class),
_ => None,
}
}
fn analysis_sequence(
chars: &[char],
read_back: &[usize],
rtl_base: bool,
replaced: &[(usize, char)],
phantoms: &[(usize, char)],
) -> Vec<AnalysisChar> {
let class_at = |k: usize| class_of(chars[read_back[k]]);
let mut tokens: Vec<(usize, usize)> = Vec::new();
let mut k = 0;
while k < read_back.len() {
let mut j = k;
while j < read_back.len() && class_at(j) == BidiClass::ET {
j += 1;
}
if !(j < read_back.len() && class_at(j) == BidiClass::EN) {
k += 1;
continue;
}
loop {
while j < read_back.len() && class_at(j) == BidiClass::EN {
j += 1;
}
if j + 1 < read_back.len()
&& matches!(class_at(j), BidiClass::CS | BidiClass::ES)
&& class_at(j + 1) == BidiClass::EN
{
j += 1;
continue;
}
while j < read_back.len() && class_at(j) == BidiClass::ET {
j += 1;
}
break;
}
tokens.push((k, j));
k = j;
}
let nearest_letter =
|mut it: Box<dyn Iterator<Item = usize> + '_>| it.find_map(|q| letter_class(chars[q]));
let touching_letter = |mut it: Box<dyn Iterator<Item = usize> + '_>| {
it.find(|&q| {
paired_bracket(chars[q]).is_some()
|| !matches!(
class_of(chars[q]),
BidiClass::WS
| BidiClass::ON
| BidiClass::CS
| BidiClass::ES
| BidiClass::ET
| BidiClass::NSM
| BidiClass::BN
)
})
.and_then(|q| letter_class(chars[q]))
};
let mut sequence: Vec<AnalysisChar> = Vec::with_capacity(read_back.len() + 4);
for (k, &p) in read_back.iter().enumerate() {
for &(_, mark) in phantoms.iter().filter(|(at, _)| *at == k) {
sequence.push(AnalysisChar {
ch: mark,
position: None,
});
}
if let Some(&(_, end)) = tokens.iter().find(|&&(start, _)| start == k) {
let before = || read_back[..k].iter().rev().copied();
let after = || read_back[end..].iter().copied();
let mark = if rtl_base {
match (
touching_letter(Box::new(before())),
touching_letter(Box::new(after())),
) {
(b, Some(BidiClass::L)) if b != Some(BidiClass::L) => Some('\u{200E}'),
(Some(BidiClass::L), a) if a != Some(BidiClass::L) => Some('\u{200E}'),
_ => None,
}
} else {
match (
nearest_letter(Box::new(before())),
nearest_letter(Box::new(after())),
) {
(Some(b), Some(a)) if b != BidiClass::L && a != BidiClass::L => {
Some(if a == BidiClass::AL {
'\u{061C}'
} else {
'\u{200F}'
})
}
_ => None,
}
};
if let Some(mark) = mark {
sequence.push(AnalysisChar {
ch: mark,
position: None,
});
}
}
let ch = match replaced.iter().find(|(q, _)| *q == p) {
Some(&(_, replacement)) => replacement,
None => chars[p],
};
sequence.push(AnalysisChar {
ch,
position: Some(p),
});
}
sequence
}
fn undo_reordering(chars: &[char], level_at: &[u8]) -> (Vec<usize>, Vec<char>) {
let max_level = level_at.iter().copied().max().unwrap_or(0);
let mut logical: Vec<usize> = (0..chars.len()).collect();
for min in 1..=max_level {
reverse_runs_at_least(&mut logical, level_at, min, |p| chars[p]);
}
let mut out_chars: Vec<char> = logical.iter().map(|&p| chars[p]).collect();
let out_levels: Vec<u8> = logical.iter().map(|&p| level_at[p]).collect();
mirror_at_odd_levels(&mut out_chars, &out_levels);
(logical, out_chars)
}
const MAX_OPEN_PAIRS: u32 = 4;
pub(crate) fn visual_to_logical(visual: &[VisualChar], rtl_base: bool) -> Vec<VisualChar> {
if visual.is_empty() {
return Vec::new();
}
let chars: Vec<char> = visual.iter().map(|v| v.ch).collect();
let mut read_back: Vec<usize> = (0..chars.len()).collect();
if rtl_base {
reverse_clusters(&mut read_back, |p| chars[p]);
}
let pairs = bracket_pairs(&chars, rtl_base);
let open_pairs: Vec<usize> = pairs
.iter()
.enumerate()
.filter_map(|(i, pair)| pair.alternative.map(|_| i))
.collect();
let readings: Vec<u32> = if open_pairs.len() as u32 > MAX_OPEN_PAIRS {
vec![0]
} else {
let mut masks: Vec<u32> = (0..1u32 << open_pairs.len()).collect();
masks.sort_by_key(|mask| mask.count_ones());
masks
};
let mut read_back_index = vec![0usize; chars.len()];
for (k, &p) in read_back.iter().enumerate() {
read_back_index[p] = k;
}
let mut replaced: Vec<(usize, char)> = Vec::new();
for (i, &c) in chars.iter().enumerate() {
if paired_bracket(c).is_none() {
continue;
}
if pairs.iter().any(|pair| pair.open == i || pair.close == i) {
if rtl_base {
replaced.push((i, mirror_char(c)));
}
} else {
replaced.push((i, '\u{00A6}'));
}
}
let mut first: Option<(Vec<usize>, Vec<char>)> = None;
for mask in readings {
let mut phantoms: Vec<(usize, char)> = Vec::new();
for (i, pair) in pairs.iter().enumerate() {
if pair.alternative.is_none() {
continue;
}
let flipped = open_pairs
.iter()
.position(|&open| open == i)
.is_some_and(|bit| mask & (1 << bit) != 0);
let level = if flipped {
pair.alternative.unwrap_or(pair.level)
} else {
pair.level
};
let opener = if rtl_base { pair.close } else { pair.open };
phantoms.push((
read_back_index[opener],
phantom_before_pair(&chars, pair, level),
));
}
let sequence = analysis_sequence(&chars, &read_back, rtl_base, &replaced, &phantoms);
let level_at = resolved_levels(&sequence, rtl_base, chars.len());
let (logical, out_chars) = undo_reordering(&chars, &level_at);
if display_order(&out_chars, rtl_base) == chars {
first = Some((logical, out_chars));
break;
}
if first.is_none() {
first = Some((logical, out_chars));
}
}
let (logical, out_chars) = first.unwrap_or_default();
logical
.iter()
.zip(out_chars)
.map(|(&p, ch)| VisualChar {
ch,
item: visual[p].item,
})
.collect()
}
fn display_order(chars: &[char], rtl_base: bool) -> Vec<char> {
if chars.is_empty() {
return Vec::new();
}
let sequence: Vec<AnalysisChar> = chars
.iter()
.enumerate()
.map(|(p, &ch)| AnalysisChar {
ch,
position: Some(p),
})
.collect();
let level_at = resolved_levels(&sequence, rtl_base, chars.len());
let max_level = level_at.iter().copied().max().unwrap_or(0);
let mut display: Vec<usize> = (0..chars.len()).collect();
for min in (1..=max_level).rev() {
reverse_runs_at_least(&mut display, &level_at, min, |p| chars[p]);
}
let mut out: Vec<char> = display.iter().map(|&p| chars[p]).collect();
let levels: Vec<u8> = display.iter().map(|&p| level_at[p]).collect();
mirror_at_odd_levels(&mut out, &levels);
out
}
pub(crate) fn logical_to_visual(text: &str, rtl_base: bool) -> String {
let chars: Vec<char> = text.chars().collect();
display_order(&chars, rtl_base).into_iter().collect()
}
pub(crate) fn is_presentation_form(c: char) -> bool {
matches!(c, '\u{FB1D}'..='\u{FB4F}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFE}')
}
pub(crate) fn normalize_presentation_forms(text: &str) -> Cow<'_, str> {
if !text.chars().any(is_presentation_form) {
return Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if is_presentation_form(c) {
out.extend(std::iter::once(c).nfkc());
} else {
out.push(c);
}
}
Cow::Owned(out)
}
const WORD_GAP_EM: f32 = 0.1;
const NUMBER_GAP_EM: f32 = 0.3;
fn joins_number(c: char) -> bool {
matches!(
class_of(c),
BidiClass::EN | BidiClass::AN | BidiClass::ES | BidiClass::CS | BidiClass::ET
)
}
pub(crate) fn logical_line_order<T>(
items: &[T],
text_of: impl Fn(&T) -> &str,
span_of: impl Fn(&T) -> (f32, f32),
em_of: impl Fn(&T) -> f32,
text_is_visual: impl Fn(usize) -> bool,
word_gap: Option<f32>,
rtl_base: bool,
) -> Vec<(usize, String)> {
let mut visual: Vec<VisualChar> = Vec::new();
let mut counts: Vec<usize> = vec![0; items.len()];
let mut prev_right: Option<f32> = None;
let mut prev_last: Option<char> = None;
let mut prev_em = f32::INFINITY;
for (index, item) in items.iter().enumerate() {
let (x, width) = span_of(item);
let display: Cow<'_, str> = if text_is_visual(index) {
Cow::Borrowed(text_of(item))
} else {
Cow::Owned(logical_to_visual(text_of(item), rtl_base))
};
if let Some(right) = prev_right {
let numeric_junction = prev_last.is_some_and(joins_number)
&& display.chars().next().is_some_and(joins_number);
let floor = match word_gap {
_ if numeric_junction => em_of(item).max(1.0) * NUMBER_GAP_EM,
Some(floor_em) => floor_em * em_of(item).min(prev_em).max(1.0),
None => em_of(item).max(1.0) * WORD_GAP_EM,
};
if x - right > floor {
visual.push(VisualChar {
ch: ' ',
item: None,
});
}
}
prev_right = Some(prev_right.unwrap_or(f32::MIN).max(x + width.max(0.0)));
prev_last = display.chars().last().or(prev_last);
prev_em = em_of(item);
counts[index] = display.chars().count();
visual.extend(display.chars().map(|ch| VisualChar {
ch,
item: Some(index),
}));
}
let logical = visual_to_logical(&visual, rtl_base);
let is_strong = |c: char| c.is_alphanumeric() && !is_combining_mark(c);
let mut strong_positions: Vec<Vec<usize>> = vec![Vec::new(); items.len()];
for (at, VisualChar { ch, item }) in logical.iter().enumerate() {
if let Some(index) = item {
if is_strong(*ch) {
strong_positions[*index].push(at);
}
}
}
let mut reading: Vec<usize> = (0..items.len())
.filter(|&i| !strong_positions[i].is_empty())
.collect();
reading.sort_by_key(|&i| strong_positions[i][strong_positions[i].len() / 2]);
let lettered = |i: usize| !strong_positions[i].is_empty();
let mut k = 0;
while k < items.len() {
if counts[k] == 0 || lettered(k) {
k += 1;
continue;
}
let run_start = k;
while k < items.len() && !lettered(k) {
k += 1;
}
let mut run: Vec<usize> = (run_start..k).filter(|&i| counts[i] > 0).collect();
let left = (0..run_start).rev().find(|&i| lettered(i));
let right = (k..items.len()).find(|&i| lettered(i));
if let Some(l) = left {
let (left_x, left_width) = span_of(&items[l]);
let over_left = |i: usize| {
let (x, _) = span_of(&items[i]);
text_of(&items[i]).chars().all(is_combining_mark)
&& x >= left_x.min(left_x + left_width)
&& x < left_x.max(left_x + left_width)
};
let marks = run.iter().take_while(|&&i| over_left(i)).count();
if marks > 0 {
let at = reading.iter().position(|&r| r == l).map_or(0, |p| p + 1);
for (offset, i) in run.drain(..marks).enumerate() {
reading.insert(at + offset, i);
}
if run.is_empty() {
continue;
}
}
}
let place = |i: usize| reading.iter().position(|&r| r == i).unwrap_or(0);
let (insert_at, reversed) = match (left, right) {
(Some(l), Some(r)) => {
let (pl, pr) = (place(l), place(r));
if pl < pr {
(pl + 1, false)
} else {
(pr + 1, true)
}
}
(None, Some(r)) => {
if place(r) == 0 {
(0, false)
} else {
(reading.len(), true)
}
}
(Some(l), None) => {
if place(l) + 1 == reading.len() {
(reading.len(), false)
} else {
(0, true)
}
}
(None, None) => (0, rtl_base),
};
if reversed {
run.reverse();
}
for (offset, i) in run.into_iter().enumerate() {
reading.insert(insert_at + offset, i);
}
}
let mut chars = logical.iter().filter(|v| v.item.is_some()).map(|v| v.ch);
let mut result: Vec<(usize, String)> = reading
.into_iter()
.map(|index| (index, chars.by_ref().take(counts[index]).collect()))
.collect();
result.extend(
(0..items.len())
.filter(|&i| counts[i] == 0)
.map(|i| (i, String::new())),
);
result
}
#[cfg(test)]
mod tests {
use super::*;
fn visual(text: &str) -> Vec<VisualChar> {
text.chars()
.map(|ch| VisualChar { ch, item: None })
.collect()
}
fn logical_text(display: &str, rtl_base: bool) -> String {
visual_to_logical(&visual(display), rtl_base)
.into_iter()
.map(|v| v.ch)
.collect()
}
const HELLO_WORLD: &str = "\u{05E9}\u{05DC}\u{05D5}\u{05DD} \u{05E2}\u{05D5}\u{05DC}\u{05DD}";
#[test]
fn mirrored_characters_come_from_the_unicode_data() {
assert_eq!(
logical_text("\u{27E6}\u{05D1}\u{05D0}\u{27E7}", true),
"\u{27E6}\u{05D0}\u{05D1}\u{27E7}"
);
assert_eq!(
logical_text("\u{05D1} \u{2265} \u{05D0}", true),
"\u{05D0} \u{2264} \u{05D1}"
);
assert_eq!(mirror_char('\u{00AB}'), '\u{00BB}');
assert_eq!(mirror_char('a'), 'a');
assert_eq!(paired_bracket('\u{2329}'), Some(('\u{3008}', true)));
assert_eq!(paired_bracket('\u{3009}'), Some(('\u{3008}', false)));
assert_eq!(paired_bracket('\u{00AB}'), None);
}
#[test]
fn the_glyph_joiner_is_transparent_to_the_embedding_levels() {
let levels = |text: &str, rtl_base: bool| -> Vec<u8> {
let chars: Vec<char> = text.chars().collect();
let sequence: Vec<AnalysisChar> = chars
.iter()
.enumerate()
.map(|(p, &ch)| AnalysisChar {
ch,
position: Some(p),
})
.collect();
resolved_levels(&sequence, rtl_base, chars.len())
};
assert_eq!(class_of(GLYPH_JOINER), BidiClass::BN);
let plain = "a \u{0644}\u{0627}\u{0647} 12 b";
let joined = format!("a \u{0644}{GLYPH_JOINER}\u{0627}\u{0647} 12 b");
for rtl_base in [true, false] {
let expected = levels(plain, rtl_base);
let mut with_joiner = levels(&joined, rtl_base);
let joiner = with_joiner.remove(3);
assert_eq!(with_joiner, expected, "rtl_base {rtl_base}");
assert_eq!(joiner, expected[2], "rtl_base {rtl_base}");
}
}
#[test]
fn the_characters_of_one_glyph_stay_together_through_the_read_back() {
let mut lam_alef = String::new();
push_glyph_characters(&mut lam_alef, "\u{0644}\u{0627}");
assert_eq!(lam_alef, format!("\u{0644}{GLYPH_JOINER}\u{0627}"));
let display = format!("{lam_alef}\u{0647}");
let logical = format!("\u{0647}{lam_alef}");
assert_eq!(logical_text(&display, true), logical);
assert_eq!(logical_to_visual(&logical, true), display);
assert_eq!(
logical_text(&format!("a {display} b"), false),
format!("a {logical} b")
);
assert_eq!(
logical_text("\u{0644}\u{0627}\u{0647}", true),
"\u{0647}\u{0627}\u{0644}"
);
for label in ["fi", "1/2", "\u{0627}", "a", ""] {
let mut out = String::new();
push_glyph_characters(&mut out, label);
assert_eq!(out, label);
}
let mut text = logical.clone();
strip_glyph_joiners(&mut text);
assert_eq!(text, "\u{0647}\u{0644}\u{0627}");
}
#[test]
fn pure_rtl_line_is_reversed() {
let display = logical_to_visual(HELLO_WORLD, true);
assert_eq!(display, HELLO_WORLD.chars().rev().collect::<String>());
assert_eq!(logical_text(&display, true), HELLO_WORLD);
}
#[test]
fn embedded_number_and_trailing_colon_keep_their_places() {
let logical = "\u{05EA}\u{05E8}\u{05E9}\u{05D9}\u{05DD} 42:";
let display = logical_to_visual(logical, true);
assert_eq!(display, ":42 \u{05DD}\u{05D9}\u{05E9}\u{05E8}\u{05EA}");
assert_eq!(logical_text(&display, true), logical);
}
#[test]
fn latin_phrase_with_its_own_brackets_stays_whole() {
let logical = "\u{05D0}\u{05D1} ABC (DEF) \u{05D2}\u{05D3}";
let display = logical_to_visual(logical, true);
assert_eq!(display, "\u{05D3}\u{05D2} ABC (DEF) \u{05D1}\u{05D0}");
assert_eq!(logical_text(&display, true), logical);
}
#[test]
fn brackets_in_the_rtl_flow_are_unmirrored() {
let logical = "\u{05D0}\u{05D1} (IASB) \u{05D2}\u{05D3}";
let display = logical_to_visual(logical, true);
assert_eq!(display, "\u{05D3}\u{05D2} (IASB) \u{05D1}\u{05D0}");
assert_eq!(logical_text(&display, true), logical);
let logical = "\u{05D0} (\u{05D1}\u{05D2}) \u{05D3}";
assert_eq!(
logical_text(&logical_to_visual(logical, true), true),
logical
);
}
#[test]
fn number_after_latin_word_stays_with_the_phrase() {
let logical = "\u{05D0}\u{05D1} ABC 123 \u{05D2}\u{05D3}";
let display = logical_to_visual(logical, true);
assert_eq!(display, "\u{05D3}\u{05D2} ABC 123 \u{05D1}\u{05D0}");
assert_eq!(logical_text(&display, true), logical);
let logical = "\u{05D1}\u{05EA}\u{05D0}\u{05E8}\u{05D9}\u{05DA} 12/03/2024 \u{05D1}";
assert_eq!(
logical_text(&logical_to_visual(logical, true), true),
logical
);
}
#[test]
fn a_dash_between_digits_stays_inside_the_number_after_arabic() {
let logical = "\u{0627}لتعاريف 1-9";
let display = logical_to_visual(logical, true);
assert!(display.starts_with("1-9 "), "{display}");
assert_eq!(logical_text(&display, true), logical);
let logical = "\u{05D8}\u{05DC}. 03-1234567";
assert_eq!(
logical_text(&logical_to_visual(logical, true), true),
logical
);
}
#[test]
fn combining_marks_stay_on_their_base() {
let logical = "\u{05E9}\u{05C1}\u{05B8}\u{05DC}\u{05D5}\u{05B9}\u{05DD}";
let display = logical_to_visual(logical, true);
assert_eq!(
display,
"\u{05DD}\u{05D5}\u{05B9}\u{05DC}\u{05E9}\u{05C1}\u{05B8}"
);
assert_eq!(logical_text(&display, true), logical);
}
#[test]
fn ltr_paragraph_with_an_embedded_rtl_word() {
let logical =
"see \u{05E9}\u{05DC}\u{05D5}\u{05DD} (\u{05E2}\u{05D5}\u{05DC}\u{05DD}) here";
let display = logical_to_visual(logical, false);
assert_eq!(
display,
"see (\u{05DD}\u{05DC}\u{05D5}\u{05E2}) \u{05DD}\u{05D5}\u{05DC}\u{05E9} here"
);
assert_eq!(logical_text(&display, false), logical);
}
#[test]
fn arabic_with_arabic_indic_digits_and_percent() {
let logical = "\u{0646}\u{0633}\u{0628}\u{0629} \u{0662}\u{0665}\u{066A} \u{0645}\u{0646} \u{0627}\u{0644}\u{0637}\u{0644}\u{0627}\u{0628}";
let display = logical_to_visual(logical, true);
assert!(display.contains("\u{066A}\u{0662}\u{0665}"), "{display}");
assert_eq!(logical_text(&display, true), logical);
}
#[test]
fn presentation_forms_normalize_to_letters_in_reading_order() {
assert_eq!(
normalize_presentation_forms("\u{FEFB}\u{FEE6}"),
"\u{0644}\u{0627}\u{0646}"
);
assert_eq!(normalize_presentation_forms("\u{FB2A}"), "\u{05E9}\u{05C1}");
assert!(matches!(
normalize_presentation_forms("plain"),
Cow::Borrowed("plain")
));
assert!(!is_presentation_form('\u{FEFF}'));
}
#[test]
fn line_order_rebuilds_items_from_visual_text() {
let items = [
("\u{05DD}\u{05DC}\u{05D5}\u{05E2}", 100.0f32, 30.0f32), ("\u{05DD}\u{05D5}\u{05DC}\u{05E9}", 140.0, 30.0), ];
let order = logical_line_order(
&items,
|i| i.0,
|i| (i.1, i.2),
|_| 12.0,
|_| true,
None,
true,
);
assert_eq!(
order,
vec![
(1, "\u{05E9}\u{05DC}\u{05D5}\u{05DD}".to_string()),
(0, "\u{05E2}\u{05D5}\u{05DC}\u{05DD}".to_string()),
]
);
}
#[test]
fn a_vowel_sign_shown_apart_follows_its_letter_in_the_reading() {
let items = [
("\u{05DC}", 110.0f32, 10.0f32),
("\u{05D0}", 120.0, 10.0),
("\u{05B8}", 124.0, 0.0),
("\u{05D1}", 130.0, 10.0),
];
for visual in [false, true] {
let order = logical_line_order(
&items,
|i| i.0,
|i| (i.1, i.2),
|_| 12.0,
|_| visual,
None,
true,
);
assert_eq!(
order,
vec![
(3, "\u{05D1}".to_string()),
(1, "\u{05D0}".to_string()),
(2, "\u{05B8}".to_string()),
(0, "\u{05DC}".to_string()),
],
"visual={visual}"
);
}
let items = [
("\u{05DC}", 110.0f32, 10.0f32),
("\u{05B8}", 120.0, 0.0),
("\u{05D0}", 120.0, 10.0),
("\u{05D1}", 130.0, 10.0),
];
let order = logical_line_order(
&items,
|i| i.0,
|i| (i.1, i.2),
|_| 12.0,
|_| false,
None,
true,
);
let reading: Vec<usize> = order.iter().map(|(index, _)| *index).collect();
assert_eq!(reading, [3, 2, 1, 0]);
}
#[test]
fn line_order_keeps_runs_whole_around_a_split_bracket_pair() {
let items = [
("(IFRS", 100.0f32, 30.0f32),
("16)", 133.0, 16.0),
("\u{05DF}\u{05E7}\u{05EA}\u{05D4}", 160.0, 28.0),
];
let order = logical_line_order(
&items,
|i| i.0,
|i| (i.1, i.2),
|_| 12.0,
|_| true,
None,
true,
);
assert_eq!(
order,
vec![
(2, "\u{05D4}\u{05EA}\u{05E7}\u{05DF}".to_string()),
(0, "(IFRS".to_string()),
(1, "16)".to_string()),
]
);
}
#[test]
fn punctuation_runs_keep_their_page_neighbours() {
let items = [
(" (", 462.0f32, 8.0f32),
("LR2", 470.4, 13.0),
(")(", 483.3, 6.5),
("LR1", 489.8, 13.0),
(")", 502.9, 3.3),
(
"\u{05E3}\u{05D5}\u{05E0}\u{05D9}\u{05DE}\u{05D4}",
508.3,
39.0,
),
];
let order = logical_line_order(
&items,
|i| i.0,
|i| (i.1, i.2),
|_| 10.0,
|_| true,
None,
true,
);
assert_eq!(
order,
vec![
(
5,
"\u{05D4}\u{05DE}\u{05D9}\u{05E0}\u{05D5}\u{05E3}".to_string()
),
(4, "(".to_string()),
(3, "LR1".to_string()),
(2, ")(".to_string()),
(1, "LR2".to_string()),
(0, ") ".to_string()),
]
);
}
#[test]
fn line_order_keeps_logical_items_and_orders_embedded_ltr_forward() {
let items = [
(":", 100.0f32, 3.0f32),
("IASB", 103.5, 30.0),
("42", 140.0, 12.0),
("\u{05E9}\u{05DC}", 160.0, 20.0),
];
let order = logical_line_order(
&items,
|i| i.0,
|i| (i.1, i.2),
|_| 12.0,
|_| false,
None,
true,
);
let indexes: Vec<usize> = order.iter().map(|(i, _)| *i).collect();
assert_eq!(indexes, vec![3, 1, 2, 0]);
assert_eq!(order[2].1, "42");
assert_eq!(order[3].1, ":");
}
struct XorShift(u64);
impl XorShift {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
&items[self.below(items.len())]
}
fn chance(&mut self, one_in: usize) -> bool {
self.below(one_in) == 0
}
}
const HEBREW_LETTERS: &[char] = &['א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ל', 'מ', 'ש', 'ת', 'ם', 'ן'];
const HEBREW_POINTS: &[char] = &['\u{05B8}', '\u{05B4}', '\u{05BC}', '\u{05C1}'];
const ARABIC_LETTERS: &[char] = &['ا', 'ب', 'ت', 'ك', 'ل', 'م', 'ن', 'ع', 'س'];
const ARABIC_MARKS: &[char] = &['\u{064E}', '\u{064F}', '\u{0651}'];
const LATIN_LETTERS: &[char] = &['a', 'b', 'c', 'x', 'y', 'e', 'n', 'B', 'D', 'P'];
const ARABIC_INDIC_DIGITS: &[char] = &['٠', '١', '٢', '٥', '٩'];
fn token(rng: &mut XorShift) -> String {
let word = |rng: &mut XorShift, letters: &[char], marks: &[char]| -> String {
let mut w = String::new();
for _ in 0..1 + rng.below(5) {
w.push(*rng.pick(letters));
if !marks.is_empty() && rng.chance(6) {
w.push(*rng.pick(marks));
}
}
w
};
let number = |rng: &mut XorShift, digits: &[char]| -> String {
let mut n: String = (0..1 + rng.below(4)).map(|_| *rng.pick(digits)).collect();
match rng.below(6) {
0 => {
n = format!(
"{n},{}{}{}",
rng.pick(digits),
rng.pick(digits),
rng.pick(digits)
)
}
1 => n = format!("{n}.{}", rng.pick(digits)),
2 => n.push('%'),
3 => n = format!("-{n}"),
_ => {}
}
n
};
let mut t = match rng.below(10) {
0..=3 => word(rng, HEBREW_LETTERS, HEBREW_POINTS),
4 | 5 => word(rng, ARABIC_LETTERS, ARABIC_MARKS),
6 | 7 => word(rng, LATIN_LETTERS, &[]),
8 => number(rng, &['0', '1', '2', '5', '9']),
_ => {
if rng.chance(2) {
number(rng, ARABIC_INDIC_DIGITS)
} else {
"12/03/2024".to_string()
}
}
};
if rng.chance(5) {
t.push(*rng.pick(&[',', '.', ':', ';', '?', '!']));
}
t
}
fn line(rng: &mut XorShift) -> String {
let mut tokens: Vec<String> = (0..1 + rng.below(7)).map(|_| token(rng)).collect();
if rng.chance(4) {
let (open, close) = *rng.pick(&[('(', ')'), ('[', ']'), ('"', '"'), ('«', '»')]);
let from = rng.below(tokens.len());
let to = from + rng.below(tokens.len() - from);
tokens[from].insert(0, open);
tokens[to].push(close);
}
tokens.join(" ")
}
#[test]
fn round_trip_recovers_random_lines() {
let mut rng = XorShift(0x9E37_79B9_7F4A_7C15);
let mut exact = 0;
let mut equivalent = 0;
let mut failures: Vec<String> = Vec::new();
for _ in 0..10_000 {
let logical = line(&mut rng);
for rtl_base in [true, false] {
let display = logical_to_visual(&logical, rtl_base);
let recovered = logical_text(&display, rtl_base);
if recovered == logical {
exact += 1;
} else if logical_to_visual(&recovered, rtl_base) == display {
equivalent += 1;
} else {
failures.push(format!(
"rtl_base={rtl_base}: {logical:?} displayed as {display:?} came back as {recovered:?}"
));
}
}
}
assert!(
failures.is_empty(),
"{} of {} lines came back displaying differently ({exact} exact, {equivalent} equivalent):\n{}",
failures.len(),
exact + equivalent + failures.len(),
failures.iter().take(12).cloned().collect::<Vec<_>>().join("\n")
);
assert!(
exact * 10 >= 20_000 * 8,
"round trip exact for {exact} of 20000 lines"
);
}
#[test]
fn character_soup_is_permuted_without_panicking() {
const SOUP: &[char] = &[
'א', 'ب', '\u{05B8}', '\u{064E}', 'a', 'X', '1', '9', '٢', ' ', '(', ')', '[', ']',
'.', ',', ':', '-', '%', '/', '"', '\n', '\t', '\u{200F}', '\u{202B}', '\u{2029}',
];
let mut rng = XorShift(7);
for _ in 0..3_000 {
let text: Vec<char> = (0..rng.below(12)).map(|_| *rng.pick(SOUP)).collect();
let visual: Vec<VisualChar> = text
.iter()
.enumerate()
.map(|(i, &ch)| VisualChar { ch, item: Some(i) })
.collect();
for rtl_base in [true, false] {
let logical = visual_to_logical(&visual, rtl_base);
assert_eq!(logical.len(), text.len());
let mut seen: Vec<usize> = logical.iter().filter_map(|v| v.item).collect();
seen.sort_unstable();
assert_eq!(seen, (0..text.len()).collect::<Vec<_>>());
for v in &logical {
let original = text[v.item.unwrap()];
assert!(v.ch == original || v.ch == mirror_char(original));
}
let _ = logical_to_visual(&text.iter().collect::<String>(), rtl_base);
}
}
}
}