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 <noreply@anthropic.com>
This commit is contained in:
ruchirguitar
2026-11-04 12:44:44 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 0dba3afea9
commit bf58f605fe
3 changed files with 54 additions and 6 deletions
+32 -3
View File
@@ -89,29 +89,58 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result<Value> {
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<usize> =
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<usize> = 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));
}
@@ -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::<Vec<_>>();
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}");
+2 -1
View File
@@ -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.