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:
Brandon Thomas
2026-10-14 15:53:57 -04:00
76 changed files with 6113 additions and 477 deletions
+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}مرحبا"));
}
}
+87
View File
@@ -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");
}
}
+6
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};
@@ -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)>,
},
}