From bf58f605fe5fda2411ea9ced539af81384b5d840 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:53:50 +0530 Subject: [PATCH] Text Tweaker: refuse ranges that aren't whole bytes, make no step when nothing changes (#4924) A `start` or `end` such as -1 or 1.5 was read as absent and changed text before the range; it is now refused. Characters that already have the value are left out, so a run that changes nothing returns count 0 and makes no undo step. docs/mcp.md cites the public formatting-type page. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/addons/typetools.rs | 35 +++++++++++++++++-- .../engine/src/cmd/addons/typetools/tests.rs | 22 ++++++++++-- docs/mcp.md | 3 +- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 0cd8e21ea..29ca6f6bf 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -89,29 +89,58 @@ 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. let mut plans: Vec<(NodeId, Vec<(usize, CharChange)>)> = vec![]; - let mut count = 0; + let (mut seen, mut count) = (0, 0); for (id, a, b, _) in ranges { let t = text_ref(s, id)?; let text = t.plain_text(); let chars: Vec = text.char_indices().filter(|&(i, c)| (a..b).contains(&i) && !(skip && (c.is_whitespace() || c.is_control()))).map(|(i, _)| i).collect(); - count += chars.len(); - if count > MAX_CHARS { + seen += chars.len(); + if seen > MAX_CHARS { return Err(bad(C, format!("too many characters: at most {MAX_CHARS}"))); } + // Each run's end byte, to find the style a character has now. + let ends: Vec = t + .runs + .iter() + .scan(0, |off, r| { + *off += r.text.len(); + Some(*off) + }) + .collect(); let n = chars.len(); let mut changes = Vec::with_capacity(n); + let mut run = 0; for (i, at) in chars.into_iter().enumerate() { let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; let value = from + (to - from) * amount(shape, i, n, random); let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; change.localize(&t, C)?; + while ends.get(run).is_some_and(|&e| e <= at) { + run += 1; + } + // A character that already has the value is left out, so a run that changes nothing + // makes no step. + if let Some(r) = t.runs.get(run) { + let mut style = r.style.clone(); + change.apply(&mut style); + if style == r.style { + continue; + } + } changes.push((at, change)); } + count += changes.len(); if !changes.is_empty() { plans.push((id, changes)); } diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs index 47a1b92b9..fec04f9c6 100644 --- a/crates/engine/src/cmd/addons/typetools/tests.rs +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -62,7 +62,8 @@ fn a_straight_ramp_sizes_each_letter_and_skips_spaces() { select(&mut s, &[id]); let n = steps(&s); let out = s.execute(C, &json!({"attribute": "size", "from": 10, "to": 40})).unwrap(); - assert_eq!(out, json!({"count": 4, "ids": [id.0]})); + // `b` is 20 pt already: three characters change. + assert_eq!(out, json!({"count": 3, "ids": [id.0]})); assert_eq!(steps(&s), n + 1, "one undo step"); let sizes: Vec<(char, f64)> = styles(&s, id).into_iter().map(|(c, st)| (c, st.size)).collect(); assert_eq!(sizes, [('a', 10.0), ('b', 20.0), (' ', 20.0), ('c', 30.0), ('d', 40.0)], "the space keeps its size"); @@ -108,7 +109,8 @@ fn each_object_gets_its_own_ramp() { let (a, b) = (text(&mut s, "abc"), text(&mut s, "de")); select(&mut s, &[a, b]); let out = s.execute(C, &json!({"attribute": "rotation", "from": 0, "to": 30})).unwrap(); - assert_eq!(out["count"], json!(5)); + // Each first character is upright already. + assert_eq!(out["count"], json!(3)); let turns = |id| styles(&s, id).iter().map(|(_, st)| st.rotation).collect::>(); assert_eq!((turns(a), turns(b)), (vec![0.0, 15.0, 30.0], vec![0.0, 30.0])); } @@ -161,6 +163,19 @@ fn empty_text_and_only_spaces_change_nothing() { assert_eq!(steps(&s), n, "no step"); } +#[test] +fn characters_that_already_have_the_value_are_left_out() { + let mut s = session(); + let id = text(&mut s, "abc"); + select(&mut s, &[id]); + let p = json!({"attribute": "rotation", "from": 0, "to": 30}); + // The first character already has 0°. + assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 2, "ids": [id.0]})); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 0, "ids": []}), "again: nothing to change"); + assert_eq!((s.doc().unwrap().doc.clone(), steps(&s)), (before, n), "no step"); +} + #[test] fn bad_params_are_refused_without_a_change() { let mut s = session(); @@ -179,6 +194,9 @@ fn bad_params_are_refused_without_a_change() { json!({"seed": -1}), json!({"skipSpaces": "yes"}), json!({"ids": [999_999]}), + json!({"id": id.0, "start": -1}), + json!({"id": id.0, "start": 1, "end": 1.5}), + json!({"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 5fab5134a..4036c1c1b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6838,7 +6838,8 @@ there is no step. → `{symbols: [name…], ids: [instance id…], skipped: [{la Type Tools is a built-in add-on for type: its command is `addon.type.textTweaker`, and in the desktop app it lives in Object › Plug-ins › Type Tools (#4924). Text Tweaker gives one character attribute to the characters letter by letter, from a start value to an end value along a shape, as one undo step; the text stays live and editable. Each value is set -as `text.setRangeStyle` sets it (the Character panel), in its units and ranges, so on scaled type it is the size shown. +as `text.setRangeStyle` sets it (the Character panel), in its units and ranges ([formatting type](https://helpx.adobe.com/illustrator/using/formatting-type.html)), so on scaled +type it is the size shown. It changes the range of `id` (`start`/`end` bytes), else all the text of the type objects in `ids`, else the text the Type tool has selected (at a caret: all of that object's text), else all the text of every selected type object; each object gets its own ramp, and at most 100000 characters change in one run. A run that changes nothing makes no step.