From 595db10900d12fa3e1dd4d86fff2947593285c0b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 23:17:20 -0800 Subject: [PATCH] Type: refuse negative or fractional byte offsets in text commands text.startList read a negative or fractional `at` as the end of the text, so it could remove a typed list marker there and start a list. The `start` and `end` of text.editRange, text.getRange, text.setRangeStyle and the type shortcut commands given an `id` read such values as the start or end of the text. These now return "`at` must be a whole number" (naming the key) and change nothing. Omitted or null keys keep their defaults, and 2.0 is read as 2. --- crates/engine/src/cmd/textedit.rs | 21 +++++++++++++++------ crates/engine/src/tests/textedit.rs | 27 +++++++++++++++++++++++++++ docs/mcp.md | 5 +++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index f0a331b0a..03943029e 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, T use vectorcraft_geom::{Affine, Point, Vec2}; use vectorcraft_text::edit; +use super::menucmds::whole_or; use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::{ @@ -233,12 +234,21 @@ fn byte_param(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_u64).map(index) } +/// The byte offset that `p` gives for `k`, or `default` when `p` leaves it out or gives null. Any +/// other value that is not a whole number of at least 0, such as `"2"`, `-1` or `1.5`, is an error. +fn offset_or(p: &Value, k: &str, default: usize, cmd: &str) -> Result { + match p.get(k) { + None | Some(Value::Null) => Ok(default), + Some(_) => Ok(index(whole_or(p, k, 0, cmd)?)), + } +} + /// The bytes `start..end` of `p` in text of `len` bytes, clamped and ordered (without them, all of -/// it). A `start` or `end` that is not a number or null, such as `"2"`, is an error. +/// it). A `start` or `end` that is not a whole number of at least 0 or null, such as `"2"`, `-1` +/// or `1.5`, is an error. fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> { - check_numbers(p, &["start", "end"], cmd)?; - let a = byte_param(p, "start").unwrap_or(0).min(len); - let b = byte_param(p, "end").unwrap_or(len).min(len); + let a = offset_or(p, "start", 0, cmd)?.min(len); + let b = offset_or(p, "end", len, cmd)?.min(len); Ok((a.min(b), a.max(b))) } @@ -310,10 +320,9 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result Result { const C: &str = "text.startList"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; - check_numbers(p, &["at"], C)?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); - let at = byte_param(p, "at").unwrap_or(len).min(len); + let at = offset_or(p, "at", len, C)?.min(len); let Some((start, list)) = t.typed_list_at(at) else { return Ok(json!({"id": id.0, "started": false, "caret": at})); }; diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 9e346bf65..fa73e9d50 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1200,6 +1200,33 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() { assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required"); } +/// A byte offset given as a negative or fractional number is an error, not the end of the text +/// (`at`) or its start or end (`start`, `end`), and the text is left as it was. Null keeps the default. +#[test] +fn byte_offsets_that_are_not_whole_numbers_are_refused() { + let mut s = session(); + let id = text(&mut s, "x\n# "); + for at in [json!(-1), json!(1.5)] { + let e = s.execute("text.startList", &json!({"id": id.0, "at": at})).unwrap_err().to_string(); + assert!(e.contains("`at`"), "{e}"); + } + assert_eq!(obj(&s, id).plain_text(), "x\n# ", "no list was started"); + assert_eq!(s.execute("text.startList", &json!({"id": id.0, "at": null})).unwrap()["started"], json!(true)); + let id = text(&mut s, "Hello"); + for (cmd, p, key) in [ + ("text.getRange", json!({"id": id.0, "start": -1, "end": 2}), "start"), + ("text.getRange", json!({"id": id.0, "start": 1, "end": 2.5}), "end"), + ("text.editRange", json!({"id": id.0, "start": 1.5, "end": 2, "insert": "!"}), "start"), + ("text.editRange", json!({"id": id.0, "start": 1, "end": -2, "insert": "!"}), "end"), + ("text.setRangeStyle", json!({"id": id.0, "start": 0.5, "size": 30}), "start"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(&format!("`{key}`")), "{cmd} {p}: {e}"); + } + assert_eq!(obj(&s, id).plain_text(), "Hello"); + assert_eq!(s.execute("text.getRange", &json!({"id": id.0, "start": 1.0, "end": null})).unwrap()["text"], json!("ello")); +} + /// #1881: a Cmd/Ctrl drag while typing (the Selection tool lent to the Type tool) moves the type /// and keeps what was typed, and typing goes on after it. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 398e0fdbd..e50412eb5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1207,6 +1207,11 @@ and `amount` in `color.harmony`; `angle` in `paint.editGradient` and in the `gra `width` and `tolerance` in `livePaint.strokeEdge`. A null is read as a key left out. A gradient stop that names a swatch refuses a `tint` that is not a number with `` stop 0: `tint` must be a number ``. +A byte offset must be a whole number of at least 0. `at` in `text.startList`, and `start` and `end` in +`text.editRange`, `text.getRange`, `text.setRangeStyle` and the `type.step`, `type.size.*`, `type.resetTracking` and +`type.resetHorizontalScale` commands given an `id`, refuse a negative or fractional number with +`` `at` must be a whole number ``, naming the key, and change nothing. `2.0` is read as `2`, and a null as the key left out. + A step can use an earlier step's result, in `run` as in the `command_batch` tool. A parameter string that is exactly `$N` or `$N.path` takes that value from the result of step `N` (counting from 0, in the order they ran; `run --in`'s open isn't a step), and `$last…` from the step before. A path goes into objects by key and arrays by index: