mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #169 from Hevarh1: Bidi: auto-detect RTL paragraph direction when typing Kurdish, Arabic and Hebrew
This commit is contained in:
@@ -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(¶.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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}مرحبا"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Style> = vec![];
|
||||
let mut faces: Vec<Arc<FontFace>> = vec![];
|
||||
// The logical paragraph: its text and each character's style.
|
||||
|
||||
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user