Files
wordcraft/crates/engine/src/tests_bidi.rs
T
Saeed AzarehgarandClaude Opus 5.5 5d86dec250 Right-to-left and Persian text support (bidi, shaping, RTL paragraphs, DOCX) (#207)
* feat(doc,docx): complex-script run properties and RTL OOXML round-trip

Model the formatting Word applies to Persian, Arabic and Hebrew text:
cs, font_cs, size_cs, bold_cs, italic_cs and lang_bidi on CharProps,
resolved with Word's fallbacks (ResolvedChar::complex). Size, bold and
italic pair with their complex-script values so a run's size or bold
also covers its Persian characters. Add doc::bidi script helpers, and
keep ZWNJ and Arabic marks inside words for word selection.

DOCX: read and write w:rFonts/@w:cs, w:szCs, w:bCs, w:iCs, w:cs and
w:lang/@w:bidi in schema order; stop using the cs font as the Latin font.
Tests round-trip Persian text with ZWNJ, digits and mixed Latin.

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

* feat(fonts): directional shaping and Persian font fallback

Add shape_run to shape a run in a known bidi direction (Arabic joining,
mirrored brackets). Prefer Persian-capable fonts (Vazirmatn, Noto Naskh/
Sans Arabic…) for right-to-left fallback, pick bold/italic fallback faces,
and map common Persian document fonts (B Nazanin, B Lotus, B Titr,
IRANSans…) to open substitutes.

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

* feat(layout): Unicode Bidirectional Algorithm and right-to-left paragraphs

Run UAX #9 per paragraph (unicode-bidi), shape each level run in its
direction, keep marks and ZWNJ with their letter and a word in one
fallback face. After line breaking, apply rules L1/L2 per line and give
each cluster a visual box; right-to-left paragraphs are laid out from
their start edge and mirrored, so indents, tabs, alignment, list labels,
drop caps, floats and borders sit on the right.

Caret, hit testing and selection use visual geometry (split selections,
caret at bidi boundaries, DocLayout::visual_step for arrow keys). Display
items carry glyph-to-text ranges, which the PDF writer now uses. Plain
left-to-right lines are unchanged. No hyphenation of RTL words.

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

* feat(engine,ui): paragraph direction commands and RTL editing

Add para.ltr / para.rtl (Word's text direction buttons) that change only
the reading order, and make the alignment buttons visual in right-to-left
paragraphs (para.align also takes start/end). Arrow keys move visually in
bidirectional text, Ctrl+arrows follow reading order, and Bold/Italic/
Size/Font cover complex-script text. Ribbon buttons with code-drawn
icons, a mirrored ruler for RTL paragraphs, translations, catalog,
parity and roadmap updates.

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

* chore(contributors): add Saeed Azarehgar to people.toml

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

* fix(engine): para.align with an explicit value sets it, without toggling

Only the alignment buttons toggle back to the start edge when pressed
again. para.align (used by the Paragraph dialog, scripts and agents) now
sets the requested alignment, as seen on the page, in each selected
paragraph's own direction. Previously pressing OK in the dialog with
Center or Right selected reset the paragraph to the start edge.

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

* feat(ui): Direction option in the Paragraph dialog

Add Word's Direction (Right-to-left / Left-to-right) to the Paragraph
dialog's General section. The dialog now shows alignment and Left/Right
indents as seen on the page, mapping them to the paragraph's logical
start/end values on OK, so an unchanged OK no longer flips a
right-to-left paragraph. OK logic moved to apply_paragraph, with tests;
ja/zh translations.

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

* test(layout): allow hanging trailing spaces in the RTL display test

A wrapped right-to-left line's trailing spaces hang past its end edge
(the left margin), as left-to-right lines hang them past the right. The
test failed whenever the loaded fallback fonts made a line end there;
it now checks only visible glyphs.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:26:41 -04:00

186 lines
7.8 KiB
Rust

//! Right-to-left editing commands: paragraph direction, alignment as seen on the page, font
//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open.
use serde_json::json;
use wordcraft_doc::props::Align;
use wordcraft_doc::{Pos, StoryRef};
use crate::Session;
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// A session with `text` (paragraphs split on `\n`), every paragraph right to left when `rtl`.
fn session(text: &str, rtl: bool) -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "document.setText", json!({"text": text}));
if rtl {
run(&mut s, "select.all", json!({}));
run(&mut s, "para.rtl", json!({}));
run(&mut s, "caret.docStart", json!({}));
}
s
}
fn props(s: &Session, i: usize) -> wordcraft_doc::props::ParaProps {
s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.clone()
}
fn caret_x(s: &mut Session) -> f32 {
let l = s.layout();
l.caret(&s.sel.focus).unwrap().x
}
#[test]
fn direction_commands_change_only_the_reading_order() {
let mut s = session("سلام دنیا\nHello", false);
run(&mut s, "para.alignCenter", json!({}));
run(&mut s, "para.rtl", json!({}));
assert_eq!(props(&s, 0).bidi, Some(true));
assert_eq!(props(&s, 0).align, Some(Align::Center), "direction doesn't touch alignment");
// Left-to-right again, and `value: false` means the other direction.
run(&mut s, "para.ltr", json!({}));
assert_eq!(props(&s, 0).bidi, Some(false));
run(&mut s, "para.ltr", json!({"value": false}));
assert_eq!(props(&s, 0).bidi, Some(true));
// A new right-to-left paragraph starts at the right margin (start-aligned).
let mut s = session("متن", true);
assert!((caret_x(&mut s) - 540.0).abs() < 0.5, "caret at {}", caret_x(&mut s));
}
#[test]
fn alignment_buttons_mean_what_they_show_in_rtl_paragraphs() {
let mut s = session("متن فارسی", true);
// Align Left in a right-to-left paragraph moves text to the left: its end edge.
run(&mut s, "para.alignLeft", json!({}));
assert_eq!(props(&s, 0).align, Some(Align::Right));
let x_end = s.layout().caret(&Pos::body(0, "متن فارسی".len())).unwrap().x;
assert!((x_end - 72.0).abs() < 0.5, "text ends at the left margin: {x_end}");
// Align Right is the start edge; pressing it again keeps it there.
run(&mut s, "para.alignRight", json!({}));
assert_eq!(props(&s, 0).align, Some(Align::Left));
run(&mut s, "para.alignRight", json!({}));
assert_eq!(props(&s, 0).align, Some(Align::Left));
// Logical values for scripts.
run(&mut s, "para.align", json!({"value": "end"}));
assert_eq!(props(&s, 0).align, Some(Align::Right));
run(&mut s, "para.align", json!({"value": "right"}));
assert_eq!(props(&s, 0).align, Some(Align::Left));
}
#[test]
fn font_commands_reach_persian_text() {
let mut s = session("کتاب خوب", true);
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "format.size", json!({"size": 18}));
run(&mut s, "format.font", json!({"name": "Vazirmatn"}));
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
let c = p.props_of_char(0).clone();
let rc = s.doc.styles.resolve_char(None, &c).complex();
assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true), "the complex-script formatting follows");
// Saved and reopened, Word sees bold and size for the Persian text too.
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
let d = crate::io::open_bytes("x.docx", &bytes).unwrap();
let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone();
let rc = d.styles.resolve_char(None, &c).complex();
assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true));
assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true));
}
#[test]
fn arrow_keys_move_visually_in_rtl_text() {
let fa = "سلام دنیا";
let mut s = session(fa, true);
// From the start (the right edge), Left moves forward through the text.
let x0 = caret_x(&mut s);
run(&mut s, "caret.left", json!({}));
assert!(s.sel.focus.off > 0, "Left moves to the next character in right-to-left text");
assert!(caret_x(&mut s) < x0, "and the caret moves left on the screen");
run(&mut s, "caret.right", json!({}));
assert_eq!(s.sel.focus.off, 0);
// Right at the start edge goes nowhere in the first paragraph (no previous one).
run(&mut s, "caret.right", json!({}));
assert_eq!(s.sel.focus.off, 0);
// Left all the way reaches the end, then the next paragraph.
let mut s = session(&format!("{fa}\nبعدی"), true);
// (Stepping until the paragraph changes: the لا ligature is one caret position for two letters.)
let mut steps = 0;
while s.sel.focus.path.last() == 0 && steps < 40 {
run(&mut s, "caret.left", json!({}));
steps += 1;
}
assert!(steps <= fa.chars().count() + 1, "{steps} steps");
assert_eq!(s.sel.focus.path.last(), 1, "past the left end of the line: the next paragraph");
assert_eq!(s.sel.focus.off, 0, "at its start (right edge)");
// Shift+Left selects forward in logical order.
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.left", json!({"extend": true}));
let (a, b) = s.sel.ordered();
assert_eq!((a.off, b.off > 0), (0, true));
// Left with a selection collapses to its left edge: the logical end.
run(&mut s, "caret.left", json!({}));
assert_eq!(s.sel.focus.off, b.off);
}
#[test]
fn arrow_keys_in_mixed_text_visit_each_position() {
// A left-to-right paragraph with a Persian word: Right walks across the screen.
let t = "ab سلام cd";
let mut s = session(t, false);
let mut xs = vec![caret_x(&mut s)];
for _ in 0..30 {
let before = s.sel.focus.clone();
run(&mut s, "caret.right", json!({}));
if s.sel.focus == before {
break;
}
xs.push(caret_x(&mut s));
}
assert!(xs.windows(2).all(|w| w[1] > w[0]), "each Right moves right: {xs:?}");
assert_eq!(s.sel.focus.off, t.len(), "ends at the end of the paragraph");
}
#[test]
fn word_moves_follow_reading_order() {
let mut s = session("یک دو سه", true);
run(&mut s, "caret.wordLeft", json!({}));
assert!(s.sel.focus.off > 0, "Ctrl+Left goes to the next word in right-to-left text");
run(&mut s, "caret.wordRight", json!({}));
assert_eq!(s.sel.focus.off, 0);
}
#[test]
fn typing_persian_keeps_logical_order() {
let mut s = session("", true);
for w in ["سلام", " ", "WordCraft", " ", "۱۴۰۳"] {
run(&mut s, "text.insert", json!({"text": w}));
}
assert_eq!(s.doc.plain_text(StoryRef::Body), "سلام WordCraft ۱۴۰۳");
// The caret after typing a number at the end sits right after it on the screen.
let l = s.layout();
let c = l.caret(&s.sel.focus).unwrap();
assert!(c.x < 540.0 && c.x > 72.0);
// select.text finds Persian text.
run(&mut s, "select.text", json!({"text": "سلام"}));
assert_eq!(s.selected_text(), "سلام");
}
#[test]
fn explicit_alignment_sets_without_toggling() {
for rtl in [false, true] {
let mut s = session("متن", rtl);
for v in ["center", "center", "right", "right", "left", "left"] {
run(&mut s, "para.align", json!({"value": v}));
let shown = props(&s, 0).align.unwrap_or_default().visual(rtl);
let want = match v {
"center" => Align::Center,
"right" => Align::Right,
_ => Align::Left,
};
assert_eq!(shown, want, "{v} twice stays {v} (rtl={rtl})");
}
}
}