Merge PR #169 from Hevarh1: Bidi: auto-detect RTL paragraph direction when typing Kurdish, Arabic and Hebrew

This commit is contained in:
Brandon Thomas
2026-10-14 15:14:09 -04:00
6 changed files with 168 additions and 4 deletions
+21 -1
View File
@@ -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<RunProps>)
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(&para.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<RunProps> {
@@ -604,6 +618,11 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {
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<Value> {
para.level = *lv;
para.props = pp.clone();
}
promote_rtl_if_needed(para);
for r in &mut para.runs {
r.props = keep.clone();
}
+46 -1
View File
@@ -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;
+81
View File
@@ -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}مرحبا"));
}
}
+2
View File
@@ -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};
+5 -2
View File
@@ -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(&para.text()));
if rtl && para.props.align.is_none() {
props.align = Some(Align::Right);
}
let mut styles: Vec<Style> = vec![];
let mut faces: Vec<Arc<FontFace>> = vec![];
// The logical paragraph: its text and each character's style.
+13
View File
@@ -504,6 +504,19 @@ fn rtl_left_and_center_alignment() {
assert!((c - (l.inner.x0 + l.inner.x1) / 2.0).abs() < 0.5);
}
#[test]
fn layout_auto_detects_rtl_from_first_strong_character() {
let (p, mut sh) = bidi_setup(vec![Run::new("من ناوم هێڤاره")], 400.0, false, Align::Left);
// Leave props.rtl as None and props.align as None (unassigned)
sh.text.as_mut().unwrap().paragraphs[0].props.rtl = None;
sh.text.as_mut().unwrap().paragraphs[0].props.align = None;
let l = lay(&p, &sh, 400.0, 300.0);
let line = &l.lines[0];
assert!(line.rtl, "line should auto-detect as RTL");
// Line aligns to the right edge
assert!(line.extent.1 > 350.0, "extent right: {}", line.extent.1);
}
#[test]
fn bidi_caret_stops_round_trip_through_hit_testing() {
for rtl in [true, false] {