diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index d4fd26f3..0d43c62f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use deckcraft_model::edit::{self as ed, Pos}; use deckcraft_model::resolve::{self, Ctx}; -use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; +use deckcraft_model::text::{Align, AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; use deckcraft_model::{Presentation, ShapeId, ShapeKind}; use serde_json::{Value, json}; @@ -322,12 +322,26 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) let c = ed::insert(body, at, &text, props); t.anchor = c; t.caret = c; + for pi in at.0..=c.0 { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para); + } + } c })?; autocorrect(s, text.chars().count() == 1)?; Ok(json!({"caret": [pos.0, pos.1]})) } +fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph) { + if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { + para.props.rtl = Some(true); + if para.props.align.is_none() { + para.props.align = Some(Align::Right); + } + } +} + /// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked /// run, the props to type with are the run's without the link. `None` elsewhere. fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { @@ -604,6 +618,11 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { let c = ed::insert_body(body, at, src); t.anchor = c; t.caret = c; + for pi in at.0..=c.0 { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para); + } + } c })?; Ok(json!({"caret": [pos.0, pos.1]})) @@ -1055,6 +1074,7 @@ fn set(s: &mut Session, p: &Value) -> Result { para.level = *lv; para.props = pp.clone(); } + promote_rtl_if_needed(para); for r in &mut para.runs { r.props = keep.clone(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1eb87ae0..e62ce1a8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -488,7 +488,7 @@ fn right_to_left_paragraphs() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); - s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap(); + s.execute("text.insert", &json!({"text": "Year 2024 (Retrait) only"})).unwrap(); s.execute("format.rtl", &json!({})).unwrap(); let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); assert_eq!(para(&s).rtl, Some(true)); @@ -508,6 +508,51 @@ fn right_to_left_paragraphs() { assert_eq!(para(&s).rtl, Some(true)); } +#[test] +fn typing_kurdish_or_arabic_auto_detects_rtl_and_advances_caret_smoothly() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + + let mut first_x = None; + let mut last_x = 0.0; + + // Type Kurdish sentence character-by-character: "من ناوم هێڤاره" + for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { + s.execute("text.insert", &json!({"text": ch})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.as_ref().unwrap(); + let l = super::cmd::text::layout_for(st, t).unwrap(); + let (cx, ..) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }).unwrap(); + + if first_x.is_none() { + first_x = Some(cx); + } + last_x = cx; + + // Caret must remain near the right margin (e.g. > 400.0 pt in title placeholder) + // and must NOT jump back to the left margin (e.g. ~7.2 pt). + assert!(cx > 400.0, "caret should not jump to left margin: {cx}"); + } + + // Caret advances leftwards overall as text is appended + assert!(last_x < first_x.unwrap(), "caret should advance leftwards: last={last_x} first={:?}", first_x); + let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(true), "auto-detected as RTL"); + assert_eq!(para.align, Some(deckcraft_model::text::Align::Right), "aligned right"); +} + +#[test] +fn explicit_ltr_override_is_preserved_when_typing_rtl() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("format.rtl", &json!({"on": false})).unwrap(); + s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap(); + let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(false), "user explicit LTR is preserved"); +} + #[test] fn header_footer_fixed_date_is_the_slides_own_text() { use deckcraft_model::text::RunKind; diff --git a/crates/model/src/bidi.rs b/crates/model/src/bidi.rs new file mode 100644 index 00000000..0b113522 --- /dev/null +++ b/crates/model/src/bidi.rs @@ -0,0 +1,81 @@ +//! Bidirectional text helpers (UAX #9). +//! +//! Follows Unicode Bidirectional Algorithm (UAX #9 rules P2 and P3) for paragraph +//! base direction auto-detection. + +/// 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}' { + // Right-to-Left Mark (RLM) + return true; + } + if c == '\u{200E}' { + // Left-to-Right Mark (LRM) + return false; + } + if c.is_alphabetic() { + return is_rtl_script(c); + } + } + false +} + +/// Helper checking if a char belongs to standard RTL Unicode blocks. +pub fn is_rtl_script(c: char) -> bool { + matches!( + c, + '\u{0590}'..='\u{05FF}' // Hebrew + | '\u{0600}'..='\u{06FF}' // Arabic + | '\u{0700}'..='\u{074F}' // Syriac + | '\u{0750}'..='\u{077F}' // Arabic Supplement + | '\u{0780}'..='\u{07BF}' // Thaana + | '\u{0870}'..='\u{089F}' // Arabic Extended-B + | '\u{08A0}'..='\u{08FF}' // Arabic Extended-A + | '\u{FB1D}'..='\u{FB4F}' // Hebrew Presentation Forms + | '\u{FB50}'..='\u{FDFF}' // Arabic Presentation Forms-A + | '\u{FE70}'..='\u{FEFE}' // Arabic Presentation Forms-B + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_arabic_and_kurdish() { + assert!(first_strong_is_rtl("مرحبا")); + assert!(first_strong_is_rtl("من ناوم هێڤاره")); + assert!(first_strong_is_rtl("سلام")); + } + + #[test] + fn detects_hebrew() { + assert!(first_strong_is_rtl("שלום")); + } + + #[test] + fn detects_latin_as_not_rtl() { + assert!(!first_strong_is_rtl("Hello")); + assert!(!first_strong_is_rtl("DeckCraft")); + } + + #[test] + fn skips_neutrals_numbers_and_whitespace() { + assert!(first_strong_is_rtl(" 123 مرحبا")); + assert!(first_strong_is_rtl("... (2024) سنة")); + assert!(!first_strong_is_rtl(" 123 Hello")); + assert!(!first_strong_is_rtl("")); + assert!(!first_strong_is_rtl(" 12345 ")); + } + + #[test] + fn honours_directional_marks() { + assert!(first_strong_is_rtl("\u{200F}Hello")); + assert!(!first_strong_is_rtl("\u{200E}مرحبا")); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 82c9100e..29112e16 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] pub mod anim; +pub mod bidi; pub mod chart; pub mod defaults; pub mod edit; @@ -27,6 +28,7 @@ pub use deckcraft_geom::{Rect, Size, Xfrm}; use serde::{Deserialize, Serialize}; pub use anim::{AnimClass, AnimStart, Animation, Transition}; +pub use bidi::{first_strong_is_rtl, is_rtl_script}; pub use chart::Chart; pub use style::{ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle}; pub use table::{Cell, Table}; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index b9e28fb1..70c58a29 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -712,8 +712,11 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, rtl: false, }; }; - let props = resolve::para(ctx, shape, para); - let rtl = props.rtl.unwrap_or(false); + let mut props = resolve::para(ctx, shape, para); + let rtl = props.rtl.unwrap_or_else(|| deckcraft_model::bidi::first_strong_is_rtl(¶.text())); + if rtl && para.props.align.is_none() { + props.align = Some(Align::Right); + } let mut styles: Vec