Merge pull request #7776 from dotCooCoo/feat/aia-files-carry-select-object-steps

Actions: Load Actions… reads .aia Set Selection steps as Select Object
This commit is contained in:
BFlat
2026-11-15 17:31:10 +07:00
committed by GitHub
10 changed files with 326 additions and 32 deletions
+77 -17
View File
@@ -9,8 +9,9 @@
//! text. Each action is an `/action-N` block with a hexadecimal `/name`; each step is an `/event-N`
//! block inside one, with its `/internalName` in parentheses, the `/localizedName` it is shown with
//! as hexadecimal text, and `/isOn` 0 when it is left out of playback; each value a step was
//! recorded with is a `/parameter-N` block inside it, which is counted, and whose hexadecimal
//! `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount`
//! recorded with is a `/parameter-N` block inside it, which is counted, whose `/key` (a number),
//! `/type` (text in parentheses) and `/value` (a number, text in parentheses or hexadecimal text) are
//! kept, and whose hexadecimal `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount`
//! and a step's `/parameterCount` give how many actions, steps and recorded values follow.
//! The layout follows the README of Inventsable's Redesigning-Illustrator-Actions-in-Scripting
//! (https://github.com/Inventsable/Redesigning-Illustrator-Actions-in-Scripting), the forum post
@@ -40,6 +41,8 @@ const MAX_STEPS: usize = 10_000;
/// The most steps read in the set: a small text can hold hundreds of thousands of empty steps, and
/// the answer holds an object for each.
const MAX_SET_STEPS: usize = 100_000;
/// The most recorded values kept in the set; the others are counted but not kept.
const MAX_VALUES: usize = 100_000;
/// The longest name kept (bytes).
const MAX_NAME: usize = 1024;
/// The most tokens listed as unread.
@@ -56,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Read Action Set File",
[],
None,
"{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error",
"{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, values: [{key: the block's /key, or null, type: its /type, or null, value: its /value when it is a number, or text that is UTF-8 (else null), bytes?: the text's length in bytes}] (the first 100,000 recorded values in the set), line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error",
always,
read
)]
@@ -73,12 +76,33 @@ pub struct AiaStep {
pub on: Option<i64>,
/// How many `/parameter-N` blocks it has.
pub params: usize,
/// Its `/parameter-N` blocks, in order, while the set keeps at most [`MAX_VALUES`].
pub values: Vec<AiaValue>,
/// Its `/parameterCount`, if it has one.
pub declared: Option<i64>,
/// The line its block opens on (from 1).
pub line: usize,
}
/// A value a step was recorded with: a `/parameter-N` block.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AiaValue {
/// Its `/key`, if it has one that fits an `i64`.
pub key: Option<i64>,
/// Its `/type`, read as UTF-8 with replacement characters, if it has one.
pub kind: Option<String>,
/// Its `/value`, if it is a number that fits an `i64` or text.
pub value: Option<AiaData>,
}
/// A recorded `/value` that is kept.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AiaData {
Int(i64),
/// The bytes of text in parentheses or of hexadecimal text.
Text(Vec<u8>),
}
/// An action: an `/action-N` block.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AiaAction {
@@ -106,6 +130,8 @@ pub struct AiaSet {
pub truncated: bool,
/// How many steps were read in all the actions.
steps: usize,
/// How many recorded values were kept in all the steps.
values: usize,
}
/// A token of the text.
@@ -252,7 +278,8 @@ fn hex_bytes(digits: &[u8]) -> Option<Vec<u8>> {
enum Kind {
Action,
Step,
Param,
/// A recorded value, and whether it is kept.
Param(bool),
/// A block that isn't read, or one past a cap.
Other,
}
@@ -315,12 +342,18 @@ fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kin
}
},
Some(Kind::Step) if numbered(name, b"parameter-") => {
let mut kept = false;
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
s.params += 1;
if set.values < MAX_VALUES {
s.values.push(AiaValue::default());
set.values += 1;
kept = true;
}
}
Kind::Param
Kind::Param(kept)
}
Some(Kind::Param | Kind::Other) => Kind::Other,
Some(Kind::Param(_) | Kind::Other) => Kind::Other,
_ => {
unread(set, line, format!("/{} {{", String::from_utf8_lossy(name)));
Kind::Other
@@ -355,12 +388,23 @@ fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Op
}
}
/// Mark `set` truncated when the hexadecimal `/value` on `line` isn't as many bytes as its count
/// says.
fn check_value(set: &mut AiaSet, bytes: Option<Vec<u8>>, line: usize) {
if bytes.is_none() {
/// Set the field `name` of the recorded value open on `line` to `token`: its `/key`, `/type` or
/// `/value`, when the value is `kept`. A hexadecimal `/value` that isn't as many bytes as its count
/// says marks `set` truncated, kept or not.
fn set_param(set: &mut AiaSet, kept: bool, name: Option<&[u8]>, token: Token, line: usize) {
if name == Some(b"value".as_slice()) && matches!(token, Token::Hex(None)) {
set.truncated = true;
unread(set, line, BAD_HEX);
return;
}
let value = set.actions.last_mut().and_then(|a| a.steps.last_mut()).and_then(|s| s.values.last_mut());
let Some(v) = value.filter(|_| kept) else { return };
match (name, token) {
(Some(b"key"), Token::Int(key)) => v.key = key,
(Some(b"type"), Token::Text(t)) => v.kind = Some(String::from_utf8_lossy(&t).into_owned()),
(Some(b"value"), Token::Int(Some(n))) => v.value = Some(AiaData::Int(n)),
(Some(b"value"), Token::Text(t) | Token::Hex(Some(t))) => v.value = Some(AiaData::Text(t)),
_ => {}
}
}
@@ -448,13 +492,13 @@ pub fn read_aia(bytes: &[u8]) -> std::result::Result<AiaSet, String> {
unread(&mut set, line, "}");
}
}
Token::Hex(bytes) if matches!(stack.last(), Some(Kind::Param)) => {
if field(&mut pending).as_deref() == Some(b"value".as_slice()) {
check_value(&mut set, bytes, line);
}
_ if matches!(stack.last(), Some(Kind::Param(_))) => {
let kept = matches!(stack.last(), Some(Kind::Param(true)));
let name = field(&mut pending);
set_param(&mut set, kept, name.as_deref(), token, line);
}
// The other values inside a recorded value, or a block that isn't read.
_ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => {
// The values inside a block that isn't read.
_ if matches!(stack.last(), Some(Kind::Other)) => {
pending = None;
}
Token::Hex(bytes) => {
@@ -521,6 +565,21 @@ pub fn read_aia(bytes: &[u8]) -> std::result::Result<AiaSet, String> {
Ok(set)
}
/// A recorded value in `actionSet.read`'s answer: `{key, type, value, bytes?}`, the value a number,
/// text that is UTF-8 (else null) with its length in bytes, or null.
fn value_json(v: &AiaValue) -> Value {
let mut out = json!({ "key": v.key, "type": v.kind, "value": null });
match &v.value {
Some(AiaData::Int(n)) => out["value"] = json!(n),
Some(AiaData::Text(t)) => {
out["value"] = json!(std::str::from_utf8(t).ok());
out["bytes"] = json!(t.len());
}
None => {}
}
out
}
fn read(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "actionSet.read";
let (path, data, b64) = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?);
@@ -544,7 +603,8 @@ fn read(_: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|s| {
let label = s.label.clone().filter(|l| !l.trim().is_empty());
json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "line": s.line })
let values: Vec<Value> = s.values.iter().map(value_json).collect();
json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "values": values, "line": s.line })
})
.collect();
json!({ "name": a.name, "steps": steps })
+57 -3
View File
@@ -3,7 +3,7 @@
use serde_json::json;
use crate::cmd::actionset::{AiaStep, read_aia};
use crate::cmd::actionset::{AiaData, AiaStep, AiaValue, read_aia};
use crate::*;
/// `text` as a bracketed hexadecimal value.
@@ -37,7 +37,12 @@ fn a_set_reads_its_actions_and_steps() {
let names: Vec<&str> = set.actions.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["First", "二番目"]);
let step = |a: usize, s: usize| set.actions[a].steps[s].clone();
let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, declared: Some(3), line: 9 };
let values = vec![
AiaValue { key: Some(1), kind: Some("unit real".into()), value: None },
AiaValue { key: None, kind: Some("ustring".into()), value: Some(AiaData::Text(b"a } brace".to_vec())) },
AiaValue { key: None, kind: Some("raw".into()), value: None },
];
let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, values, declared: Some(3), line: 9 };
assert_eq!(step(0, 0), join);
assert_eq!((step(1, 0).name.as_str(), step(1, 0).label.clone(), step(1, 0).on), ("sample)one", None, Some(0)));
assert_eq!((step(1, 1).label.as_deref(), step(1, 1).on, step(1, 1).line), (Some("Second Step"), None, 39));
@@ -60,6 +65,55 @@ fn recorded_values_are_counted_and_checked() {
assert!(set(&[value("one"), "/value [ 1 zz ]".to_string()]).truncated, "a second value that isn't hexadecimal");
}
/// `actionSet.read` answers with each recorded value's key, type and value: a number, text in
/// parentheses or hexadecimal text that is UTF-8 with its length in bytes, and null for text that
/// isn't UTF-8 and for other values. Fields may come in any order, and a value with the wrong byte
/// count still marks the set truncated.
#[test]
fn recorded_values_come_back_with_their_keys_and_types() {
let mut s = Session::new();
let text = format!(
"/version 3 /action-1 {{ /event-1 {{ /internalName (sample_values) /parameterCount 6 \
/parameter-1 {{ /key 1952807028 /type (ustring) /value {note} }} \
/parameter-2 {{ /value 1 /type (boolean) /key 2003792484 }} \
/parameter-3 {{ /type (ustring) /value [ 2 c328 ] /key 7 }} \
/parameter-4 {{ /key 99999999999999999999 /type (real) /value 4.5 }} \
/parameter-5 {{ /key 3 /type (text) /value (plain) }} \
/parameter-6 {{ }} }} }}",
note = hex("Größe ✓")
);
let r = s.execute("actionSet.read", &json!({ "data": text })).unwrap();
assert_eq!(r["truncated"], false);
assert_eq!(
r["actions"][0]["steps"][0]["values"],
json!([
{"key": 1952807028, "type": "ustring", "value": "Größe ✓", "bytes": 11},
{"key": 2003792484, "type": "boolean", "value": 1},
{"key": 7, "type": "ustring", "value": null, "bytes": 2},
{"key": null, "type": "real", "value": null},
{"key": 3, "type": "text", "value": "plain", "bytes": 5},
{"key": null, "type": null, "value": null},
])
);
let bad = "/version 3 /action-1 { /event-1 { /parameter-1 { /key 1 /type (ustring) /value [ 3 4142 ] } } }";
assert_eq!(s.execute("actionSet.read", &json!({ "data": bad })).unwrap()["truncated"], true);
}
/// At most 100,000 recorded values are kept in a set: the others are still counted, so a step's
/// `values` can be shorter than its `params`.
#[test]
fn recorded_values_past_the_cap_are_counted_but_not_kept() {
let step = format!("/event-1 {{ {} }} ", "/parameter-1 { /key 1 /value 0 } ".repeat(1000));
let action = format!("/action-1 {{ {} }} ", step.repeat(50));
let set = read_aia(format!("/version 3 {}", action.repeat(3)).as_bytes()).unwrap();
assert!(!set.truncated);
let steps = || set.actions.iter().flat_map(|a| &a.steps);
assert_eq!(steps().map(|s| s.params).sum::<usize>(), 150_000);
assert_eq!(steps().map(|s| s.values.len()).sum::<usize>(), 100_000);
assert_eq!(set.actions[2].steps[0].values.len(), 0);
assert_eq!(set.actions[1].steps[49].values[999], AiaValue { key: Some(1), kind: None, value: Some(AiaData::Int(0)) });
}
/// `actionSet.read` takes exactly one of `path`, `data` and `dataBase64`, and text for the text
/// params; any other value is an error.
#[test]
@@ -97,7 +151,7 @@ fn read_command_answers_with_the_set() {
let text = sample().replacen(&format!("/name {}", hex("Prep Sätze")), "", 1);
let r = s.execute("actionSet.read", &json!({ "data": text, "name": "/sets/Mine.aia" })).unwrap();
assert_eq!(r["set"], "Mine");
assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "line": 34}));
assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "values": [], "line": 34}));
assert_eq!(r["actions"][0]["steps"][0]["on"], true);
assert_eq!(r["actions"][0]["steps"][0]["label"], "Join");
let blank = format!("/version 3 /action-1 {{ /event-1 {{ /internalName (x) /localizedName {} }} }}", hex(" "));
+6
View File
@@ -1672,6 +1672,9 @@ fn action_set(bytes: &[u8]) -> Result<(), TestCaseError> {
prop_assert!(actions.iter().all(|a| a["steps"].as_array().map_or(0, Vec::len) <= 10_000));
prop_assert!(actions.iter().map(|a| a["steps"].as_array().map_or(0, Vec::len)).sum::<usize>() <= 100_000);
prop_assert!(r["unread"].as_array().map_or(0, Vec::len) <= 1000);
let steps = || actions.iter().flat_map(|a| a["steps"].as_array().cloned().unwrap_or_default());
prop_assert!(steps().all(|s| s["values"].as_array().map_or(0, Vec::len) as u64 <= s["params"].as_u64().unwrap_or(0)));
prop_assert!(steps().map(|s| s["values"].as_array().map_or(0, Vec::len)).sum::<usize>() <= 100_000);
}
Ok(())
}
@@ -1690,6 +1693,8 @@ fn arb_aia_token() -> impl Strategy<Value = Vec<u8>> {
"/localizedName",
"/isOn",
"/value",
"/key",
"/type",
"/x",
"/version",
])
@@ -1699,6 +1704,7 @@ fn arb_aia_token() -> impl Strategy<Value = Vec<u8>> {
"[a-z ()\\\\]{0,12}".prop_map(|t| format!("({t})").into_bytes()),
prop::sample::select(vec!["(", ")", "\\", "[ 2 4142 ]", "[ 3 41 4", "[ 1 zz ]", "[", "]", "< 2 0a0b >", "<", ">", "2.5"])
.prop_map(|t| t.as_bytes().to_vec()),
prop::sample::select(vec!["[ 2 c328 ]", "(ustring)", "(boolean)"]).prop_map(|t| t.as_bytes().to_vec()),
prop::sample::select(vec!["{", "}", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()),
prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]),
]
+1 -1
View File
@@ -1615,7 +1615,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.actionSet.load",
"Load Actions…",
"",
"{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"",
"{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file. A Set Selection step (found by its internal name, whatever its label) comes in as select.byNote {note, caseSensitive, wholeWord} when it has exactly three recorded values: text (ustring, UTF-8; 1 to 1,024 bytes once trimmed), word and case (boolean, 0 or 1); with any other values it is left out. The other steps, every other step with no label and every other step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"",
),
(
"ui.actionSet.delete",
+1 -1
View File
@@ -1616,7 +1616,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
crate::panels::actions::LOAD_SET,
"Load Actions…",
"",
"{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"",
"{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file. A Set Selection step (found by its internal name, whatever its label) comes in as select.byNote {note, caseSensitive, wholeWord} when it has exactly three recorded values: text (ustring, UTF-8; 1 to 1,024 bytes once trimmed), word and case (boolean, 0 or 1); with any other values it is left out. The other steps, every other step with no label and every other step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"",
),
(
crate::panels::actions::DELETE_SETS,
+119
View File
@@ -4237,6 +4237,125 @@ mod tests {
assert_eq!(r["skipped"], json!([{"action": "A", "step": "group"}, {"action": "A", "step": "Ungroup"}]));
assert_eq!(r["leftOut"], 2);
}
/// A Set Selection step written from scratch, as `/event-N` text: its label (none when empty),
/// whether it is on, and its recorded values, each the text inside a `/parameter-N` block.
fn set_selection_event(n: usize, label: &str, on: bool, values: &[String]) -> String {
let hex = |t: &[u8]| format!("[ {} {} ]", t.len(), t.iter().map(|b| format!("{b:02x}")).collect::<String>());
let label = if label.is_empty() { String::new() } else { format!("/localizedName {} ", hex(label.as_bytes())) };
let params: String = values.iter().enumerate().map(|(i, v)| format!("/parameter-{} {{ {v} }} ", i + 1)).collect();
format!(
"/event-{n} {{ /useRulersIn1stQuadrant 0 /internalName (adobe_setSelection) {label}/isOn {} /isOff 0 /parameterCount {} {params}}} ",
u8::from(on),
values.len()
)
}
/// The recorded values of a Set Selection step: the note's bytes, then Whole Word and Case
/// Sensitive.
fn set_selection_values(note: &[u8], whole_word: u8, case_sensitive: u8) -> Vec<String> {
let hex = |t: &[u8]| format!("[ {} {} ]", t.len(), t.iter().map(|b| format!("{b:02x}")).collect::<String>());
vec![
format!("/key 1952807028 /showInPalette 4294967295 /type (ustring) /value {}", hex(note)),
format!("/key 2003792484 /showInPalette 4294967295 /type (boolean) /value {whole_word}"),
format!("/key 1667330917 /showInPalette 4294967295 /type (boolean) /value {case_sensitive}"),
]
}
/// Load Actions… reads an `.aia` Set Selection step, found by its internal name whatever its
/// label, as a Select Object step with its note (trimmed), Case Sensitive and Whole Word, in any
/// order; a step that is off stays excluded. The set saves to VectorCraft's own file and loads
/// back the same, and playing it selects the objects with that note.
#[test]
fn load_actions_reads_set_selection_steps_as_select_object() {
let mut reordered = set_selection_values("Größe ✓".as_bytes(), 1, 0);
reordered.reverse();
let events = [
set_selection_event(1, "Set Selection", true, &set_selection_values(b" Badge front ", 0, 1)),
set_selection_event(2, "Auswahl festlegen", true, &reordered),
set_selection_event(3, "", false, &set_selection_values(b"Off", 1, 1)),
]
.concat();
let text = format!("/version 3 /name [ 5 4e6f746573 ] /actionCount 1 /action-1 {{ /name [ 1 41 ] /eventCount 3 {events}}}");
let mut app = app_with_sets();
let r = app.run(LOAD_SET, json!({ "data": text })).unwrap();
assert_eq!((r["leftOut"].clone(), r["skipped"].clone()), (json!(0), json!([])));
let step = |note: &str, case_sensitive: bool, whole_word: bool| {
(SELECT_OBJECT_STEP.to_string(), json!({"note": note, "caseSensitive": case_sensitive, "wholeWord": whole_word}))
};
let expected = [step("Badge front", true, false), step("Größe ✓", false, true), step("Off", true, true)];
assert_eq!(app.ui.action_sets[3].actions[0].steps, expected);
assert_eq!(app.ui.action_sets[3].actions[0].excluded_steps, [2].into());
let saved = files::write_set(&app.ui.action_sets[3]);
assert!(saved.contains("\"version\": 4"), "{saved}");
app.run(LOAD_SET, json!({ "data": saved })).unwrap();
assert_eq!(app.ui.action_sets[4].actions[0].steps, expected);
assert_eq!(app.ui.action_sets[4].actions[0].excluded_steps, [2].into());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let mut ids = vec![];
for x in [0, 50] {
ids.push(app.run("shape.rectangle", json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
}
app.run("attributes.set", json!({"ids": [ids[1]], "note": "Badge front"})).unwrap();
app.run("select.all", json!({})).unwrap();
app.ui.action_sets[3].actions[0].excluded_steps = [1, 2].into();
play(&mut app, 3, 0).unwrap();
let selected: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect();
assert_eq!(selected, [ids[1]], "the step selected the object with the note");
}
/// A Set Selection step whose recorded values are not exactly the note (UTF-8 text, not empty
/// once trimmed, at most 1,024 bytes) and the two options (each 0 or 1), with the right types and
/// no other value, is left out, listed by its label (else its internal name) and counted; no step
/// comes in with a missing option filled in. A value whose byte count is wrong refuses the file.
#[test]
fn set_selection_steps_with_bad_values_are_left_out() {
let good = || set_selection_values(b"Badge", 0, 0);
let with = |i: usize, value: &str| {
let mut v = good();
v[i] = value.to_string();
v
};
let mut extra = good();
extra.push("/key 1 /type (boolean) /value 0".into());
let mut duplicate = good();
duplicate[0] = good()[2].clone();
let bad: Vec<Vec<String>> = vec![
good()[1..].to_vec(),
vec![good()[0].clone(), good()[1].clone()],
extra,
duplicate,
with(0, "/key 1952807028 /type (ustring) /value [ 2 c328 ]"),
with(0, "/key 1952807028 /type (ustring) /value [ 3 202020 ]"),
with(0, &format!("/key 1952807028 /type (ustring) /value [ 1025 {} ]", "61".repeat(1025))),
with(0, "/key 1952807028 /type (text) /value [ 5 4261646765 ]"),
with(0, "/key 1952807028 /type (ustring) /value 5"),
with(1, "/key 2003792484 /type (boolean) /value 2"),
with(1, "/key 2003792484 /type (integer) /value 1"),
with(2, "/key 1667330917 /type (boolean) /value 1.0"),
with(2, "/key 1667330917 /type (boolean)"),
with(2, "/key 1 /type (boolean) /value 1"),
];
let events: String = bad.iter().enumerate().map(|(i, values)| set_selection_event(i + 1, "Set Selection", true, values)).collect();
let events = events + &set_selection_event(bad.len() + 1, "", true, &[]);
let text = format!("/version 3 /name [ 3 426164 ] /action-1 {{ /name [ 1 41 ] {events}}}");
let mut app = app_with_sets();
let r = app.run(LOAD_SET, json!({ "data": text })).unwrap();
assert!(app.ui.action_sets[3].actions[0].steps.is_empty(), "{:?}", app.ui.action_sets[3].actions[0].steps);
assert_eq!(r["leftOut"], bad.len() + 1);
let names: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|s| s["step"].as_str().unwrap()).collect();
let mut expected = vec!["Set Selection"; bad.len()];
expected.push("adobe_setSelection");
assert_eq!(names, expected);
assert_eq!(app.ui.status, format!("Loaded “Bad”: {} steps were left out", bad.len() + 1));
let wrong_count = set_selection_event(1, "Set Selection", true, &with(0, "/key 1952807028 /type (ustring) /value [ 6 4261646765 ]"));
let text = format!("/version 3 /action-1 {{ {wrong_count}}}");
assert_eq!(app.run(LOAD_SET, json!({ "data": text })).unwrap_err(), "the action set file can't be read in full");
assert_eq!(app.ui.action_sets.len(), 4, "no set added");
}
/// Load Actions… picks action set files of both kinds, and a set another app saved without a
/// name takes the file's name, or `Actions` when there is no file; one named like a set in the
/// panel gets the next free number. A file that isn't UTF-8 still reads. A set past the reader's
+52 -6
View File
@@ -15,7 +15,7 @@
//! version 6 so older builds refuse them rather than fail at that step. Other sets use version 1, and
//! earlier version 1 to 5 files still work.
//! Load Actions… also reads the action sets another app saves as text (`.aia`, through
//! `actionSet.read`).
//! `actionSet.read`), a Set Selection step as a Select Object step.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -398,6 +398,45 @@ fn step_commands() -> std::collections::HashMap<String, Option<&'static str>> {
by_label
}
/// The `/internalName` of a step of another app's action set that selects objects by their note: the
/// Set Selection step.
const SET_SELECTION: &str = "adobe_setSelection"; // brand-ok: the step's internal name in the file format
/// The `/key` of a Set Selection step's note (`text`), of its Whole Word option (`word`) and of its
/// Case Sensitive option (`case`): four characters read as a big-endian number.
const NOTE_KEY: i64 = 0x7465_7874;
const WHOLE_WORD_KEY: i64 = 0x776f_7264;
const CASE_SENSITIVE_KEY: i64 = 0x6361_7365;
/// The Select Object step ([`super::SELECT_OBJECT_STEP`]) a Set Selection step of `actionSet.read`'s
/// answer stands for. The step must have exactly three recorded values, all kept: the note, of type
/// `ustring` and UTF-8, and Whole Word and Case Sensitive, each of type `boolean` and 0 or 1. The note
/// is as [`super::select_object_params`] takes it (trimmed, not empty, at most 1,024 bytes). Anything
/// else stands for nothing.
fn select_object_step(step: &Value) -> Option<(String, Value)> {
let values = step.get("values").and_then(Value::as_array)?;
if values.len() != 3 || step.get("params").and_then(Value::as_u64) != Some(3) {
return None;
}
let value = |key: i64, kind: &str| {
let mut found = values.iter().filter(|v| v.get("key").and_then(Value::as_i64) == Some(key));
let v = found.next().filter(|v| v.get("type").and_then(Value::as_str) == Some(kind))?;
if found.next().is_some() {
return None;
}
v.get("value")
};
let flag = |key: i64| match value(key, "boolean")?.as_i64()? {
0 => Some(false),
1 => Some(true),
_ => None,
};
let note = value(NOTE_KEY, "ustring")?.as_str()?;
let params = json!({"note": note, "caseSensitive": flag(CASE_SENSITIVE_KEY)?, "wholeWord": flag(WHOLE_WORD_KEY)?});
let (note, case_sensitive, whole_word) = super::select_object_params(&params).ok()?;
Some((super::SELECT_OBJECT_STEP.to_string(), json!({"note": note, "caseSensitive": case_sensitive, "wholeWord": whole_word})))
}
/// The command a step of another app's action set stands for ([`step_commands`]).
#[cfg(test)]
pub(super) fn step_command(label: &str) -> Option<&'static str> {
@@ -414,8 +453,9 @@ struct AiaLoad {
}
/// The set of `read`, `actionSet.read`'s answer, named `name` when the file gives none. A step comes
/// in as the command it stands for ([`step_name`], [`step_commands`]), with no params, and excluded
/// from playback when it is off in the file. An action with no name is called `Action N`.
/// in as the command it stands for ([`step_name`], [`step_commands`]), with no params, or a Set
/// Selection step as a Select Object step ([`select_object_step`]), and excluded from playback when
/// it is off in the file. An action with no name is called `Action N`.
fn aia_set(read: &Value, name: &str) -> AiaLoad {
let (mut skipped, mut left_out) = (vec![], 0usize);
let commands = step_commands();
@@ -432,7 +472,12 @@ fn aia_set(read: &Value, name: &str) -> AiaLoad {
let (mut steps, mut excluded_steps) = (vec![], std::collections::BTreeSet::new());
for s in a.get("steps").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() {
let (name, can_stand) = step_name(s);
let Some(id) = commands.get(&bare_label(&name)).copied().flatten().filter(|_| can_stand) else {
let step = if s.get("name").and_then(Value::as_str) == Some(SET_SELECTION) {
select_object_step(s)
} else {
commands.get(&bare_label(&name)).copied().flatten().filter(|_| can_stand).map(|id| (id.to_string(), json!({})))
};
let Some(step) = step else {
left_out += 1;
if skipped.len() < MAX_SKIPPED {
skipped.push(json!({ "action": action, "step": name }));
@@ -442,7 +487,7 @@ fn aia_set(read: &Value, name: &str) -> AiaLoad {
if s.get("on").and_then(Value::as_bool) == Some(false) {
excluded_steps.insert(steps.len());
}
steps.push((id.to_string(), json!({})));
steps.push(step);
}
Action { excluded_steps, ..Action::new(action, steps) }
})
@@ -496,7 +541,8 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
///
/// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its
/// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]),
/// with no params; a step that is off in the file is excluded from playback. A set with no name is
/// with no params, and its Set Selection steps as Select Object steps ([`select_object_step`]); a
/// step that is off in the file is excluded from playback. A set with no name is
/// called after the file, else `Actions`. The other steps are left out and listed, and the status
/// bar says how many → `{set, name, actions, skipped: [{action, step}] (at most 1,000), leftOut: n}`.
/// Such a file that `actionSet.read` marks `truncated` is refused, and so is one whose set Save
+11 -3
View File
@@ -11481,7 +11481,11 @@ that starts with `/version`) without changing anything. Give exactly one of `pat
the set's name (`set`, else the file's name, else empty), its `version`, and its `actions`, each with its `name` and
`steps`. A step lists its `name` (the file's `/internalName`), its `label` (the `/localizedName` it is shown with, or
null when it has none or it is blank), `on` (false when the file sets `/isOn 0`), `params` (how many recorded values it
has) and its `line`. `unread` lists the tokens the reader skipped, with their lines. The reader stops at 8 MiB, 16 open
has), `values` and its `line`. `values` lists the step's recorded values in order, each as `{key, type, value, bytes?}`:
the block's `/key` and `/type` (null when it has none), and its `/value` when that is a whole number, or text in
parentheses or hexadecimal text that is UTF-8, with `bytes` giving the text's length in bytes; any other value, and text
that is not UTF-8, is null. Only the first 100,000 recorded values in the set are listed, so a step's `values` can be
shorter than its `params`. `unread` lists the tokens the reader skipped, with their lines. The reader stops at 8 MiB, 16 open
blocks, 1,000 actions, 10,000 steps in an action and 100,000 steps in the set, and cuts names at 1,024 bytes. It sets
`truncated` when it reaches one of these limits, when the text ends inside a block or a value or after a field's name,
when `/actionCount`, `/eventCount` or a step's `/parameterCount` differs from the actions, steps or recorded values read
@@ -11496,8 +11500,12 @@ label, in English or in the interface language, in any case and without a final
that label and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with
no values and opens no dialog (Group, Ungroup). A submenu's items, file and document commands, Paste, Undo, Redo and
add-on commands are never matched. The step runs with no params, and a step that is off in the file is excluded from
playback. Other steps are left out, including every step with no label and every step recorded with values (recorded
menu items too). The reply lists the first 1,000 in `skipped` (`{action, step}`, the step by its label, else its name),
playback. A Set Selection step, found by its `/internalName` whatever its label, comes in as a Select Object step
(`select.byNote` with `{note, caseSensitive, wholeWord}`) when it has exactly three recorded values: the note (key
`text`, 1952807028, of type `ustring`, UTF-8), Whole Word (key `word`, 2003792484) and Case Sensitive (key `case`,
1667330917), each of type `boolean` and 0 or 1. The note loses its leading and trailing spaces and must then be 1 to
1,024 bytes long. A Set Selection step with any other values is left out; no option is filled in. Other steps are left
out, including every other step with no label and every other step recorded with values (recorded menu items too). The reply lists the first 1,000 in `skipped` (`{action, step}`, the step by its label, else its name),
`leftOut` counts them, and the status bar says how many were left out. An action with no name is called `Action N`, by
its place in the file. Function keys and colors are not read. A file marked `truncated` is refused, and no set is added.
Text that starts `/version` and is not an action set gives "not an action set file". Tokens in `unread` do not stop a
+1
View File
@@ -806,6 +806,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Actions panel menu: Load Actions… keeps a step whose command this build or its installed plug-ins do not have, and reports each one when the set loads: the `ui.actionSet.load` answer lists the action, step and command id (`unknownCommands`, `unknownCount`), and the status bar names the first and the count. An action with such a step included does not play, and the error names the step and its id before any step runs; an excluded one does not stop it. These are VectorCraft's own rules. Tests `panels::actions::tests::{load_actions_reports_steps_with_unknown_commands, load_actions_bounds_what_it_reports_of_unknown_commands, an_action_with_an_unknown_command_does_not_play}`.
- Actions panel menu: Save Actions… does not write a set with a step that sets a folder preference (the Fonts, Plug-ins, Templates and Recovery folders and the two scratch disks) to a folder, and names the action and the preference instead; an empty folder and the scratch disk choices Startup and None save. Export Preferences leaves out both scratch disks, and `ui.inspect` under automation read roots leaves such a folder outside them out of recorded steps. These are VectorCraft's own rules (`ui.actionSet.save`, `prefs.export`).
- Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many.
- Actions panel menu: Load Actions… reads an `.aia` file's Set Selection step, which selects objects by the note typed in its dialog ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html)), as a Select Object step with its note, Case Sensitive and Whole Word (`ui.actionSet.load`; `actionSet.read` now lists each step's recorded values with their keys and types). VectorCraft's own rules: the step is found by its internal name whatever its label, and comes in only when it has exactly the note (UTF-8, 1 to 1,024 bytes once trimmed) and the two options (each 0 or 1); any other Set Selection step is left out and counted, with no option filled in. Tests `panels::actions::tests::{load_actions_reads_set_selection_steps_as_select_object, set_selection_steps_with_bad_values_are_left_out}`, `tests::actionset::{recorded_values_come_back_with_their_keys_and_types, recorded_values_past_the_cap_are_counted_but_not_kept}`.
- Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color.
- Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is the color of the action's button in Button Mode.
- Actions panel: the panel menu's Button Mode shows the actions as buttons with their names in their Action Options colors, without sets or individual commands; a click on a button plays its action without its excluded steps, and choosing Button Mode again returns to the list ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=467) and [the next page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=468); [the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) says commands cannot be excluded in Button Mode; `ui.action.buttonMode`, `ui.action.playAt`). VectorCraft's own rules, which the pages do not cover: the menu item shows a check mark while Button Mode is on; every set's actions show, in set order and then action order; an action without a color is an ordinary button; function keys still play their actions; the bottom bar shows only Stop Playing/Recording while an action plays or records, and Play Current Selection while one waits at a stop; Button Mode cannot be turned on while an action records; and the mode is kept with the UI preferences.
+1 -1
View File
@@ -415,7 +415,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Image Trace panel: the Preview checkbox before Trace chooses whether a setting change traces the selected Image Trace object again at once | done | Per the public documentation, Preview shows the result of the current settings and Trace traces with them (<https://web.archive.org/web/20250831090623/https://helpx.adobe.com/illustrator/using/image-trace.html>). Preview is on by default and kept with the UI preferences; Reset to Default leaves it. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected, and come back when it is selected again. `imageTrace.preview {on?}`, `ui.set {imageTracePreview}`; `panels/image_trace.rs` headless tests (`with_preview_off_a_slider_change_waits_for_trace`, `turning_preview_on_traces_the_changes_that_waited`, `preview_turned_on_with_nothing_selected_traces_when_the_object_is_selected_again`, `settings_that_wait_stay_with_their_object`, `settings_that_wait_come_back_when_their_object_is_selected_again`, `settings_that_wait_are_dropped_when_their_object_changed_since`, `preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it`) |
| Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls |
| Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; Reset, Clear and Replace Actions remain |
| Actions panel: ≡ › Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `actionSet.read`, `ui.actionSet.load`; `tests/actionset.rs`, `panels/actions.rs` tests, `import_fuzz`. Only steps with a label and no recorded values come in, as menu commands that run with no values and open no dialog, matched by label; the other steps, recorded menu items included, are left out and counted |
| Actions panel: ≡ › Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `actionSet.read`, `ui.actionSet.load`; `tests/actionset.rs`, `panels/actions.rs` tests, `import_fuzz`. Steps with a label and no recorded values come in as menu commands that run with no values and open no dialog, matched by label, and Set Selection steps come in as Select Object steps; the other steps, recorded menu items included, are left out and counted |
| Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). Button Mode shows the color |
| Actions panel: ≡ › Button Mode shows each action by name as a button in its Action Options color, without sets or steps; a click on a button plays the action, and choosing Button Mode again shows the list ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | done | `ui.action.buttonMode`, `ui.action.playAt`; `panels/actions.rs` tests. VectorCraft's own rules: every set's actions show in set order, an action without a color is an ordinary button, the mode is kept with the UI preferences, and it cannot be turned on while an action records |
| Actions panel: ≡ › Playback Options… plays actions Accelerated, Step by Step or with a pause between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html)) | done | `ui.action.playbackOptions`; `panels/actions.rs` tests. The pause is a whole number of seconds from 1 to 60 (VectorCraft's own range); Batch… plays at full speed |