diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index b92cd85d..e9921ba9 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -692,7 +692,8 @@ fn case(s: &mut Session, p: &Value) -> Result { } 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 }; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index fbba9356..5e7047f8 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -417,7 +417,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) 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 { 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 { } 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 { 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); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab39..c0381746 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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();