use unicode_bidi::{BidiClass, BidiInfo, Level};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum BaseDirection {
#[default]
Ltr,
Rtl,
}
impl BaseDirection {
pub fn level(self) -> BidiLevel {
match self {
Self::Ltr => BidiLevel::LTR,
Self::Rtl => BidiLevel::RTL,
}
}
pub fn isolate(self) -> char {
match self {
Self::Ltr => unicode_bidi::format_chars::LRI,
Self::Rtl => unicode_bidi::format_chars::RLI,
}
}
}
pub const POP_ISOLATE: char = unicode_bidi::format_chars::PDI;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct BidiLevel(u8);
impl BidiLevel {
pub const LTR: Self = Self(0);
pub const RTL: Self = Self(1);
pub fn is_rtl(self) -> bool {
self.0 % 2 == 1
}
pub fn is_ltr(self) -> bool {
!self.is_rtl()
}
#[cfg(test)]
pub(crate) const fn from_number(n: u8) -> Self {
Self(n)
}
}
impl From<Level> for BidiLevel {
fn from(l: Level) -> Self {
Self(l.number())
}
}
impl From<BidiLevel> for Level {
fn from(l: BidiLevel) -> Self {
Level::new(l.0).unwrap_or(unicode_bidi::LTR_LEVEL)
}
}
pub fn needs_analysis(text: &str) -> bool {
text.chars().any(|c| {
matches!(
unicode_bidi::bidi_class(c),
BidiClass::R | BidiClass::AL | BidiClass::RLE | BidiClass::RLO | BidiClass::RLI
)
})
}
pub fn resolve_levels(text: &str, base: BaseDirection) -> Vec<BidiLevel> {
if text.is_empty() {
return Vec::new();
}
BidiInfo::new(text, Some(base.level().into()))
.levels
.into_iter()
.map(BidiLevel::from)
.collect()
}
pub fn reorder(levels: &[BidiLevel]) -> Vec<usize> {
let levels: Vec<Level> = levels.iter().copied().map(Level::from).collect();
BidiInfo::reorder_visual(&levels)
}
pub fn mirror(c: char) -> Option<char> {
unicode_bidi_mirroring::get_mirrored(c)
}
#[cfg(test)]
mod tests {
use super::*;
fn char_levels(text: &str, base: BaseDirection) -> Vec<u8> {
let levels = resolve_levels(text, base);
text.char_indices().map(|(i, _)| levels[i].0).collect()
}
#[test]
fn ordinary_latin_text_needs_no_analysis() {
for text in [
"Nicht gefunden",
"S.I.G.M.A. Technik Service GmbH",
"Türöffner-Gerät — 10:30–12:00",
"日本語の文章",
"ภาษาไทย",
"",
] {
assert!(
!needs_analysis(text),
"{text:?} has no right-to-left character"
);
}
}
#[test]
fn hebrew_and_arabic_need_analysis() {
for text in ["שלום", "مرحبا", "page שלום here"] {
assert!(needs_analysis(text), "{text:?} is bidirectional");
}
}
#[test]
fn arabic_indic_digits_alone_need_no_analysis() {
assert!(!needs_analysis("\u{0661}\u{0662}\u{0663}"));
assert_eq!(
char_levels("a \u{0661}\u{0662}", BaseDirection::Ltr),
[0, 0, 2, 2]
);
assert_eq!(
reorder(&resolve_levels("a \u{0661}\u{0662}", BaseDirection::Ltr)),
(0..6).collect::<Vec<_>>(),
"an even level is not reversed",
);
}
#[test]
fn plain_latin_is_all_level_zero() {
assert_eq!(char_levels("abc", BaseDirection::Ltr), [0, 0, 0]);
}
#[test]
fn hebrew_in_a_left_to_right_paragraph_is_level_one() {
assert_eq!(
char_levels("a שלום b", BaseDirection::Ltr),
[0, 0, 1, 1, 1, 1, 0, 0]
);
}
#[test]
fn latin_in_a_right_to_left_paragraph_is_level_two() {
assert_eq!(
char_levels("שלום ab שלום", BaseDirection::Rtl),
[1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1]
);
}
#[test]
fn the_base_direction_decides_and_is_never_sniffed() {
assert_eq!(char_levels("שלום", BaseDirection::Ltr), [1, 1, 1, 1]);
assert_eq!(char_levels("שלום", BaseDirection::Rtl), [1, 1, 1, 1]);
assert_eq!(char_levels("שלום.", BaseDirection::Ltr), [1, 1, 1, 1, 0]);
assert_eq!(char_levels("שלום.", BaseDirection::Rtl), [1, 1, 1, 1, 1]);
}
#[test]
fn an_rtl_isolate_changes_how_neutrals_resolve() {
let bare = char_levels("a (1) b", BaseDirection::Ltr);
assert_eq!(bare, [0, 0, 0, 0, 0, 0, 0], "no strong RTL anywhere");
let wrapped = format!("a {}(1){} b", BaseDirection::Rtl.isolate(), POP_ISOLATE);
let levels = char_levels(&wrapped, BaseDirection::Ltr);
assert!(
levels[3] > 0 && levels[5] > 0,
"the isolate's contents resolve right-to-left: {levels:?}",
);
assert_eq!(levels[0], 0, "and the text outside it does not");
}
#[test]
fn a_forced_break_starts_a_paragraph_at_the_stated_direction() {
assert_eq!(
char_levels("ab\nשלום", BaseDirection::Rtl),
[2, 2, 1, 1, 1, 1, 1]
);
}
#[test]
fn empty_text_resolves_to_no_levels() {
assert!(resolve_levels("", BaseDirection::Rtl).is_empty());
}
#[test]
fn a_left_to_right_line_keeps_its_order() {
let levels = [BidiLevel::from_number(0); 4];
assert_eq!(reorder(&levels), [0, 1, 2, 3]);
}
#[test]
fn a_right_to_left_line_is_reversed() {
let levels = [BidiLevel::from_number(1); 4];
assert_eq!(reorder(&levels), [3, 2, 1, 0]);
}
#[test]
fn an_embedded_run_flips_position_but_not_internal_order() {
let levels = [
BidiLevel::from_number(1),
BidiLevel::from_number(1),
BidiLevel::from_number(2),
BidiLevel::from_number(2),
BidiLevel::from_number(1),
];
assert_eq!(
reorder(&levels),
[4, 2, 3, 1, 0],
"the level-2 pair stays in order; everything else reverses",
);
}
#[test]
fn reordering_is_always_a_permutation() {
for levels in [
vec![
BidiLevel::from_number(0),
BidiLevel::from_number(1),
BidiLevel::from_number(0),
],
vec![
BidiLevel::from_number(1),
BidiLevel::from_number(2),
BidiLevel::from_number(3),
BidiLevel::from_number(1),
],
vec![BidiLevel::from_number(2)],
vec![],
] {
let order = reorder(&levels);
assert_eq!(order.len(), levels.len());
let mut seen = order.clone();
seen.sort_unstable();
assert_eq!(seen, (0..levels.len()).collect::<Vec<_>>());
}
}
#[test]
fn paired_punctuation_mirrors() {
for (from, to) in [
('(', ')'),
(')', '('),
('[', ']'),
('{', '}'),
('<', '>'),
('\u{00AB}', '\u{00BB}'),
] {
assert_eq!(mirror(from), Some(to), "{from:?} mirrors to {to:?}");
}
}
#[test]
fn unpaired_characters_do_not_mirror() {
for c in ['a', 'א', '.', ' ', '"', '-'] {
assert_eq!(mirror(c), None, "{c:?} has no mirror");
}
}
#[test]
fn arabic_with_western_digits_puts_the_number_at_an_even_level() {
let text = "صفحة 12 من";
let levels = char_levels(text, BaseDirection::Rtl);
let digits: Vec<u8> = text
.char_indices()
.zip(&levels)
.filter(|((_, c), _)| c.is_ascii_digit())
.map(|(_, l)| *l)
.collect();
assert_eq!(digits, [2, 2], "digits ride at an even level");
assert!(
levels.iter().filter(|l| **l == 1).count() >= 6,
"and the Arabic around them is odd: {levels:?}",
);
}
}