pub(crate) fn is_rtl_char(c: char) -> bool {
matches!(c as u32,
0x0590..=0x05FF | 0x0600..=0x07BF | 0x0800..=0x085F | 0x08A0..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF )
}
fn is_strong_ltr_char(c: char) -> bool {
c.is_alphabetic() && !is_rtl_char(c)
}
fn is_rtl_chars(chars: impl Iterator<Item = char>) -> bool {
let mut rtl = 0usize;
let mut ltr = 0usize;
for c in chars {
if is_rtl_char(c) {
rtl += 1;
} else if is_strong_ltr_char(c) {
ltr += 1;
}
}
rtl > ltr
}
pub(crate) fn is_rtl_text(s: &str) -> bool {
!s.is_ascii() && is_rtl_chars(s.chars())
}
pub(crate) fn is_rtl_pieces<'a>(pieces: impl IntoIterator<Item = &'a str> + Clone) -> bool {
if pieces.clone().into_iter().all(|p| p.is_ascii()) {
return false;
}
is_rtl_chars(pieces.into_iter().flat_map(str::chars))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strong_direction_classification() {
assert!(is_rtl_char('\u{05DE}')); assert!(is_rtl_char('\u{0642}')); assert!(!is_rtl_char('A'));
assert!(!is_rtl_char('7'));
assert!(!is_strong_ltr_char('7'));
assert!(!is_strong_ltr_char('.'));
assert!(is_strong_ltr_char('A'));
}
#[test]
fn line_direction_ignores_neutrals() {
assert!(is_rtl_text("المجموع الفرعي: 75.00"));
assert!(is_rtl_text("סך הכל 12.34"));
assert!(!is_rtl_text("Total due 87.75 ILS"));
assert!(!is_rtl_text("2026-07-27 10:30"));
assert!(!is_rtl_text(""));
}
#[test]
fn ascii_fast_path_agrees_with_full_scan() {
for s in [
"",
"Total due 87.75 ILS",
"2026-07-27",
"!@#$%^&*()",
"\u{05DE}\u{05E1}",
"mixed \u{0642} latin",
"caf\u{e9} na\u{ef}ve",
] {
assert_eq!(is_rtl_text(s), is_rtl_chars(s.chars()), "mismatch on {s:?}");
assert_eq!(is_rtl_pieces([s]), is_rtl_chars(s.chars()));
}
assert_eq!(
is_rtl_pieces(["12.34 ", "\u{05DE}\u{05E1}\u{05E2}"]),
is_rtl_chars("12.34 \u{05DE}\u{05E1}\u{05E2}".chars())
);
}
#[test]
fn currency_code_does_not_flip_short_rtl_line() {
assert!(is_rtl_text("סך הכל לתשלום 87.75 ILS"));
}
}