mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 20:07:19 -06:00
Keep inherited complex-script size, bold and italic independent from Latin properties and retain explicit values during DOCX round trips. Preserve ordinary formatting-command behavior and add regression coverage. Resolve eight existing Clippy warnings without changing behavior so all six required CI gates pass. Co-authored-by: ChatGPT <noreply@openai.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
10 KiB
Rust
228 lines
10 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 font_commands_override_inherited_complex_script_properties_explicitly() {
|
|
use wordcraft_doc::props::CharProps;
|
|
|
|
let mut s = session("שלום ABC", true);
|
|
s.doc.styles.default_chr.size_cs = Some(30.0);
|
|
s.doc.styles.default_chr.bold_cs = Some(true);
|
|
s.doc.styles.default_chr.italic_cs = Some(true);
|
|
run(&mut s, "select.all", json!({}));
|
|
run(&mut s, "format.bold", json!({"value": false}));
|
|
run(&mut s, "format.italic", json!({"value": false}));
|
|
run(&mut s, "format.size", json!({"size": 18}));
|
|
for (command, expected_size) in [
|
|
(None, 18.0),
|
|
(Some("format.growFont"), 20.0),
|
|
(Some("format.shrinkFont"), 18.0),
|
|
(Some("format.growFont1"), 19.0),
|
|
(Some("format.shrinkFont1"), 18.0),
|
|
] {
|
|
if let Some(command) = command {
|
|
run(&mut s, command, json!({}));
|
|
}
|
|
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
|
let c = p.props_of_char(0);
|
|
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false)));
|
|
let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap();
|
|
let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0);
|
|
let cs = d.styles.resolve_char(None, c).complex();
|
|
assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false));
|
|
}
|
|
// The font dialog's command keeps formatting both scripts when given one value.
|
|
run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}}));
|
|
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
|
let c: &CharProps = p.props_of_char(0);
|
|
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true)));
|
|
// Callers can still supply distinct values explicitly, including false.
|
|
run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}}));
|
|
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
|
let c = p.props_of_char(0);
|
|
assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false)));
|
|
}
|
|
|
|
#[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})");
|
|
}
|
|
}
|
|
}
|