mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-01 14:37:05 -06:00
Merge the landing branch into land-r8
# Conflicts: # contributors/people.toml # crates/engine/src/cmd/file.rs # crates/pptx/src/opc.rs # crates/pptx/tests/roundtrip.rs # crates/ui-egui/src/backstage.rs # crates/ui-egui/src/lib.rs
This commit is contained in:
@@ -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}مرحبا"));
|
||||
}
|
||||
}
|
||||
@@ -401,6 +401,44 @@ pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of the `n`th character of the body's plain text (`\n` between paragraphs).
|
||||
fn pos_of_char(body: &TextBody, mut n: usize) -> Pos {
|
||||
for (pi, p) in body.paragraphs.iter().enumerate() {
|
||||
let len = p.char_len();
|
||||
if n <= len {
|
||||
return (pi, n);
|
||||
}
|
||||
n -= len + 1;
|
||||
}
|
||||
end(body)
|
||||
}
|
||||
|
||||
/// Make the body's plain text (paragraphs joined by `\n`) read `text`, as if it had been typed in
|
||||
/// place: only the span between the longest common prefix and suffix changes, so untouched text
|
||||
/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text
|
||||
/// that shares nothing with the old text replaces it as plain text.
|
||||
pub fn replace_text(body: &mut TextBody, text: &str) {
|
||||
let old: Vec<char> = body.text().chars().collect();
|
||||
let new: Vec<char> = text.chars().collect();
|
||||
if old == new {
|
||||
return;
|
||||
}
|
||||
let pre = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
|
||||
let room = old.len().min(new.len()) - pre;
|
||||
let suf = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count();
|
||||
if pre == 0 && suf == 0 {
|
||||
*body = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), ..TextBody::from_text(text) };
|
||||
return;
|
||||
}
|
||||
let a = pos_of_char(body, pre);
|
||||
let b = pos_of_char(body, old.len() - suf);
|
||||
let at = delete(body, a, b);
|
||||
let mid: String = new.get(pre..new.len() - suf).unwrap_or(&[]).iter().collect();
|
||||
if !mid.is_empty() {
|
||||
insert(body, at, &mid, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// End of the body.
|
||||
pub fn end(body: &TextBody) -> Pos {
|
||||
let pi = body.paragraphs.len().saturating_sub(1);
|
||||
@@ -676,4 +714,53 @@ mod tests {
|
||||
assert_eq!(next_case("hello. world is HERE"), "upper");
|
||||
assert_eq!(next_case("123"), "upper");
|
||||
}
|
||||
|
||||
/// Speaker notes: a bold heading over indented bullet points.
|
||||
fn notes() -> TextBody {
|
||||
use crate::text::{Bullet, ParaProps};
|
||||
let mut b = body("Overview\nFirst point\nSecond point");
|
||||
format(&mut b, (0, 0), (0, 8), &|p| p.bold = Some(true));
|
||||
for p in b.paragraphs.iter_mut().skip(1) {
|
||||
p.level = 1;
|
||||
p.props =
|
||||
ParaProps { margin_left: Some(36.0), indent: Some(-18.0), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_text_keeps_formatting() {
|
||||
let before = notes();
|
||||
// Same text: nothing changes.
|
||||
let mut b = before.clone();
|
||||
replace_text(&mut b, "Overview\nFirst point\nSecond point");
|
||||
assert_eq!(b, before);
|
||||
// A word typed into a bullet: heading stays bold, bullets keep level, indent and bullet.
|
||||
replace_text(&mut b, "Overview\nFirst main point\nSecond point");
|
||||
assert_eq!(b.text(), "Overview\nFirst main point\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(b.paragraphs[1].props, before.paragraphs[1].props);
|
||||
assert_eq!((b.paragraphs[1].level, b.paragraphs[2].level), (1, 1));
|
||||
// Typing at the end of the heading continues it in bold.
|
||||
replace_text(&mut b, "Overview (5 min)\nFirst main point\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs.len(), 1);
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
// Enter after a bullet starts another bullet; deleting one joins nothing else.
|
||||
replace_text(&mut b, "Overview (5 min)\nFirst main point\nThird\nSecond point");
|
||||
assert_eq!(b.paragraphs.len(), 4);
|
||||
assert_eq!((b.paragraphs[2].level, &b.paragraphs[2].props), (1, &before.paragraphs[1].props));
|
||||
replace_text(&mut b, "Overview (5 min)\nSecond point");
|
||||
assert_eq!(b.text(), "Overview (5 min)\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(b.paragraphs[1].level, 1);
|
||||
// Text with nothing in common is new plain text.
|
||||
replace_text(&mut b, "Plain");
|
||||
assert_eq!(b, body("Plain"));
|
||||
replace_text(&mut b, "");
|
||||
assert!(b.is_empty());
|
||||
// Into an empty body.
|
||||
let mut e = TextBody::default();
|
||||
replace_text(&mut e, "a\nb");
|
||||
assert_eq!(e.text(), "a\nb");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -315,6 +317,10 @@ pub enum ShapeKind {
|
||||
xml: String,
|
||||
preview: Option<MediaId>,
|
||||
label: String,
|
||||
/// The parts its XML points to (SmartArt data, an OLE embedding…), so a save writes them
|
||||
/// back: (relationship id used in `xml`, relationship type, the part as a media item).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
parts: Vec<(String, String, MediaId)>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user