Bidi: auto-detect RTL paragraph direction when typing Kurdish, Arabic and Hebrew (#713)

* Bidi: auto-detect RTL paragraph direction and keep typing caret advancing smoothly

When typing in Kurdish, Arabic or Hebrew in a new document, default
paragraph direction (bidi: None) was resolving to LTR. In an LTR
paragraph, visual reordering pinned newly typed characters and the
caret to the left margin (x = 72.0), resetting to the start of the line
with every keystroke.

- Add first_strong_is_rtl() following UAX #9 rules P2/P3.
- Auto-resolve unassigned paragraph direction to RTL in layout when the
  paragraph starts with an RTL script.
- Auto-promote paragraph to RTL (bidi = Some(true)) when typing text
  starting with an RTL character.
- Add regression test in wordcraft-engine.

* Review fixes for #713: RTL only when typing into an empty paragraph

- Drop the layout fallback that drew any paragraph without w:bidi as RTL when its first
  strong character is Arabic/Hebrew: opened DOCX paragraphs keep their direction, and a
  style that sets bidi=false is respected.
- type_text promotes a paragraph to RTL only when it had no letters before the insert and
  nothing (paragraph, style chain, document defaults) sets its direction; typing into a
  paragraph that already has text no longer flips it.
- docs/typing-parity.md: Text direction section; test in tests_typing.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hevar Hemin 2026-10-12 13:51:03 +03:00 committed by GitHub
parent e22ce1d05e
commit 62a17b841f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 115 additions and 1 deletions

View File

@ -37,6 +37,26 @@ pub fn has_rtl(text: &str) -> bool {
text.chars().any(is_rtl_script)
}
/// Does the first strong directional character in `text` belong to a right-to-left script?
///
/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R
/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek,
/// Cyrillic, CJK…) give `false`. Neutral, weak and whitespace characters are skipped.
pub fn first_strong_is_rtl(text: &str) -> bool {
for c in text.chars() {
if c == '\u{200F}' {
return true;
}
if c == '\u{200E}' {
return false;
}
if c.is_alphabetic() {
return is_rtl_script(c);
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
@ -56,5 +76,11 @@ mod tests {
assert!("می‌خواهم".chars().all(is_word_char), "ZWNJ is inside the word");
assert!("کِتاب".chars().all(is_word_char), "so are harakat");
assert!(!is_word_char(' ') && !is_word_char('،'));
assert!(first_strong_is_rtl("سلام"));
assert!(first_strong_is_rtl("من ناوم هێڤاره"));
assert!(first_strong_is_rtl(" 123. سلام"));
assert!(!first_strong_is_rtl("Hello سلام"));
assert!(!first_strong_is_rtl("123 Hello"));
assert!(!first_strong_is_rtl(""));
}
}

View File

@ -275,7 +275,18 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
first = false;
let part = part.replace('\u{000B}', "\n");
if !part.is_empty() {
let starts_para = direction_unset(s, &at);
at = s.doc.insert_text(&at, &part, &props)?;
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
// sets makes it a right-to-left paragraph, so the caret and text start at the right
// margin. Paragraphs that already have letters, or a direction, keep theirs.
if starts_para
&& let Ok(p) = s.doc.para_mut(at.story, &at.path)
&& wordcraft_doc::bidi::first_strong_is_rtl(&p.text)
{
p.props.bidi = Some(true);
p.touch();
}
}
}
s.sel = crate::Selection::caret(at);
@ -283,6 +294,17 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
Ok(())
}
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
/// punctuation), with no direction set on it, its style chain or the document defaults?
fn direction_unset(s: &Session, at: &Pos) -> bool {
let Some(p) = s.doc.para_at(at) else { return false };
if p.props.bidi.is_some() || p.text.chars().any(char::is_alphabetic) || s.doc.styles.default_para.bidi.is_some() {
return false;
}
let style = p.props.style.as_deref().unwrap_or("Normal");
!s.doc.styles.chain(style).iter().any(|st| st.para.bidi.is_some())
}
/// Take a paragraph out of its list like Word does when Enter or Backspace ends a list: a
/// "List Paragraph" goes back to Normal; any other style keeps itself with numbering switched off.
pub fn leave_list(para: &mut wordcraft_doc::Paragraph) {

View File

@ -225,3 +225,23 @@ fn explicit_alignment_sets_without_toggling() {
}
}
}
#[test]
fn typing_kurdish_in_new_document_advances_caret_smoothly() {
let mut s = Session::new(wordcraft_doc::Document::new());
let mut first_x = None;
let mut last_x = 0.0;
for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] {
run(&mut s, "text.insert", json!({"text": ch}));
let l = s.layout();
let c = l.caret(&s.sel.focus).unwrap();
if first_x.is_none() {
first_x = Some(c.x);
}
last_x = c.x;
// The caret must not jump back to the left margin (72.0), but stay near the right margin (540.0).
assert!(c.x > 400.0, "caret should not be at left margin: {}", c.x);
}
assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall");
assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left");
}

