Merge PR #813: engine: a hostile text selection can't drive paragraph commands past the body

This commit is contained in:
aiongg
2026-10-24 06:15:03 +00:00
3 changed files with 36 additions and 6 deletions
+2 -1
View File
@@ -692,7 +692,8 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
}
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
super::text::edit_text(s, move |body, t| {
for pi in a.0..=b.0 {
let last = body.paragraphs.len().saturating_sub(1);
for pi in a.0..=b.0.min(last) {
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
let n = para.char_len();
let from = if pi == a.0 { a.1 } else { 0 };
+8 -5
View File
@@ -417,7 +417,8 @@ 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 {
let last = body.paragraphs.len().saturating_sub(1);
for pi in at.0..=c.0.min(last) {
if let Some(para) = body.paragraphs.get_mut(pi) {
promote_rtl_if_needed(para, &inherited);
}
@@ -784,7 +785,8 @@ 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 {
let last = body.paragraphs.len().saturating_sub(1);
for pi in at.0..=c.0.min(last) {
if let Some(para) = body.paragraphs.get_mut(pi) {
promote_rtl_if_needed(para, &inherited);
}
@@ -1124,7 +1126,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
}
None => match to.as_str() {
"up" => (caret.0.saturating_sub(1), caret.1),
"down" => ((caret.0 + 1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
"down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
"lineStart" => (caret.0, 0),
_ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
},
@@ -1193,7 +1195,7 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1);
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
s.select(|_, sel| {
if let Some(t) = sel.text.as_mut() {
t.anchor = (at.0, 0);
@@ -1373,7 +1375,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
if body.paragraphs.is_empty() {
body.paragraphs.push(Default::default());
}
for pi in a.0..=b.0 {
let last = body.paragraphs.len().saturating_sub(1);
for pi in a.0..=b.0.min(last) {
if let Some(p) = body.paragraphs.get_mut(pi) {
f(p);
}
+26
View File
@@ -3893,6 +3893,32 @@ fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() {
assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place");
}
#[test]
fn paragraph_commands_clamp_a_hostile_text_selection() {
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.select", &json!({"anchor": [0, 0], "caret": [u64::MAX, 0]})).unwrap();
s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]})).unwrap();
s.execute("format.alignCenter", &json!({})).unwrap();
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
s.execute("text.insert", &json!({"text": "A"})).unwrap();
let t = s.doc().unwrap().selection.text.clone().unwrap();
assert!(t.caret.0 < 1_000, "the caret stayed inside the body: {t:?}");
}
#[test]
fn paragraph_commands_clamp_a_direct_hostile_text_selection() {
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.select", &json!({"anchor": [0, 0], "caret": [u64::MAX, 0]})).unwrap();
s.execute("format.alignCenter", &json!({})).unwrap();
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.as_ref().unwrap();
assert_eq!(body.paragraphs[0].props.align, Some(deckcraft_model::text::Align::Center));
}
#[test]
fn table_column_width_of_a_huge_column_is_ignored() {
let mut s = session();