From 595db10900d12fa3e1dd4d86fff2947593285c0b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 23:17:20 -0800 Subject: [PATCH 1/2] 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: From e3a55b97fddf01755386e8850e49b18fafa4ad12 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 01:30:31 -0800 Subject: [PATCH 2/2] Type: Text Tweaker leaves byte offsets to range_of Remove Text Tweaker's own start and end check: range_of already refuses a bad offset given with an id, and like the other type commands Text Tweaker now ignores start and end without one. The test's "2" case gets an id so it still reaches the check, and docs/mcp.md lists addon.type.textTweaker with the commands that refuse such offsets. --- crates/engine/src/cmd/addons/typetools.rs | 6 ------ crates/engine/src/cmd/addons/typetools/tests.rs | 2 +- docs/mcp.md | 4 ++-- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 29ca6f6bf..320dce6b9 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -89,12 +89,6 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result { let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); let skip = switch_or(p, "skipSpaces", true, C)?; let setup = s.doc()?.doc.setup.clone(); - // A range given as anything but whole bytes is refused, not read as all of the text. - for k in ["start", "end"] { - if p.get(k).is_some_and(|v| !v.is_null() && v.as_u64().is_none()) { - return Err(bad(C, format!("`{k}` must be a whole number of bytes"))); - } - } // At a caret, all of the object's text. let ranges = type_ranges(s, p, C, |text, _| Some((0, text.len())))?; // Each object's changed characters: (byte, its change), worked out before the edit. diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs index fec04f9c6..6c56f6769 100644 --- a/crates/engine/src/cmd/addons/typetools/tests.rs +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -196,7 +196,7 @@ fn bad_params_are_refused_without_a_change() { json!({"ids": [999_999]}), json!({"id": id.0, "start": -1}), json!({"id": id.0, "start": 1, "end": 1.5}), - json!({"end": "2"}), + json!({"id": id.0, "end": "2"}), ] { assert!(s.execute(C, &p).is_err(), "{p}"); assert_eq!(s.doc().unwrap().doc, before, "{p}"); diff --git a/docs/mcp.md b/docs/mcp.md index e50412eb5..7c0499a06 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1208,8 +1208,8 @@ and `amount` in `color.harmony`; `angle` in `paint.editGradient` and in the `gra 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 +`text.editRange`, `text.getRange`, `text.setRangeStyle` and the `type.step`, `type.size.*`, `type.resetTracking`, +`type.resetHorizontalScale` and `addon.type.textTweaker` 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