View File

@ -705,3 +705,39 @@ fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() {
typ(&mut s, "A\n\nB");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]);
}
/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that
/// already has letters, or whose style sets a direction, keeps its own.
#[test]
fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() {
let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi);
// Empty paragraph: the first Arabic letter flips it, the rest keep it.
let s = typed("سلام");
assert_eq!(bidi(&s, 0), Some(true));
// Digits first, then Arabic: still no letters before, so it flips.
let s = typed("1. سلام");
assert_eq!(bidi(&s, 0), Some(true));
// Latin first: Arabic typed later doesn't flip the paragraph.
let s = typed("Hello سلام");
assert_eq!(bidi(&s, 0), None);
// A new paragraph after an RTL one starts its own detection.
let s = typed("سلام\nHello");
assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word");
// A style that sets left-to-right wins.
let mut doc = Session::new(wordcraft_doc::Document::new());
if let Some(st) = doc.doc.styles.get_mut("Normal") {
st.para.bidi = Some(false);
}
typ(&mut doc, "سلام");
assert_eq!(bidi(&doc, 0), None);
// An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret
// after the Arabic word sits near the left margin, not the right one.
let mut doc = typed("سلام");
let path = doc.sel.focus.path.clone();
if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) {
p.props.bidi = None;
p.touch();
}
let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX);
assert!(x < 300.0, "left-to-right paragraph: caret at {x}");
}

View File

@ -1,6 +1,6 @@
# Typing parity with Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (grammar proofing: ellipses are not sentence boundaries; previously minor: pasted paragraph breaks tracked under Track Changes, kept as text in lists, #417, #422) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (right-to-left letters typed into an empty paragraph make it right-to-left, #713; previously minor: editing options: typing replaces selection, overtype, #485; previously minor: Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
What Word does when you type, observed black-box (same keystrokes typed into Word and
WordCraft, results read back paragraph by paragraph) and pinned by tests in
@ -97,6 +97,15 @@ Word.
| Typing over a selection | replaces it (Word's default); with "Typing replaces selected text" off, the text goes in front of the selection, which stays | `typing_over_a_selection_follows_the_replace_selection_option` |
| Overtype on, typing | each character replaces the one after the caret, never the paragraph mark or an object; Insert switches overtype only with the Insert-key option on (off by default, as in Word) | `overtype_replaces_the_characters_after_the_caret` |
## Text direction
- Typing Arabic, Hebrew or another right-to-left script as the first letters of a paragraph
whose direction nothing sets (not the paragraph, its style or the document defaults) makes it
a right-to-left paragraph, so the caret starts at the right margin. A paragraph that already
has letters keeps its direction, and so do opened documents: a paragraph without `w:bidi` stays
left-to-right whatever its text (`typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl`).
Word takes the direction from the keyboard language instead; not yet compared side by side.
## Layout
- No line break right after a slash (`and/or`, URLs) unless the word can't fit a line
@ -135,6 +144,7 @@ Word.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) |
| 2026-10-11 | trivial | Grammar proofing (not AutoCorrect) no longer suggests capitalising a sentence after `...` or `…` (`proof::tests::ellipses_do_not_start_sentences`) |
| 2026-10-11 | minor | AutoFormat tables from `+---+`, arrow bullets, smileys inside words, undo after a border line (#546) |
| 2026-10-11 | trivial | Outline view keys: expand/collapse, Show Level, moving and promoting with collapsed content (#493) |