mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
Merge pull request #6149 from dotCooCoo/feat/load-aia-actions-2
Actions: Load Actions… reads action sets saved as .aia files
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
//! The command registry. Ids follow Illustrator's menu structure.
|
||||
|
||||
pub mod actionset;
|
||||
mod addons;
|
||||
pub(crate) mod appearance;
|
||||
mod assets;
|
||||
@@ -344,6 +345,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(halftone::specs());
|
||||
v.extend(perspgrid::specs());
|
||||
v.extend(shortcutset::specs());
|
||||
v.extend(actionset::specs());
|
||||
v.extend(importmatches::specs());
|
||||
v.extend(addons::specs());
|
||||
v
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
//! Action sets another app saves as text (`.aia` files that start `/version`), read into their
|
||||
//! actions and steps without changing anything (`actionSet.read`).
|
||||
//!
|
||||
//! The text is a set of fields and blocks. A slash name followed by a value sets a field, and a
|
||||
//! slash name followed by `{` opens a block that `}` closes. A value is a decimal number, text in
|
||||
//! parentheses, a byte count and hexadecimal bytes in square brackets (`[ 5 48656c6c6f ]`, UTF-8
|
||||
//! text, which can run over several words), or a byte count and hexadecimal bytes in angle brackets
|
||||
//! (`< 3 0a0b0c >`, raw bytes). The set's `/version` comes first, and its `/name` is hexadecimal
|
||||
//! 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`
|
||||
//! 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
|
||||
//! "Made a two-way aia to json converter"
|
||||
//! (https://community.adobe.com/t5/illustrator-discussions/made-a-two-way-aia-to-json-converter/m-p/13751909),
|
||||
//! sttk3's article on names as UTF-8 hexadecimal
|
||||
//! (https://sttk3.com/blog/tips/illustrator/dynamic-generate-action.html), and the MIT-licensed
|
||||
//! reader in zachshallbetter/adobe-mcp (https://github.com/zachshallbetter/adobe-mcp). The raw form
|
||||
//! follows the forum thread "Creating a dynamic action"
|
||||
//! (https://community.adobe.com/questions-652/creating-a-dynamic-action-to-use-with-app-doscript-method-814406).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, text_param};
|
||||
|
||||
/// The most bytes of a file read.
|
||||
const MAX_BYTES: usize = 8 << 20;
|
||||
/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text
|
||||
/// goes on.
|
||||
const READ_LIMIT: usize = MAX_BYTES + 4;
|
||||
/// The most blocks open at once.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// The most actions read.
|
||||
const MAX_ACTIONS: usize = 1000;
|
||||
/// The most steps read in one action.
|
||||
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 longest name kept (bytes).
|
||||
const MAX_NAME: usize = 1024;
|
||||
/// The most tokens listed as unread.
|
||||
const MAX_UNREAD: usize = 1000;
|
||||
/// The longest unread token shown (characters).
|
||||
const UNREAD_SHOWN: usize = 80;
|
||||
|
||||
/// The extensions of action sets other apps save.
|
||||
pub const EXTS: &[&str] = &["aia"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "actionSet.read",
|
||||
"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",
|
||||
always,
|
||||
read
|
||||
)]
|
||||
}
|
||||
|
||||
/// A step of an action: an `/event-N` block.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AiaStep {
|
||||
/// Its `/internalName`.
|
||||
pub name: String,
|
||||
/// Its `/localizedName`, if it has one.
|
||||
pub label: Option<String>,
|
||||
/// Its `/isOn`, if it has one.
|
||||
pub on: Option<i64>,
|
||||
/// How many `/parameter-N` blocks it has.
|
||||
pub params: usize,
|
||||
/// Its `/parameterCount`, if it has one.
|
||||
pub declared: Option<i64>,
|
||||
/// The line its block opens on (from 1).
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
/// An action: an `/action-N` block.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AiaAction {
|
||||
pub name: String,
|
||||
pub steps: Vec<AiaStep>,
|
||||
/// Its `/eventCount`, if it has one.
|
||||
pub declared: Option<i64>,
|
||||
}
|
||||
|
||||
/// An action set read from text.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AiaSet {
|
||||
pub version: i64,
|
||||
/// The set's `/name`, if it has one.
|
||||
pub name: Option<String>,
|
||||
pub actions: Vec<AiaAction>,
|
||||
/// Its `/actionCount`, if it has one.
|
||||
pub declared: Option<i64>,
|
||||
/// The tokens that weren't read, with their lines.
|
||||
pub unread: Vec<(usize, String)>,
|
||||
/// A cap stopped the reading or cut a name, the text ends inside a block or a value or after a
|
||||
/// field's name, its counts differ from the actions, steps and recorded values read (a negative
|
||||
/// count, one too large for an `i64` and one that isn't a number always differ), or a hexadecimal
|
||||
/// name, label or recorded `/value` isn't as many bytes as its count says.
|
||||
pub truncated: bool,
|
||||
/// How many steps were read in all the actions.
|
||||
steps: usize,
|
||||
}
|
||||
|
||||
/// A token of the text.
|
||||
enum Token {
|
||||
/// A slash name.
|
||||
Name(Vec<u8>),
|
||||
/// An integer; `None` when it doesn't fit an `i64`.
|
||||
Int(Option<i64>),
|
||||
/// Text in parentheses, a backslash taking the next byte as it is.
|
||||
Text(Vec<u8>),
|
||||
/// The bytes of `[ count hex… ]`; `None` when the hexadecimal digits don't make whole bytes or
|
||||
/// as many bytes as the count says.
|
||||
Hex(Option<Vec<u8>>),
|
||||
/// `< count hex… >`.
|
||||
Raw,
|
||||
Open,
|
||||
Close,
|
||||
/// Anything else, as written.
|
||||
Other(Vec<u8>),
|
||||
}
|
||||
|
||||
/// The tokens of `text`, each with its line.
|
||||
struct Tokens<'a> {
|
||||
text: &'a [u8],
|
||||
at: usize,
|
||||
line: usize,
|
||||
/// The text ended inside a value in parentheses, square brackets or angle brackets.
|
||||
unclosed: bool,
|
||||
}
|
||||
|
||||
impl<'a> Tokens<'a> {
|
||||
/// Past one byte, counting a line at LF, at CR alone, and once for CRLF.
|
||||
fn step(&mut self) -> Option<u8> {
|
||||
let b = *self.text.get(self.at)?;
|
||||
if b == b'\n' || b == b'\r' && self.text.get(self.at + 1) != Some(&b'\n') {
|
||||
self.line += 1;
|
||||
}
|
||||
self.at += 1;
|
||||
Some(b)
|
||||
}
|
||||
|
||||
/// The bytes up to `end` (not taken in); `None` when the text ends first.
|
||||
fn until(&mut self, end: u8) -> Option<&'a [u8]> {
|
||||
let (text, start) = (self.text, self.at);
|
||||
while text.get(self.at).is_some_and(|&b| b != end) {
|
||||
self.step();
|
||||
}
|
||||
let inside = text.get(start..self.at)?;
|
||||
text.get(self.at)?;
|
||||
self.at += 1;
|
||||
Some(inside)
|
||||
}
|
||||
|
||||
fn next_token(&mut self) -> Option<(usize, Token)> {
|
||||
while self.text.get(self.at).is_some_and(|&b| b.is_ascii_whitespace() || b == 0) {
|
||||
self.step();
|
||||
}
|
||||
let line = self.line;
|
||||
let b = *self.text.get(self.at)?;
|
||||
let ends = |b: u8| b.is_ascii_whitespace() || b == 0 || b"{}[]()<>/".contains(&b);
|
||||
let token = match b {
|
||||
b'{' | b'}' => {
|
||||
self.at += 1;
|
||||
if b == b'{' { Token::Open } else { Token::Close }
|
||||
}
|
||||
b'(' => {
|
||||
self.at += 1;
|
||||
let (mut text, mut depth) = (vec![], 0usize);
|
||||
self.unclosed = true;
|
||||
while let Some(c) = self.step() {
|
||||
match c {
|
||||
b'\\' => text.extend(self.step()),
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
text.push(c);
|
||||
}
|
||||
b')' if depth == 0 => {
|
||||
self.unclosed = false;
|
||||
break;
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
text.push(c);
|
||||
}
|
||||
_ => text.push(c),
|
||||
}
|
||||
}
|
||||
Token::Text(text)
|
||||
}
|
||||
b'[' | b'<' => {
|
||||
self.at += 1;
|
||||
let close = if b == b'[' { b']' } else { b'>' };
|
||||
let inside = self.until(close);
|
||||
self.unclosed = inside.is_none();
|
||||
let inside = inside.unwrap_or_default().to_vec();
|
||||
if b == b'<' {
|
||||
Token::Raw
|
||||
} else {
|
||||
// The first word is the byte count; the bytes follow in one or more words.
|
||||
let mut words = inside.split(|c| c.is_ascii_whitespace()).filter(|w| !w.is_empty());
|
||||
let count = words.next().and_then(|w| std::str::from_utf8(w).ok()).and_then(|w| w.parse::<usize>().ok());
|
||||
let digits: Vec<u8> = words.flatten().copied().collect();
|
||||
Token::Hex(hex_bytes(&digits).filter(|b| Some(b.len()) == count))
|
||||
}
|
||||
}
|
||||
b'/' => {
|
||||
self.at += 1;
|
||||
let start = self.at;
|
||||
while self.text.get(self.at).is_some_and(|&c| !ends(c)) {
|
||||
self.at += 1;
|
||||
}
|
||||
Token::Name(self.text.get(start..self.at).unwrap_or_default().to_vec())
|
||||
}
|
||||
_ => {
|
||||
let start = self.at;
|
||||
while self.text.get(self.at).is_some_and(|&c| !ends(c)) {
|
||||
self.at += 1;
|
||||
}
|
||||
// Always past at least one byte.
|
||||
self.at = self.at.max(start + 1);
|
||||
let word = self.text.get(start..self.at).unwrap_or_default();
|
||||
let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word);
|
||||
if !number.is_empty() && number.iter().all(u8::is_ascii_digit) {
|
||||
Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok()))
|
||||
} else {
|
||||
Token::Other(word.to_vec())
|
||||
}
|
||||
}
|
||||
};
|
||||
Some((line, token))
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes hexadecimal `digits` stand for, two digits a byte.
|
||||
fn hex_bytes(digits: &[u8]) -> Option<Vec<u8>> {
|
||||
let nibble = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok());
|
||||
if !digits.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
digits.chunks(2).map(|pair| Some((nibble(*pair.first()?)? << 4) | nibble(*pair.get(1)?)?)).collect()
|
||||
}
|
||||
|
||||
/// What a block is.
|
||||
enum Kind {
|
||||
Action,
|
||||
Step,
|
||||
Param,
|
||||
/// A block that isn't read, or one past a cap.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// `name` ends with `-` and a number after `prefix`, as `/action-1` does.
|
||||
fn numbered(name: &[u8], prefix: &[u8]) -> bool {
|
||||
name.strip_prefix(prefix).is_some_and(|n| !n.is_empty() && n.iter().all(u8::is_ascii_digit))
|
||||
}
|
||||
|
||||
/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters.
|
||||
fn name_text(name: &[u8], set: &mut AiaSet) -> String {
|
||||
if name.len() > MAX_NAME {
|
||||
set.truncated = true;
|
||||
}
|
||||
let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned();
|
||||
// A character cut at the limit reads as one replacement character.
|
||||
if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') {
|
||||
s.pop();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// How the unread list shows a value in square brackets that isn't as many bytes as its count says.
|
||||
const BAD_HEX: &str = "[ … ] (not the hexadecimal bytes its count says)";
|
||||
|
||||
/// List a token that isn't read.
|
||||
fn unread(set: &mut AiaSet, line: usize, text: impl AsRef<[u8]>) {
|
||||
if set.unread.len() < MAX_UNREAD {
|
||||
let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect();
|
||||
set.unread.push((line, text));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the block `name` that opens on `line` inside the blocks `stack` is: a new action, step or
|
||||
/// recorded value of `set`, or a block that isn't read (listed when it is out of place).
|
||||
fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kind {
|
||||
match stack.last() {
|
||||
_ if stack.len() >= MAX_DEPTH => {
|
||||
set.truncated = true;
|
||||
Kind::Other
|
||||
}
|
||||
None if numbered(name, b"action-") => {
|
||||
if set.actions.len() < MAX_ACTIONS {
|
||||
set.actions.push(AiaAction::default());
|
||||
Kind::Action
|
||||
} else {
|
||||
set.truncated = true;
|
||||
Kind::Other
|
||||
}
|
||||
}
|
||||
Some(Kind::Action) if numbered(name, b"event-") => match set.actions.last_mut() {
|
||||
Some(a) if a.steps.len() < MAX_STEPS && set.steps < MAX_SET_STEPS => {
|
||||
a.steps.push(AiaStep { line, ..AiaStep::default() });
|
||||
set.steps += 1;
|
||||
Kind::Step
|
||||
}
|
||||
_ => {
|
||||
set.truncated = true;
|
||||
Kind::Other
|
||||
}
|
||||
},
|
||||
Some(Kind::Step) if numbered(name, b"parameter-") => {
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.params += 1;
|
||||
}
|
||||
Kind::Param
|
||||
}
|
||||
Some(Kind::Param | Kind::Other) => Kind::Other,
|
||||
_ => {
|
||||
unread(set, line, format!("/{} {{", String::from_utf8_lossy(name)));
|
||||
Kind::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the field `name` of the block `kind` (the set when `None`) to the hexadecimal text `bytes`
|
||||
/// on `line`: the set's or an action's `/name`, or a step's `/localizedName`.
|
||||
fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Option<Vec<u8>>, line: usize) {
|
||||
let Some(bytes) = bytes else {
|
||||
set.truncated = true;
|
||||
unread(set, line, BAD_HEX);
|
||||
return;
|
||||
};
|
||||
match (kind, name) {
|
||||
(None, Some(b"name")) => set.name = Some(name_text(&bytes, set)),
|
||||
(Some(Kind::Action), Some(b"name")) => {
|
||||
let text = name_text(&bytes, set);
|
||||
if let Some(a) = set.actions.last_mut() {
|
||||
a.name = text;
|
||||
}
|
||||
}
|
||||
(Some(Kind::Step), Some(b"localizedName")) => {
|
||||
let text = name_text(&bytes, set);
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.label = Some(text);
|
||||
}
|
||||
}
|
||||
(_, Some(_)) => {}
|
||||
(_, None) => unread(set, line, "[ … ]"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.truncated = true;
|
||||
unread(set, line, BAD_HEX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark `set` truncated when the field `name` of the block `kind` (the set when `None`) is a count
|
||||
/// (the set's `/actionCount`, an action's `/eventCount` or a step's `/parameterCount`) whose value is
|
||||
/// not a number that fits an `i64`.
|
||||
fn bad_count(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>) {
|
||||
if matches!((kind, name), (None, Some(b"actionCount")) | (Some(Kind::Action), Some(b"eventCount")) | (Some(Kind::Step), Some(b"parameterCount")))
|
||||
{
|
||||
set.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the field `name` of the block `kind` (the set when `None`) to the integer `value` on `line`:
|
||||
/// the set's `/actionCount`, an action's `/eventCount`, a step's `/parameterCount`, or a step's
|
||||
/// `/isOn`. A count too large for an `i64` marks the set truncated.
|
||||
fn set_int(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, value: Option<i64>, line: usize) {
|
||||
if value.is_none() {
|
||||
bad_count(set, kind, name);
|
||||
}
|
||||
match (kind, name) {
|
||||
(None, Some(b"actionCount")) => set.declared = value,
|
||||
(Some(Kind::Action), Some(b"eventCount")) => {
|
||||
if let Some(a) = set.actions.last_mut() {
|
||||
a.declared = value;
|
||||
}
|
||||
}
|
||||
(Some(Kind::Step), Some(b"parameterCount")) => {
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.declared = value;
|
||||
}
|
||||
}
|
||||
(Some(Kind::Step), Some(b"isOn")) => {
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.on = value;
|
||||
}
|
||||
}
|
||||
(_, Some(_)) => {}
|
||||
(_, None) => unread(set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an action set saved as text. An error when the text isn't one: it doesn't start with
|
||||
/// `/version` and an integer.
|
||||
pub fn read_aia(bytes: &[u8]) -> std::result::Result<AiaSet, String> {
|
||||
let mut set = AiaSet::default();
|
||||
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
let text = match text.get(..MAX_BYTES) {
|
||||
Some(head) if text.len() > MAX_BYTES => {
|
||||
set.truncated = true;
|
||||
head
|
||||
}
|
||||
_ => text,
|
||||
};
|
||||
let mut tokens = Tokens { text, at: 0, line: 1, unclosed: false };
|
||||
match (tokens.next_token(), tokens.next_token()) {
|
||||
(Some((_, Token::Name(n))), Some((_, Token::Int(Some(v))))) if n == b"version" => set.version = v,
|
||||
_ => return Err("not an action set: the text doesn't start with /version and a number".into()),
|
||||
}
|
||||
let mut stack: Vec<Kind> = vec![];
|
||||
// A slash name waiting for its value or its `{`: (line, name).
|
||||
let mut pending: Option<(usize, Vec<u8>)> = None;
|
||||
while let Some((line, token)) = tokens.next_token() {
|
||||
let in_step = matches!(stack.last(), Some(Kind::Step));
|
||||
let field = |pending: &mut Option<(usize, Vec<u8>)>| pending.take().map(|(_, n)| n);
|
||||
match token {
|
||||
Token::Name(name) => {
|
||||
if let Some((l, n)) = pending.replace((line, name)) {
|
||||
bad_count(&mut set, stack.last(), Some(&n));
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
}
|
||||
Token::Open => {
|
||||
let (l, name) = pending.take().unwrap_or((line, vec![]));
|
||||
bad_count(&mut set, stack.last(), Some(&name));
|
||||
let kind = open_block(&mut set, &stack, &name, l);
|
||||
stack.push(kind);
|
||||
}
|
||||
Token::Close => {
|
||||
if let Some((l, n)) = pending.take() {
|
||||
bad_count(&mut set, stack.last(), Some(&n));
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
if stack.pop().is_none() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// The other values inside a recorded value, or a block that isn't read.
|
||||
_ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => {
|
||||
pending = None;
|
||||
}
|
||||
Token::Hex(bytes) => {
|
||||
let name = field(&mut pending);
|
||||
bad_count(&mut set, stack.last(), name.as_deref());
|
||||
set_hex(&mut set, stack.last(), name.as_deref(), bytes, line);
|
||||
}
|
||||
Token::Text(t) => {
|
||||
let name = field(&mut pending);
|
||||
bad_count(&mut set, stack.last(), name.as_deref());
|
||||
match name {
|
||||
Some(n) if in_step && n == b"internalName" => {
|
||||
let name = name_text(&t, &mut set);
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.name = name;
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => unread(&mut set, line, [b"(".as_slice(), &t, b")"].concat()),
|
||||
}
|
||||
}
|
||||
Token::Int(v) => {
|
||||
let name = field(&mut pending);
|
||||
set_int(&mut set, stack.last(), name.as_deref(), v, line);
|
||||
}
|
||||
Token::Raw => {
|
||||
let name = field(&mut pending);
|
||||
bad_count(&mut set, stack.last(), name.as_deref());
|
||||
if name.is_none() {
|
||||
unread(&mut set, line, "< … >");
|
||||
}
|
||||
}
|
||||
Token::Other(word) => {
|
||||
// A real number, or another word as a field's value.
|
||||
let name = field(&mut pending);
|
||||
bad_count(&mut set, stack.last(), name.as_deref());
|
||||
if name.is_none() {
|
||||
unread(&mut set, line, word);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The text ends after a field's name, before its value.
|
||||
if let Some((l, n)) = pending {
|
||||
set.truncated = true;
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
// An unclosed value is the last token: it takes the rest of the text.
|
||||
if tokens.unclosed {
|
||||
set.truncated = true;
|
||||
unread(&mut set, tokens.line, "a value not closed at the end");
|
||||
}
|
||||
if !stack.is_empty() {
|
||||
set.truncated = true;
|
||||
unread(&mut set, tokens.line, format!("blocks left open at the end: {}", stack.len()));
|
||||
}
|
||||
// Not as many actions, steps or recorded values as the counts give; a negative count never matches.
|
||||
let differs = |declared: Option<i64>, read: usize| declared.is_some_and(|d| !usize::try_from(d).is_ok_and(|d| d == read));
|
||||
let step_differs = |a: &AiaAction| a.steps.iter().any(|s| differs(s.declared, s.params));
|
||||
if differs(set.declared, set.actions.len()) || set.actions.iter().any(|a| differs(a.declared, a.steps.len()) || step_differs(a)) {
|
||||
set.truncated = true;
|
||||
unread(&mut set, tokens.line, "not as many actions, steps or recorded values as the counts give");
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
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)?);
|
||||
let name = text_param(p, "name", C)?;
|
||||
let bytes: std::borrow::Cow<[u8]> = match (path, data, b64) {
|
||||
(Some(path), None, None) => super::fileio::read_file_head(path, READ_LIMIT)?.into(),
|
||||
(None, Some(text), None) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(),
|
||||
(None, None, Some(b64)) => super::scripts::decode_head(b64, READ_LIMIT).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
|
||||
_ => return Err(bad(C, "give exactly one of `path`, `data` and `dataBase64`")),
|
||||
};
|
||||
let set = read_aia(&bytes).map_err(EngineError::Other)?;
|
||||
let file = name.or(path).unwrap_or_default();
|
||||
let stem = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let name = set.name.clone().filter(|n| !n.trim().is_empty()).unwrap_or(stem);
|
||||
let actions: Vec<Value> = set
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let steps: Vec<Value> = a
|
||||
.steps
|
||||
.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 })
|
||||
})
|
||||
.collect();
|
||||
json!({ "name": a.name, "steps": steps })
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"set": name,
|
||||
"version": set.version,
|
||||
"actions": actions,
|
||||
"unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::<Vec<_>>(),
|
||||
"truncated": set.truncated,
|
||||
}))
|
||||
}
|
||||
@@ -62,7 +62,7 @@ impl Loader for Folder {
|
||||
|
||||
/// The bytes the base64 text `b64` holds, up to `max` and a few more: only the text they take is
|
||||
/// decoded.
|
||||
fn decode_head(b64: &str, max: usize) -> Option<Vec<u8>> {
|
||||
pub(super) fn decode_head(b64: &str, max: usize) -> Option<Vec<u8>> {
|
||||
let chars = max.div_ceil(3).saturating_mul(4);
|
||||
let mut kept = 0usize;
|
||||
let end = b64
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::*;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
mod actionset;
|
||||
pub(super) mod adjust;
|
||||
mod ai_open_path;
|
||||
mod appearance;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Action sets another app saved as text (`actionSet.read`): actions, steps, the tokens left unread
|
||||
//! and the caps. Every set here is written for the test, with invented names.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::actionset::{AiaStep, read_aia};
|
||||
use crate::*;
|
||||
|
||||
/// `text` as a bracketed hexadecimal value.
|
||||
fn hex(text: &str) -> String {
|
||||
let digits: String = text.bytes().map(|b| format!("{b:02x}")).collect();
|
||||
format!("[ {} {digits} ]", text.len())
|
||||
}
|
||||
|
||||
/// A set written from scratch in the layout the module describes: two actions, the second with a
|
||||
/// step left out of playback, a step with recorded values of each form, and names in several
|
||||
/// scripts.
|
||||
fn sample() -> String {
|
||||
format!(
|
||||
"/version 3\n/name {set}\n/isOpen 1\n/actionCount 2\n/action-1 {{\n\t/name {a1}\n\t/keyIndex 0\n\t/eventCount 1\n\t/event-1 {{\n\t\t/internalName (sample_join)\n\t\t/localizedName {join}\n\t\t/isOn 1\n\t\t/parameterCount 3\n\t\t/parameter-1 {{\n\t\t\t/key 1\n\t\t\t/type (unit real)\n\t\t\t/value 2.5\n\t\t}}\n\t\t/parameter-2 {{\n\t\t\t/type (ustring)\n\t\t\t/value {txt}\n\t\t}}\n\t\t/parameter-3 {{\n\t\t\t/type (raw)\n\t\t\t/value < 3\n\t\t\t\t0a7d0b\n\t\t\t>\n\t\t\t/size 3\n\t\t}}\n\t}}\n}}\r\n/action-2 {{\r\n\t/name {a2}\r\n\t/event-1 {{\r\n\t\t/internalName (sample\\)one)\r\n\t\t/isOn 0\r\n\t\t/parameterCount 0\r\n\t}}\r\n\t/event-2 {{\r\n\t\t/internalName (sample_two)\r\n\t\t/localizedName {two}\r\n\t}}\r\n}}\r\n",
|
||||
set = hex("Prep Sätze"),
|
||||
a1 = hex("First"),
|
||||
join = hex("Join"),
|
||||
txt = hex("a } brace"),
|
||||
a2 = hex("二番目"),
|
||||
two = hex("Second Step"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The set's name, its actions and their steps come in with the step's names, its on state and how
|
||||
/// many values it was recorded with, and nothing is listed unread.
|
||||
#[test]
|
||||
fn a_set_reads_its_actions_and_steps() {
|
||||
let set = read_aia(sample().as_bytes()).unwrap();
|
||||
assert_eq!((set.version, set.name.as_deref(), set.truncated), (3, Some("Prep Sätze"), false));
|
||||
assert!(set.unread.is_empty(), "{:?}", set.unread);
|
||||
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 };
|
||||
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));
|
||||
}
|
||||
|
||||
/// Every recorded value of a step is counted. A hexadecimal `/value` with the wrong byte count marks
|
||||
/// the set truncated; a long one or one that isn't UTF-8 does not.
|
||||
#[test]
|
||||
fn recorded_values_are_counted_and_checked() {
|
||||
let set = |values: &[String]| {
|
||||
let params: String = values.iter().enumerate().map(|(i, v)| format!("/parameter-{} {{ /key 1 {v} }} ", i + 1)).collect();
|
||||
read_aia(format!("/version 3 /action-1 {{ /event-1 {{ {params} }} }}").as_bytes()).unwrap()
|
||||
};
|
||||
let value = |t: &str| format!("/type (ustring) /value {}", hex(t));
|
||||
let read = set(&[value("one"), value("two"), value("three")]);
|
||||
assert_eq!((read.actions[0].steps[0].params, read.truncated), (3, false));
|
||||
let read = set(&[value(&"n".repeat(2000)), "/value [ 1 ff ]".to_string(), "/type (real) /value 4.5".to_string()]);
|
||||
assert!(!read.truncated, "a long value or one that isn't UTF-8 is not damage");
|
||||
assert!(set(&["/value [ 3 4142 ]".to_string()]).truncated);
|
||||
assert!(set(&[value("one"), "/value [ 1 zz ]".to_string()]).truncated, "a second value that isn't hexadecimal");
|
||||
}
|
||||
|
||||
/// `actionSet.read` takes exactly one of `path`, `data` and `dataBase64`, and text for the text
|
||||
/// params; any other value is an error.
|
||||
#[test]
|
||||
fn read_command_refuses_bad_params() {
|
||||
let mut s = Session::new();
|
||||
let text = "/version 3";
|
||||
let dir = std::env::temp_dir().join(format!("vc-actionset-params-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("Mine.aia");
|
||||
std::fs::write(&file, text).unwrap();
|
||||
let path = file.to_string_lossy();
|
||||
assert!(s.execute("actionSet.read", &json!({ "path": path })).is_ok());
|
||||
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
|
||||
for p in [
|
||||
json!({ "path": 5, "data": text }),
|
||||
json!({ "data": 5 }),
|
||||
json!({ "dataBase64": 5 }),
|
||||
json!({ "data": text, "name": 7 }),
|
||||
json!({ "path": path, "data": text }),
|
||||
json!({ "path": path, "dataBase64": b64 }),
|
||||
json!({ "data": text, "dataBase64": b64 }),
|
||||
] {
|
||||
assert!(s.execute("actionSet.read", &p).is_err(), "{p}");
|
||||
}
|
||||
let r = s.execute("actionSet.read", &json!({ "data": text, "name": null })).unwrap();
|
||||
assert_eq!(r["set"], "", "no set name and no file name");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// `actionSet.read` names the set after the file when it has no name, gives a step without a label,
|
||||
/// or with a blank one, a null label, and refuses text that isn't an action set.
|
||||
#[test]
|
||||
fn read_command_answers_with_the_set() {
|
||||
let mut s = Session::new();
|
||||
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"][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(" "));
|
||||
assert_eq!(s.execute("actionSet.read", &json!({ "data": blank })).unwrap()["actions"][0]["steps"][0]["label"], json!(null));
|
||||
for text in ["/Menus { }", "", "version 3", "/version three"] {
|
||||
assert!(s.execute("actionSet.read", &json!({ "data": text })).is_err(), "{text}");
|
||||
}
|
||||
assert!(s.execute("actionSet.read", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// `path` and `dataBase64` read the same set as `data`, and a set's name falls back to the path's.
|
||||
#[test]
|
||||
fn a_set_reads_from_a_path_and_from_base64() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-actionset-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Mine.aia");
|
||||
let text = "/version 3 /action-1 { /name [ 1 41 ] }";
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("actionSet.read", &json!({ "path": path.to_string_lossy() })).unwrap();
|
||||
assert_eq!((r["set"].as_str(), r["actions"][0]["name"].as_str()), (Some("Mine"), Some("A")));
|
||||
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
|
||||
assert_eq!(s.execute("actionSet.read", &json!({ "dataBase64": b64, "name": "B.aia" })).unwrap()["set"], "B");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Tokens out of place are listed with their lines, and a cap marks the set truncated.
|
||||
#[test]
|
||||
fn stray_tokens_are_listed_and_caps_truncate() {
|
||||
let set = read_aia(b"/version 3\n7 /name [ 1 zz ]\n/name [ 3 4142 ]\n/odd { /x 1 }\n}\n/action-1 { /name [ 1 41 ]").unwrap();
|
||||
let unread: Vec<&str> = set.unread.iter().map(|(_, t)| t.as_str()).collect();
|
||||
let bad = "[ … ] (not the hexadecimal bytes its count says)";
|
||||
assert_eq!(unread, ["7", bad, bad, "/odd {", "}", "blocks left open at the end: 1"]);
|
||||
let short = |text: &str| read_aia(text.as_bytes()).unwrap().truncated;
|
||||
assert!(short("/version 3 /actionCount 1 /odd { /action-1 { } }"), "the action in a block that isn't read");
|
||||
assert!(short("/version 3 /name [ 3 4142 ]"), "a set's name with the wrong byte count");
|
||||
assert!(short("/version 3 /action-1 { /name [ 1 zz ] }"), "an action's name that isn't hexadecimal");
|
||||
assert!(short("/version 3 /action-1 { /event-1 { /localizedName [ 3 4142 ] } }"), "a label with the wrong byte count");
|
||||
let cuts = ["/name [ 4 4142", "/name (ab", "/value < 2 0a", "/name", "/action-2 {"];
|
||||
let whole: Vec<&str> = cuts.into_iter().filter(|cut| !short(&format!("/version 3 /action-1 {{ }} {cut}"))).collect();
|
||||
assert!(whole.is_empty(), "{whole:?}");
|
||||
assert_eq!(set.name, None);
|
||||
assert_eq!(set.actions[0].name, "A");
|
||||
let many = format!("/version 3 {}", "/action-1 { } ".repeat(1001));
|
||||
assert!(read_aia(many.as_bytes()).unwrap().truncated);
|
||||
let deep = format!("/version 3 /action-1 {{ /event-1 {{ {} }} }}", "/parameter-1 { ".repeat(40));
|
||||
assert!(read_aia(deep.as_bytes()).unwrap().truncated);
|
||||
let long = format!("/version 3 /name {}", hex(&"n".repeat(2000)));
|
||||
assert!(read_aia(long.as_bytes()).unwrap().truncated);
|
||||
}
|
||||
|
||||
/// A count that differs from the actions, steps or recorded values that follow, or that isn't a
|
||||
/// number that fits an `i64`, marks the set truncated. A count is read only in its own block.
|
||||
#[test]
|
||||
fn counts_that_differ_truncate() {
|
||||
let short = |text: &str| read_aia(text.as_bytes()).unwrap().truncated;
|
||||
assert!(short("/version 3 /actionCount 2 /action-1 { }"), "one action of two");
|
||||
assert!(short("/version 3 /action-1 { /eventCount 2 /event-1 { } }"), "one step of two");
|
||||
assert!(!short("/version 3 /actionCount 1 /action-1 { /eventCount 1 /event-1 { } }"));
|
||||
assert!(short("/version 3 /actionCount 1 /action-1 { } /action-2 { }"), "two actions of one");
|
||||
assert!(short("/version 3 /action-1 { /eventCount 1 /event-1 { } /event-2 { } }"), "two steps of one");
|
||||
assert!(short("/version 3 /action-1 { /event-1 { /parameterCount 1 } }"), "no recorded value of one");
|
||||
assert!(short("/version 3 /action-1 { /event-1 { /parameterCount 0 /parameter-1 { } } }"), "a recorded value of none");
|
||||
assert!(!short("/version 3 /action-1 { /event-1 { /parameterCount 1 /parameter-1 { } } }"));
|
||||
assert!(short("/version 3 /actionCount -1"), "a negative count");
|
||||
assert!(short("/version 3 /action-1 { /eventCount -1 }"), "a negative step count");
|
||||
assert!(short("/version 3 /actionCount 99999999999999999999"), "a count too large for a number");
|
||||
for count in ["nope", "1.5", "(1)", "[ 1 31 ]", "< 1 31 >", "{ }"] {
|
||||
assert!(short(&format!("/version 3 /actionCount {count}")), "a count that isn't a number: {count}");
|
||||
assert!(short(&format!("/version 3 /action-1 {{ /eventCount {count} }}")), "a step count: {count}");
|
||||
assert!(short(&format!("/version 3 /action-1 {{ /event-1 {{ /parameterCount {count} }} }}")), "{count}");
|
||||
}
|
||||
assert!(short("/version 3 /actionCount /name [ 1 41 ]"), "a count followed by another field");
|
||||
assert!(short("/version 3 /action-1 { /eventCount }"), "a count at the end of its block");
|
||||
assert!(!short("/version 3 /action-1 { /event-1 { /parameter-1 { /actionCount nope } } }"), "a field inside a recorded value");
|
||||
assert!(!short("/version 3 /action-1 { /actionCount (x) }"), "a set's count in an action is not read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_takes_only_the_keys_it_describes() {
|
||||
// The control channel, MCP and the CLI check params against the description (#1442).
|
||||
check_params("actionSet.read", &json!({"path": "/x/a.aia", "data": "", "dataBase64": "", "name": "a.aia"})).unwrap();
|
||||
assert!(check_params("actionSet.read", &json!({"pth": "/x/a.aia"})).is_err());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
|
||||
//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset
|
||||
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets other apps save as text (`.kys`), and native files (compressed, damaged, saved for older versions),
|
||||
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions),
|
||||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
|
||||
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
|
||||
@@ -1617,6 +1617,93 @@ fn shortcut_sets_past_the_caps_are_truncated() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- action sets ----------
|
||||
|
||||
/// Action set `bytes` read with `actionSet.read`: an error, or a set within the caps.
|
||||
fn action_set(bytes: &[u8]) -> Result<(), TestCaseError> {
|
||||
let read = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.aia"});
|
||||
if let Ok(r) = no_panic(vectorcraft_engine::Session::new().execute("actionSet.read", &read)) {
|
||||
let actions = r["actions"].as_array().cloned().unwrap_or_default();
|
||||
prop_assert!(actions.len() <= 1000);
|
||||
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);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A token of a text action set: a block or field name, a number of any size and sign, text in
|
||||
/// parentheses (unclosed, nested or escaped too), bracketed or angled hexadecimal, a brace, a line
|
||||
/// end, NUL or bytes that aren't UTF-8.
|
||||
fn arb_aia_token() -> impl Strategy<Value = Vec<u8>> {
|
||||
prop_oneof![
|
||||
prop::sample::select(vec![
|
||||
"/action-1",
|
||||
"/event-2",
|
||||
"/parameter-1",
|
||||
"/name",
|
||||
"/internalName",
|
||||
"/localizedName",
|
||||
"/isOn",
|
||||
"/value",
|
||||
"/x",
|
||||
"/version",
|
||||
])
|
||||
.prop_map(|t| t.as_bytes().to_vec()),
|
||||
any::<i64>().prop_map(|n| n.to_string().into_bytes()),
|
||||
Just(b"99999999999999999999999".to_vec()),
|
||||
"[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!["{", "}", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()),
|
||||
prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn action_set_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..600), head in any::<bool>()) {
|
||||
let mut data = if head { b"/version 3 ".to_vec() } else { vec![] };
|
||||
data.extend(bytes);
|
||||
action_set(&data)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_action_sets_never_panic(tokens in prop::collection::vec(arb_aia_token(), 0..200), sep in prop::sample::select(vec![" ", "", "\t"])) {
|
||||
let mut data = b"/version 3".to_vec();
|
||||
for t in tokens {
|
||||
data.extend(sep.as_bytes());
|
||||
data.extend(t);
|
||||
}
|
||||
action_set(&data)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// An action set past any cap (nesting, actions, steps, name length, file size) is read up to it
|
||||
/// and marked truncated.
|
||||
#[test]
|
||||
fn action_sets_past_the_caps_are_truncated() {
|
||||
let read = |text: String| vectorcraft_engine::Session::new().execute("actionSet.read", &json!({ "data": text })).unwrap();
|
||||
let deep = format!("/version 3 /action-1 {{ {} }}", "/x { ".repeat(40));
|
||||
let actions = format!("/version 3 {}", "/action-1 { } ".repeat(1001));
|
||||
let steps = format!("/version 3 /action-1 {{ {} }}", "/event-1 { } ".repeat(10_001));
|
||||
let action = format!("/action-1 {{ {} }} ", "/event-1 { } ".repeat(10_000));
|
||||
let all_steps = format!("/version 3 {}", action.repeat(11));
|
||||
let long = format!("/version 3 /name [ 2000 {} ]", "6e".repeat(2000));
|
||||
let big = format!("/version 3 /action-1 {{ }} {}", " ".repeat(8 << 20));
|
||||
let uncapped: Vec<&str> = [("deep", deep), ("actions", actions), ("steps", steps), ("long", long), ("big", big)]
|
||||
.into_iter()
|
||||
.filter(|(_, text)| read(text.clone())["truncated"] != true)
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
assert!(uncapped.is_empty(), "{uncapped:?}");
|
||||
let r = read(all_steps);
|
||||
assert_eq!(r["truncated"], true);
|
||||
assert_eq!(r["actions"].as_array().unwrap().iter().map(|a| a["steps"].as_array().unwrap().len()).sum::<usize>(), 100_000);
|
||||
}
|
||||
|
||||
/// A small image of each raster format Place reads, with resolution metadata where it has some.
|
||||
fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let img = image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10]));
|
||||
|
||||
@@ -1666,6 +1666,7 @@ font-technology Variable Променливи
|
||||
Report copied Отчетът е копиран
|
||||
Line {line} Ред {line}
|
||||
Line {line}: {name}: {status} Ред {line}: {name}: {status}
|
||||
Read Action Set File Четене на файл с набор от действия
|
||||
Read Shortcut Set File Четене на файл с набор от клавишни комбинации
|
||||
Import Matches Импортиране на съответствия
|
||||
Remember Import Matches Запомняне на съответствията при импортиране
|
||||
@@ -4222,6 +4223,8 @@ axis Both И двете
|
||||
@msg no such action set няма такъв набор от действия
|
||||
@msg the action sets changed; nothing was deleted наборите от действия се промениха; нищо не беше изтрито
|
||||
@msg no action set to save няма набор от действия за запазване
|
||||
@msg the action set file can't be read in full файлът с набор от действия не може да се прочете изцяло
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Зареден е „{name}“: {n} стъпка е пропусната|Зареден е „{name}“: {n} стъпки са пропуснати
|
||||
@msg not an action set file не е файл с набор от действия
|
||||
@msg the action set file is larger than 8 MiB файлът с набор от действия е по-голям от 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions файлът с набор от действия съдържа повече от 1000 действия
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -142,6 +142,7 @@
|
||||
Report copied Izvještaj kopiran
|
||||
Line {line} Red {line}
|
||||
Line {line}: {name}: {status} Red {line}: {name}: {status}
|
||||
Read Action Set File Pročitaj datoteku skupa akcija
|
||||
Read Shortcut Set File Pročitaj datoteku skupa prečica
|
||||
Import Matches Uparivanja uvoza
|
||||
Remember Import Matches Zapamti uparivanja uvoza
|
||||
@@ -4616,6 +4617,8 @@ imageTrace Outline Obris
|
||||
@msg no such action set nema takvog skupa akcija
|
||||
@msg the action sets changed; nothing was deleted skupovi akcija su se promijenili; ništa nije izbrisano
|
||||
@msg no action set to save nema skupa akcija za čuvanje
|
||||
@msg the action set file can't be read in full datoteka skupa akcija ne može se pročitati u cijelosti
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Učitano „{name}”: {n} korak je izostavljen|Učitano „{name}”: {n} koraka su izostavljena|Učitano „{name}”: {n} koraka je izostavljeno
|
||||
@msg not an action set file nije datoteka skupa akcija
|
||||
@msg the action set file is larger than 8 MiB datoteka skupa akcija je veća od 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions datoteka skupa akcija sadrži više od 1.000 akcija
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3218,6 +3218,7 @@ digits Malayalam Malaiàlam
|
||||
Rating: Valoració:
|
||||
Ratio Proporció
|
||||
Rays Raigs
|
||||
Read Action Set File Llegeix el fitxer de conjunt d’accions
|
||||
Read Shortcut Set File Llegeix el fitxer de conjunt de drets de teclat
|
||||
Reads or sets environment variables Llegeix o defineix variables d’entorn
|
||||
Ready A punt
|
||||
@@ -5375,6 +5376,8 @@ digits Malayalam Malaiàlam
|
||||
@msg no such action set no existeix aquest conjunt d’accions
|
||||
@msg the action sets changed; nothing was deleted els conjunts d’accions han canviat; no s’ha suprimit res
|
||||
@msg no action set to save no hi ha cap conjunt d’accions per desar
|
||||
@msg the action set file can't be read in full el fitxer de conjunt d’accions no es pot llegir sencer
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos
|
||||
@msg not an action set file no és un fitxer de conjunt d’accions
|
||||
@msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions el fitxer de conjunt d’accions conté més de 1.000 accions
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -150,6 +150,7 @@
|
||||
Report copied Zpráva zkopírována
|
||||
Line {line} Řádek {line}
|
||||
Line {line}: {name}: {status} Řádek {line}: {name}: {status}
|
||||
Read Action Set File Přečíst soubor sady akcí
|
||||
Read Shortcut Set File Přečíst soubor sady klávesových zkratek
|
||||
Import Matches Přiřazení z importů
|
||||
Remember Import Matches Zapamatovat přiřazení z importů
|
||||
@@ -4668,6 +4669,8 @@ imageTrace Outline Obrys
|
||||
@msg no such action set taková sada akcí neexistuje
|
||||
@msg the action sets changed; nothing was deleted sady akcí se změnily; nic nebylo odstraněno
|
||||
@msg no action set to save žádná sada akcí k uložení
|
||||
@msg the action set file can't be read in full soubor sady akcí nelze přečíst celý
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků
|
||||
@msg not an action set file není to soubor sady akcí
|
||||
@msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions soubor sady akcí obsahuje více než 1 000 akcí
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -135,6 +135,7 @@
|
||||
Report copied Rapporten er kopieret
|
||||
Line {line} Linje {line}
|
||||
Line {line}: {name}: {status} Linje {line}: {name}: {status}
|
||||
Read Action Set File Læs handlingssætfil
|
||||
Read Shortcut Set File Læs fil med genvejssæt
|
||||
Import Matches Importér match
|
||||
Remember Import Matches Husk importmatch
|
||||
@@ -4330,6 +4331,8 @@ imageTrace Outline Kontur
|
||||
@msg no such action set intet sådant handlingssæt
|
||||
@msg the action sets changed; nothing was deleted handlingssættene er ændret; intet blev slettet
|
||||
@msg no action set to save intet handlingssæt at gemme
|
||||
@msg the action set file can't be read in full handlingssætfilen kan ikke læses helt
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Indlæste »{name}«: {n} trin blev udeladt|Indlæste »{name}«: {n} trin blev udeladt
|
||||
@msg not an action set file ikke en handlingssætfil
|
||||
@msg the action set file is larger than 8 MiB handlingssætfilen er større end 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions handlingssætfilen indeholder mere end 1.000 handlinger
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1714,6 +1714,7 @@ file-filter Templates Vorlagen
|
||||
Line {line} Zeile {line}
|
||||
Line {line}: {name}: {status} Zeile {line}: {name}: {status}
|
||||
Imported. Press OK to keep the imported set. Importiert. Klicken Sie auf „OK“, um den importierten Satz zu behalten.
|
||||
Read Action Set File Aktionssatzdatei lesen
|
||||
Read Shortcut Set File Tastaturbefehlssatz-Datei lesen
|
||||
Import Matches Import-Zuordnungen
|
||||
Remember Import Matches Import-Zuordnungen merken
|
||||
@@ -4274,6 +4275,8 @@ theme Appearance Erscheinungsbild
|
||||
@msg no such action set kein solcher Aktionssatz
|
||||
@msg the action sets changed; nothing was deleted die Aktionssätze haben sich geändert; nichts wurde gelöscht
|
||||
@msg no action set to save kein Aktionssatz zum Speichern
|
||||
@msg the action set file can't be read in full die Aktionssatzdatei kann nicht vollständig gelesen werden
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen
|
||||
@msg not an action set file keine Aktionssatzdatei
|
||||
@msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions die Aktionssatzdatei enthält mehr als 1.000 Aktionen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1624,6 +1624,7 @@ font-technology Variable Μεταβλητές
|
||||
Report copied Η αναφορά αντιγράφηκε
|
||||
Line {line} Γραμμή {line}
|
||||
Line {line}: {name}: {status} Γραμμή {line}: {name}: {status}
|
||||
Read Action Set File Ανάγνωση αρχείου συνόλου ενεργειών
|
||||
Read Shortcut Set File Ανάγνωση αρχείου συνόλου συντομεύσεων
|
||||
Import Matches Αντιστοιχίσεις εισαγωγής
|
||||
Remember Import Matches Απομνημόνευση αντιστοιχίσεων εισαγωγής
|
||||
@@ -4313,6 +4314,8 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg no such action set δεν υπάρχει τέτοιο σύνολο ενεργειών
|
||||
@msg the action sets changed; nothing was deleted τα σύνολα ενεργειών άλλαξαν· δεν διαγράφηκε τίποτα
|
||||
@msg no action set to save δεν υπάρχει σύνολο ενεργειών για αποθήκευση
|
||||
@msg the action set file can't be read in full το αρχείο συνόλου ενεργειών δεν μπορεί να διαβαστεί ολόκληρο
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα
|
||||
@msg not an action set file δεν είναι αρχείο συνόλου ενεργειών
|
||||
@msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions το αρχείο συνόλου ενεργειών περιέχει περισσότερες από 1.000 ενέργειες
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 168 and column 5.
|
@@ -1717,6 +1717,7 @@ font-technology Variable Variables
|
||||
Report copied Informe copiado
|
||||
Line {line} Línea {line}
|
||||
Line {line}: {name}: {status} Línea {line}: {name}: {status}
|
||||
Read Action Set File Leer archivo de conjunto de acciones
|
||||
Read Shortcut Set File Leer archivo de conjunto de métodos abreviados
|
||||
Import Matches Asociaciones de importación
|
||||
Remember Import Matches Recordar asociaciones de importación
|
||||
@@ -4358,6 +4359,8 @@ axis Both Ambos
|
||||
@msg no such action set no existe ese conjunto de acciones
|
||||
@msg the action sets changed; nothing was deleted los conjuntos de acciones han cambiado; no se ha eliminado nada
|
||||
@msg no action set to save no hay ningún conjunto de acciones que guardar
|
||||
@msg the action set file can't be read in full el archivo de conjunto de acciones no se puede leer completo
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos
|
||||
@msg not an action set file no es un archivo de conjunto de acciones
|
||||
@msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions el archivo de conjunto de acciones contiene más de 1000 acciones
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -157,6 +157,7 @@
|
||||
Report copied Raportti kopioitu
|
||||
Line {line} Rivi {line}
|
||||
Line {line}: {name}: {status} Rivi {line}: {name}: {status}
|
||||
Read Action Set File Lue toimintosarjatiedosto
|
||||
Read Shortcut Set File Lue pikanäppäinjoukkotiedosto
|
||||
Import Matches Tuonnin vastaavuudet
|
||||
Remember Import Matches Muista tuonnin vastaavuudet
|
||||
@@ -4576,6 +4577,8 @@ imageTrace Outline Ääriviiva
|
||||
@msg no such action set toimintosarjaa ei ole
|
||||
@msg the action sets changed; nothing was deleted toimintosarjat muuttuivat; mitään ei poistettu
|
||||
@msg no action set to save ei tallennettavaa toimintosarjaa
|
||||
@msg the action set file can't be read in full toimintosarjatiedostoa ei voi lukea kokonaan
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta
|
||||
@msg not an action set file ei ole toimintosarjatiedosto
|
||||
@msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions toimintosarjatiedostossa on yli 1 000 toimintoa
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1711,6 +1711,7 @@ font-technology Variable Variables
|
||||
Report copied Rapport copié
|
||||
Line {line} Ligne {line}
|
||||
Line {line}: {name}: {status} Ligne {line}, {name}, {status}
|
||||
Read Action Set File Lire un fichier de jeu de scripts
|
||||
Read Shortcut Set File Lire un fichier de jeu de raccourcis
|
||||
Import Matches Correspondances d’importation
|
||||
Remember Import Matches Mémoriser les correspondances d’importation
|
||||
@@ -4348,6 +4349,8 @@ axis Both Les deux
|
||||
@msg no such action set jeu de scripts inexistant
|
||||
@msg the action sets changed; nothing was deleted les jeux de scripts ont changé ; rien n’a été supprimé
|
||||
@msg no action set to save aucun jeu de scripts à enregistrer
|
||||
@msg the action set file can't be read in full le fichier de jeu de scripts ne peut pas être lu en entier
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées
|
||||
@msg not an action set file ce n’est pas un fichier de jeu de scripts
|
||||
@msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio
|
||||
@msg the action set file holds more than 1,000 actions le fichier de jeu de scripts contient plus de 1 000 scripts
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3509,6 +3509,7 @@
|
||||
Rating: रेटिंग:
|
||||
Ratio अनुपात
|
||||
Rays रे
|
||||
Read Action Set File एक्शन सेट फ़ाइल पढ़ें
|
||||
Read Shortcut Set File शॉर्टकट सेट फ़ाइल पढ़ें
|
||||
Reads or sets environment variables पर्यावरण चर पढ़ता है या सेट करता है
|
||||
Ready तैयार
|
||||
@@ -6857,6 +6858,8 @@
|
||||
@msg not a Word document: its main part is not a WordprocessingML document वर्ड डॉक्यूमेंट नहीं: इसका मुख्य हिस्सा वर्डप्रोसेसिंगएमएल डॉक्यूमेंट नहीं है
|
||||
@msg not a Word document: its package has no main document part नहीं एक वर्ड डॉक्यूमेंट: इसके पैकेज में कोई मुख्य डॉक्यूमेंट हिस्सा नहीं है
|
||||
@msg not a Word document: its WordDocument stream doesn't start with a file information block वर्ड डॉक्यूमेंट नहीं: इसकी WordDocument धारा फ़ाइल सूचना ब्लॉक के साथ शुरू नहीं होती है
|
||||
@msg the action set file can't be read in full एक्शन सेट फ़ाइल पूरी तरह नहीं पढ़ी जा सकती
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए
|
||||
@msg not an action set file एक एक्शन सेट फ़ाइल नहीं
|
||||
@msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल
|
||||
@msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2156,6 +2156,7 @@ file-filter Templates Sablonok
|
||||
Report copied Jelentés másolva
|
||||
Line {line} {line}. sor
|
||||
Line {line}: {name}: {status} {line}. sor: {name}: {status}
|
||||
Read Action Set File Műveletkészlet beolvasása fájlból
|
||||
Read Shortcut Set File Billentyűparancs-készlet beolvasása fájlból
|
||||
Import Matches Importálási megfeleltetések
|
||||
Remember Import Matches Importálási megfeleltetések megjegyzése
|
||||
@@ -5040,6 +5041,8 @@ imageTrace Outline Körvonal
|
||||
@msg no such action set nincs ilyen műveletkészlet
|
||||
@msg the action sets changed; nothing was deleted a műveletkészletek megváltoztak; semmi sem törlődött
|
||||
@msg no action set to save nincs menthető műveletkészlet
|
||||
@msg the action set file can't be read in full a műveletkészlet-fájl nem olvasható be teljesen
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n}
|
||||
@msg not an action set file nem műveletkészlet-fájl
|
||||
@msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál
|
||||
@msg the action set file holds more than 1,000 actions a műveletkészlet-fájl több mint 1000 műveletet tartalmaz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -135,6 +135,7 @@
|
||||
Report copied Laporan disalin
|
||||
Line {line} Baris {line}
|
||||
Line {line}: {name}: {status} Baris {line}: {name}: {status}
|
||||
Read Action Set File Baca berkas set tindakan
|
||||
Read Shortcut Set File Baca berkas set pintasan
|
||||
Import Matches Impor kecocokan
|
||||
Remember Import Matches Ingat kecocokan impor
|
||||
@@ -4330,6 +4331,8 @@ imageTrace Outline Kontur
|
||||
@msg no such action set tidak ada set tindakan seperti itu
|
||||
@msg the action sets changed; nothing was deleted set tindakan telah berubah; tidak ada yang dihapus
|
||||
@msg no action set to save tidak ada set tindakan untuk disimpan
|
||||
@msg the action set file can't be read in full file set tindakan tidak dapat dibaca seluruhnya
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” dimuat: {n} langkah dilewati
|
||||
@msg not an action set file bukan file set tindakan
|
||||
@msg the action set file is larger than 8 MiB file set tindakan lebih besar dari 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions file set tindakan berisi lebih dari 1.000 tindakan
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2270 and column 5.
|
@@ -1746,6 +1746,7 @@ file-filter Templates Modelli
|
||||
Line {line} Riga {line}
|
||||
Line {line}: {name}: {status} Riga {line}: {name}: {status}
|
||||
Imported. Press OK to keep the imported set. Importato. Premi OK per mantenere il set importato.
|
||||
Read Action Set File Leggi file di set di azioni
|
||||
Read Shortcut Set File Leggi file di set di scelte rapide
|
||||
Import Matches Abbinamenti di importazione
|
||||
Remember Import Matches Ricorda abbinamenti di importazione
|
||||
@@ -4400,6 +4401,8 @@ axis Both Entrambi
|
||||
@msg no such action set set di azioni inesistente
|
||||
@msg the action sets changed; nothing was deleted i set di azioni sono cambiati; non è stato eliminato nulla
|
||||
@msg no action set to save nessun set di azioni da salvare
|
||||
@msg the action set file can't be read in full il file di set di azioni non può essere letto per intero
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi
|
||||
@msg not an action set file non è un file di set di azioni
|
||||
@msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions il file del set di azioni contiene più di 1.000 azioni
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2008,6 +2008,7 @@ digits Malayalam マラヤーラム数字
|
||||
Line {line} {line} 行目
|
||||
Line {line}: {name}: {status} {line} 行目: {name}: {status}
|
||||
Imported. Press OK to keep the imported set. 読み込みました。読み込んだセットを保持するには OK を押してください。
|
||||
Read Action Set File アクションセットファイルを読む
|
||||
Read Shortcut Set File ショートカットセットファイルを読む
|
||||
Import Matches 読み込み時の対応付け
|
||||
Remember Import Matches 読み込み時の対応付けを記憶
|
||||
@@ -4735,6 +4736,8 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg no such action set 指定されたアクションセットが見つかりません
|
||||
@msg the action sets changed; nothing was deleted アクションセットが変更されたため、何も削除しませんでした
|
||||
@msg no action set to save 保存するアクションセットがありません
|
||||
@msg the action set file can't be read in full アクションセットファイルをすべて読み込めません
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました
|
||||
@msg not an action set file アクションセットファイルではありません
|
||||
@msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています
|
||||
@msg the action set file holds more than 1,000 actions アクションセットファイルのアクションが 1,000 個を超えています
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4127,6 +4127,8 @@ font-technology Variable ცვლადი
|
||||
@msg no such action set ასეთი მოქმედებების ნაკრები არ არის
|
||||
@msg the action sets changed; nothing was deleted მოქმედებების ნაკრებები შეიცვალა; არაფერი წაშლილა
|
||||
@msg no action set to save შესანახი მოქმედებების ნაკრები არ არის
|
||||
@msg the action set file can't be read in full მოქმედებების ნაკრების ფაილის სრულად წაკითხვა შეუძლებელია
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n}
|
||||
@msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი
|
||||
@msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია
|
||||
@msg the action set file holds more than 1,000 actions მოქმედებების ნაკრების ფაილი 1000-ზე მეტ მოქმედებას შეიცავს
|
||||
@@ -5104,6 +5106,7 @@ imageTrace Outline კონტური
|
||||
Precise Cursors ზუსტი კურსორები
|
||||
Image Trace Preview გამოსახულების ვექტორიზაციის წინასწარი ნახვა
|
||||
Press a key… დააჭირეთ კლავიშს…
|
||||
Read Action Set File მოქმედებების ნაკრების ფაილის წაკითხვა
|
||||
Read Shortcut Set File მალსახმობების ნაკრების ფაილის წაკითხვა
|
||||
Ready მზადაა
|
||||
Remember Import Matches იმპორტის შესაბამისობების დამახსოვრება
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1954,6 +1954,7 @@ digits Malayalam 말라얄람어
|
||||
Line {line} {line}행
|
||||
Line {line}: {name}: {status} {line}행: {name}: {status}
|
||||
Imported. Press OK to keep the imported set. 가져왔습니다. 가져온 세트를 유지하려면 확인을 누르세요.
|
||||
Read Action Set File 액션 세트 파일 읽기
|
||||
Read Shortcut Set File 단축키 세트 파일 읽기
|
||||
Import Matches 가져오기 대응
|
||||
Remember Import Matches 가져오기 대응 기억
|
||||
@@ -4662,6 +4663,8 @@ theme Appearance 외관
|
||||
@msg no such action set 해당 액션 세트가 없습니다
|
||||
@msg the action sets changed; nothing was deleted 액션 세트가 바뀌어 아무것도 삭제하지 않았습니다
|
||||
@msg no action set to save 저장할 액션 세트가 없습니다
|
||||
@msg the action set file can't be read in full 액션 세트 파일을 전부 읽을 수 없습니다
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다
|
||||
@msg not an action set file 액션 세트 파일이 아닙니다
|
||||
@msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다
|
||||
@msg the action set file holds more than 1,000 actions 액션 세트 파일의 액션이 1,000개를 넘습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -135,6 +135,7 @@
|
||||
Report copied Rapport gekopieerd
|
||||
Line {line} Regel {line}
|
||||
Line {line}: {name}: {status} Regel {line}: {name}: {status}
|
||||
Read Action Set File Actiesetbestand lezen
|
||||
Read Shortcut Set File Sneltoetsensetbestand lezen
|
||||
Import Matches Overeenkomsten importeren
|
||||
Remember Import Matches Importovereenkomsten onthouden
|
||||
@@ -4342,6 +4343,8 @@ imageTrace Outline Contour
|
||||
@msg no such action set actieset bestaat niet
|
||||
@msg the action sets changed; nothing was deleted de actiesets zijn gewijzigd; er is niets verwijderd
|
||||
@msg no action set to save geen actieset om op te slaan
|
||||
@msg the action set file can't be read in full het actiesetbestand kan niet volledig worden gelezen
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” geladen: {n} stap is weggelaten|„{name}” geladen: {n} stappen zijn weggelaten
|
||||
@msg not an action set file geen actiesetbestand
|
||||
@msg the action set file is larger than 8 MiB het actiesetbestand is groter dan 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions het actiesetbestand bevat meer dan 1.000 acties
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4491,6 +4491,8 @@ imageTrace Outline Kontur
|
||||
@msg no such action set brak takiego zestawu operacji
|
||||
@msg the action sets changed; nothing was deleted zestawy operacji się zmieniły; nic nie usunięto
|
||||
@msg no action set to save brak zestawu operacji do zapisania
|
||||
@msg the action set file can't be read in full nie można odczytać całego pliku zestawu operacji
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków
|
||||
@msg not an action set file to nie jest plik zestawu operacji
|
||||
@msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions plik zestawu operacji zawiera ponad 1000 operacji
|
||||
@@ -5060,6 +5062,7 @@ imageTrace Outline Kontur
|
||||
Report copied Skopiowano raport
|
||||
Line {line} Wiersz {line}
|
||||
Line {line}: {name}: {status} Wiersz {line}: {name}: {status}
|
||||
Read Action Set File Odczytaj plik zestawu operacji
|
||||
Read Shortcut Set File Odczytaj plik zestawu skrótów
|
||||
Import Matches Dopasowania importu
|
||||
Remember Import Matches Zapamiętaj dopasowania importu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3941,6 +3941,8 @@ axis Both Ambos
|
||||
@msg no such action set conjunto de ações inexistente
|
||||
@msg the action sets changed; nothing was deleted os conjuntos de ações mudaram; nada foi excluído
|
||||
@msg no action set to save nenhum conjunto de ações para salvar
|
||||
@msg the action set file can't be read in full o arquivo de conjunto de ações não pode ser lido por inteiro
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas
|
||||
@msg not an action set file não é um arquivo de conjunto de ações
|
||||
@msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions o arquivo de conjunto de ações contém mais de 1.000 ações
|
||||
@@ -4698,6 +4700,7 @@ axis Both Ambos
|
||||
Report copied Relatório copiado
|
||||
Line {line} Linha {line}
|
||||
Line {line}: {name}: {status} Linha {line}: {name}: {status}
|
||||
Read Action Set File Ler Arquivo de Conjunto de Ações
|
||||
Read Shortcut Set File Ler Arquivo de Conjunto de Atalhos
|
||||
Import Matches Importar Correspondências
|
||||
Remember Import Matches Lembrar Correspondências de Importação
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1743,6 +1743,7 @@ file-filter Templates Шаблоны
|
||||
Line {line} Строка {line}
|
||||
Line {line}: {name}: {status} Строка {line}: {name}: {status}
|
||||
Imported. Press OK to keep the imported set. Импортировано. Нажмите «ОК», чтобы сохранить импортированный набор.
|
||||
Read Action Set File Прочитать файл набора действий
|
||||
Read Shortcut Set File Прочитать файл набора сочетаний клавиш
|
||||
Import Matches Сопоставления импорта
|
||||
Remember Import Matches Запомнить сопоставления импорта
|
||||
@@ -4395,6 +4396,8 @@ axis Both Оба
|
||||
@msg no such action set такого набора действий нет
|
||||
@msg the action sets changed; nothing was deleted наборы действий изменились; ничего не удалено
|
||||
@msg no action set to save нет набора действий для сохранения
|
||||
@msg the action set file can't be read in full файл набора действий невозможно прочитать целиком
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов
|
||||
@msg not an action set file это не файл набора действий
|
||||
@msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions файл набора действий содержит более 1000 действий
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1672,6 +1672,7 @@ font-technology Variable Variabla
|
||||
Report copied Rapporten har kopierats
|
||||
Line {line} Rad {line}
|
||||
Line {line}: {name}: {status} Rad {line}: {name}: {status}
|
||||
Read Action Set File Läs fil med åtgärdsuppsättning
|
||||
Read Shortcut Set File Läs fil med kortkommandon
|
||||
Import Matches Importera matchningar
|
||||
Remember Import Matches Kom ihåg importmatchningar
|
||||
@@ -4263,6 +4264,8 @@ axis Both Båda
|
||||
@msg no such action set åtgärdsuppsättningen finns inte
|
||||
@msg the action sets changed; nothing was deleted åtgärdsuppsättningarna har ändrats; inget raderades
|
||||
@msg no action set to save ingen åtgärdsuppsättning att spara
|
||||
@msg the action set file can't be read in full filen med åtgärdsuppsättning kan inte läsas helt
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades
|
||||
@msg not an action set file inte en fil med åtgärdsuppsättning
|
||||
@msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions filen med åtgärdsuppsättningen innehåller fler än 1 000 åtgärder
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1674,9 +1674,10 @@ const COUNTED_STATUS_MESSAGES: &[(&str, &str)] = &[
|
||||
("Copied the CSS of 1 object", "Copied the CSS of 2 objects"),
|
||||
("Downloaded 1 file", "Downloaded 2 files"),
|
||||
("Exported 1 file to /out", "Exported 2 files to /out"),
|
||||
("Loaded “Tidy”: 1 step was left out", "Loaded “Tidy”: 2 steps were left out"),
|
||||
];
|
||||
|
||||
/// The link, font and export status messages name a count of 1 in the singular and other counts in
|
||||
/// The link, font, export and action set status messages name a count of 1 in the singular and other counts in
|
||||
/// the plural, and every complete catalog translates both, in the form its plural rule picks.
|
||||
#[test]
|
||||
fn counted_status_messages_take_the_plural_form() {
|
||||
|
||||
@@ -1553,6 +1553,7 @@ font-technology Variable Değişken
|
||||
Report copied Rapor kopyalandı
|
||||
Line {line} {line} Hattı
|
||||
Line {line}: {name}: {status} Hat {line}: {name}: {status}
|
||||
Read Action Set File Eylem Seti Dosyasını Oku
|
||||
Read Shortcut Set File Kısayol Seti Dosyasını Oku
|
||||
Import Matches Eşleşmeleri İçe Aktar
|
||||
Remember Import Matches İçe Aktarılan Eşleşmeleri Hatırla
|
||||
@@ -4124,6 +4125,8 @@ axis Both Her İkisi
|
||||
@msg no such action set öyle bir eylem seti yok
|
||||
@msg the action sets changed; nothing was deleted eylem setleri değişti; hiçbir şey silinmedi
|
||||
@msg no action set to save kaydedilecek eylem seti yok
|
||||
@msg the action set file can't be read in full eylem seti dosyası tamamen okunamıyor
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı
|
||||
@msg not an action set file bir eylem seti dosyası değil
|
||||
@msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük
|
||||
@msg the action set file holds more than 1,000 actions eylem seti dosyası 1.000'den fazla eylem içeriyor
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1740,6 +1740,7 @@ file-filter Templates Шаблони
|
||||
Line {line} Рядок {line}
|
||||
Line {line}: {name}: {status} Рядок {line}: {name}: {status}
|
||||
Imported. Press OK to keep the imported set. Імпортовано. Натисніть «ОК», щоб зберегти імпортований набір.
|
||||
Read Action Set File Прочитати файл набору дій
|
||||
Read Shortcut Set File Прочитати файл набору сполучень клавіш
|
||||
Import Matches Зіставлення імпорту
|
||||
Remember Import Matches Запам’ятати зіставлення імпорту
|
||||
@@ -4384,6 +4385,8 @@ axis Both Обидві
|
||||
@msg no such action set такого набору дій немає
|
||||
@msg the action sets changed; nothing was deleted набори дій змінилися; нічого не видалено
|
||||
@msg no action set to save немає набору дій для збереження
|
||||
@msg the action set file can't be read in full файл набору дій неможливо прочитати повністю
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків
|
||||
@msg not an action set file це не файл набору дій
|
||||
@msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions файл набору дій містить понад 1000 дій
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -1891,6 +1891,7 @@ digits Hindi Hindi
|
||||
Line {line} Dòng {line}
|
||||
Line {line}: {name}: {status} Dòng {line}: {name}: {status}
|
||||
Imported. Press OK to keep the imported set. Đã nhập. Nhấn OK để giữ bộ đã nhập.
|
||||
Read Action Set File Đọc tệp bộ hành động
|
||||
Read Shortcut Set File Đọc tệp bộ phím tắt
|
||||
Import Matches Cặp ghép khi nhập
|
||||
Remember Import Matches Ghi nhớ cặp ghép khi nhập
|
||||
@@ -4692,6 +4693,8 @@ theme Appearance Giao diện
|
||||
@msg no such action set không có bộ hành động như vậy
|
||||
@msg the action sets changed; nothing was deleted các bộ hành động đã thay đổi; không có gì bị xóa
|
||||
@msg no action set to save không có bộ hành động nào để lưu
|
||||
@msg the action set file can't be read in full không thể đọc hết tệp bộ hành động
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước
|
||||
@msg not an action set file không phải tệp bộ hành động
|
||||
@msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions tệp bộ hành động chứa hơn 1.000 hành động
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -4448,6 +4448,8 @@ imageTrace Outline 轮廓
|
||||
@msg no such action set 没有此动作组
|
||||
@msg the action sets changed; nothing was deleted 动作组已更改;未删除任何内容
|
||||
@msg no action set to save 没有要存储的动作组
|
||||
@msg the action set file can't be read in full 无法完整读取动作组文件
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤
|
||||
@msg not an action set file 不是动作组文件
|
||||
@msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions 动作组文件包含超过 1,000 个动作
|
||||
@@ -4715,6 +4717,7 @@ imageTrace Outline 轮廓
|
||||
Report copied 已复制报告
|
||||
Line {line} 第 {line} 行
|
||||
Line {line}: {name}: {status} 第 {line} 行:{name}:{status}
|
||||
Read Action Set File 读取动作组文件
|
||||
Read Shortcut Set File 读取快捷键组文件
|
||||
Import Matches 导入对应
|
||||
Remember Import Matches 记住导入对应
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2303,6 +2303,8 @@ file-filter Templates 範本
|
||||
@msg no such action set 沒有這個動作組
|
||||
@msg the action sets changed; nothing was deleted 動作組已變更;未刪除任何內容
|
||||
@msg no action set to save 沒有要儲存的動作組
|
||||
@msg the action set file can't be read in full 無法完整讀取動作組檔案
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟
|
||||
@msg not an action set file 不是動作組檔案
|
||||
@msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB
|
||||
@msg the action set file holds more than 1,000 actions 動作組檔案包含超過 1,000 個動作
|
||||
@@ -2597,6 +2599,7 @@ file-filter Templates 範本
|
||||
Line {line} 第 {line} 行
|
||||
Line {line}: {name}: {status} 第 {line} 行:{name}:{status}
|
||||
Imported. Press OK to keep the imported set. 已匯入。按一下「確定」以保留匯入的快速鍵組。
|
||||
Read Action Set File 讀取動作組檔案
|
||||
Read Shortcut Set File 讀取快速鍵組檔案
|
||||
Import Matches 匯入對應
|
||||
Remember Import Matches 記住匯入對應
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1365,7 +1365,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; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}",
|
||||
"{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; 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\"",
|
||||
),
|
||||
(
|
||||
crate::panels::actions::DELETE_SETS,
|
||||
|
||||
@@ -2680,6 +2680,191 @@ mod tests {
|
||||
assert_eq!(*asked.borrow(), [files::MAX_SET_FILE + 1; 2], "each read stops one byte past the limit");
|
||||
}
|
||||
|
||||
/// An action set as another app saves it as text, written from scratch: the set `name` (none
|
||||
/// when empty) and actions of steps, each a label and whether it is on, with no recorded values.
|
||||
fn aia_text(name: &str, actions: &[(&str, &[(&str, bool)])]) -> String {
|
||||
let hex = |t: &str| format!("[ {} {} ]", t.len(), t.bytes().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
let mut out = String::from("/version 3\n");
|
||||
if !name.is_empty() {
|
||||
out += &format!("/name {}\n", hex(name));
|
||||
}
|
||||
out += &format!("/isOpen 1\n/actionCount {}\n", actions.len());
|
||||
for (i, (action, steps)) in actions.iter().enumerate() {
|
||||
out += &format!("/action-{} {{\n\t/name {}\n\t/eventCount {}\n", i + 1, hex(action), steps.len());
|
||||
for (j, (label, on)) in steps.iter().enumerate() {
|
||||
out += &format!(
|
||||
"\t/event-{} {{\n\t\t/internalName (sample_step_{j})\n\t\t/localizedName {}\n\t\t/isOn {}\n\t\t/parameterCount 0\n\t}}\n",
|
||||
j + 1,
|
||||
hex(label),
|
||||
u8::from(*on)
|
||||
);
|
||||
}
|
||||
out += "}\n";
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Load Actions… reads an action set another app saved as text: its actions come in with the
|
||||
/// steps with no recorded values whose label is a VectorCraft command's that takes no values and
|
||||
/// has no dialog, a step that is off stays excluded from playback, the other steps are left out
|
||||
/// and listed, and the status bar says how many. An action plays the steps that are on.
|
||||
#[test]
|
||||
fn load_actions_reads_another_apps_action_set() {
|
||||
let mut app = app_with_sets();
|
||||
let text = aia_text(
|
||||
"Tidy Up",
|
||||
&[("Group Them", &[("group", true), ("Move", true), ("Ungroup", false)]), ("Nothing", &[]), ("", &[("Ungroup", true)])],
|
||||
);
|
||||
let r = app.run(LOAD_SET, json!({ "data": text })).unwrap();
|
||||
let skipped = json!([{"action": "Group Them", "step": "Move"}]);
|
||||
assert_eq!(r, json!({"set": 3, "name": "Tidy Up", "actions": 3, "skipped": skipped, "leftOut": 1}));
|
||||
let set = &app.ui.action_sets[3];
|
||||
assert_eq!(set.actions[0].name, "Group Them");
|
||||
assert_eq!(set.actions[0].steps, [("object.group".to_string(), json!({})), ("object.ungroup".to_string(), json!({}))]);
|
||||
assert_eq!(set.actions[0].excluded_steps, [1].into());
|
||||
assert!(set.actions[1].steps.is_empty());
|
||||
assert_eq!(set.actions[2].name, "Action 3", "an action with no name");
|
||||
assert_eq!(app.ui.status, "Loaded “Tidy Up”: 1 step was left out");
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
for x in [0, 50] {
|
||||
app.run("shape.rectangle", json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().selection.objects.len(), 2);
|
||||
play(&mut app, 3, 0).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().selection.objects.len(), 1, "the action grouped the rectangles and did not ungroup them");
|
||||
}
|
||||
|
||||
/// A step recorded with values is left out even when a command has its label: a step that set an
|
||||
/// opacity does not select by opacity. A step whose values name a menu command, in two or three
|
||||
/// values, is left out too. The status bar counts the steps left out.
|
||||
#[test]
|
||||
fn recorded_values_keep_a_step_out() {
|
||||
let hex = |t: &str| format!("[ {} {} ]", t.len(), t.bytes().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
let text = |t: &str| format!("/type (ustring) /value {}", hex(t));
|
||||
let number = || "/type (real) /value 60.0".to_string();
|
||||
let menu = |shown: &str| vec![text("some_command"), text(shown), number()];
|
||||
let steps = [
|
||||
("Opacity", vec![number()]),
|
||||
("Stroke Weight", vec![number()]),
|
||||
("A Menu Step", menu("Group")),
|
||||
("A Menu Step", vec![text("some_command"), text("Ungroup")]),
|
||||
("Rename", vec![text("OldLayer"), text("Group")]),
|
||||
("Group", vec![number(), text("Group"), number()]),
|
||||
("Group", vec![]),
|
||||
];
|
||||
let events: String = steps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, values))| {
|
||||
let params: String = values.iter().enumerate().map(|(n, v)| format!("/parameter-{} {{ {v} }} ", n + 1)).collect();
|
||||
format!("/event-{} {{ /localizedName {} /parameterCount {} {params} }} ", i + 1, hex(label), values.len())
|
||||
})
|
||||
.collect();
|
||||
let set = format!("/version 3 /name {} /action-1 {{ /name {} {events} }}", hex("Values"), hex("A"));
|
||||
let mut app = app_with_sets();
|
||||
let r = app.run(LOAD_SET, json!({ "data": set })).unwrap();
|
||||
assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]);
|
||||
assert_eq!(r["leftOut"], 6);
|
||||
let left_out: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|s| s["step"].as_str().unwrap()).collect();
|
||||
assert_eq!(left_out, ["Opacity", "Stroke Weight", "A Menu Step", "A Menu Step", "Rename", "Group"]);
|
||||
assert_eq!(app.ui.status, "Loaded “Values”: 6 steps were left out");
|
||||
}
|
||||
|
||||
/// A step with no label, or a blank one, is left out and listed by its internal name, even when
|
||||
/// that name is a command's label.
|
||||
#[test]
|
||||
fn a_step_with_no_label_is_left_out() {
|
||||
let set = "/version 3 /name [ 4 4e6f6e65 ] /action-1 { /name [ 1 41 ] \
|
||||
/event-1 { /internalName (group) /parameterCount 0 } \
|
||||
/event-2 { /internalName (Ungroup) /localizedName [ 1 20 ] /parameterCount 0 } \
|
||||
/event-3 { /internalName (x) /localizedName [ 5 47726f7570 ] /parameterCount 0 } }";
|
||||
let mut app = app_with_sets();
|
||||
let r = app.run(LOAD_SET, json!({ "data": set })).unwrap();
|
||||
assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]);
|
||||
assert_eq!(r["skipped"], json!([{"action": "A", "step": "group"}, {"action": "A", "step": "Ungroup"}]));
|
||||
assert_eq!(r["leftOut"], 2);
|
||||
}
|
||||
/// 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
|
||||
/// caps, one whose text ends inside a block, one whose counts differ from what follows and one
|
||||
/// with a damaged name are refused, and text that isn't an action set is not read.
|
||||
#[test]
|
||||
fn load_actions_names_another_apps_set_after_its_file() {
|
||||
let mut app = app_with_sets();
|
||||
let files = with_files(&mut app, "/sets/Mine.aia");
|
||||
let mut bytes = aia_text("", &[("One", &[("Ungroup", true)])]).into_bytes();
|
||||
bytes.extend(b"/note (\xFF)\n");
|
||||
files.borrow_mut().insert("/sets/Mine.aia".into(), bytes);
|
||||
let r = app.run(LOAD_SET, json!({})).unwrap();
|
||||
assert_eq!((r["name"].clone(), r["leftOut"].clone()), (json!("Mine 2"), json!(0)));
|
||||
assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.ungroup".to_string(), json!({}))]);
|
||||
let actions = vec![("A", [("Group", true)].as_slice()); 1001];
|
||||
let r = app.run(LOAD_SET, json!({ "data": aia_text("Big", &actions) }));
|
||||
assert_eq!(r.unwrap_err(), "the action set file can't be read in full");
|
||||
let cut = aia_text("Cut", &[("A", &[("Group", true)])]);
|
||||
let cut = cut.trim_end().strip_suffix('}').unwrap().to_string();
|
||||
assert_eq!(app.run(LOAD_SET, json!({ "data": cut })).unwrap_err(), "the action set file can't be read in full");
|
||||
let counts = aia_text("Counts", &[("A", &[]), ("B", &[])]).replace("/actionCount 2", "/actionCount 1");
|
||||
assert_eq!(app.run(LOAD_SET, json!({ "data": counts })).unwrap_err(), "the action set file can't be read in full");
|
||||
for damaged in ["/version 3 /action-1 { /name [ 3 41 ] }", "/version 3 /name [ 3 41 ]"] {
|
||||
assert_eq!(app.run(LOAD_SET, json!({ "data": damaged })).unwrap_err(), "the action set file can't be read in full");
|
||||
}
|
||||
for not_a_set in ["/version three", "/versionX 3"] {
|
||||
assert_eq!(app.run(LOAD_SET, json!({ "data": not_a_set })).unwrap_err(), "not an action set file");
|
||||
}
|
||||
assert_eq!(app.ui.action_sets.len(), 4, "no set added");
|
||||
let r = app.run(LOAD_SET, json!({ "data": aia_text("", &[]) })).unwrap();
|
||||
assert_eq!(r["name"], "Actions");
|
||||
}
|
||||
|
||||
/// A step stands for a command only when no other menu command has its label, and the command is
|
||||
/// recordable, runs with no values, has no dialog and sits directly in a top menu. The item of a
|
||||
/// submenu stands for nothing: its label alone does not say which command it is.
|
||||
#[test]
|
||||
fn steps_stand_for_commands_that_run_with_no_values() {
|
||||
assert_eq!(files::step_command("Group"), Some("object.group"));
|
||||
assert_eq!(files::step_command(" GROUP... "), Some("object.group"));
|
||||
for label in ["Move", "Move…", "", "No Such Command"] {
|
||||
assert_eq!(files::step_command(label), None, "{label}");
|
||||
}
|
||||
let submenu_items = ["Opacity", "Stroke Weight", "Fill Color", "Blending Mode", "Grid", "Radial", "Create", "Delete All"];
|
||||
let matched: Vec<&str> = submenu_items.into_iter().filter(|label| files::step_command(label).is_some()).collect();
|
||||
assert!(matched.is_empty(), "{matched:?}");
|
||||
// The menu bar's Crop Image item runs another command, which opens the crop tool.
|
||||
let not_on_the_menu_bar = ["Crop Image", "Expand Gradient Mesh", "Duplicate"];
|
||||
let matched: Vec<&str> = not_on_the_menu_bar.into_iter().filter(|label| files::step_command(label).is_some()).collect();
|
||||
assert!(matched.is_empty(), "{matched:?}");
|
||||
}
|
||||
|
||||
/// A step does not stand for a command that closes, reverts or writes documents, for Paste, for
|
||||
/// Undo or Redo, or for an add-on's command: such a step is left out, and nothing is closed on
|
||||
/// playback.
|
||||
#[test]
|
||||
fn steps_do_not_stand_for_file_commands_paste_undo_or_add_ons() {
|
||||
let labels = ["Close", "Close All", "Revert", "Export for Screens", "Paste", "Undo", "Redo", "Close Path", "Save a Timestamped Copy"];
|
||||
let matched: Vec<&str> = labels.into_iter().filter(|label| files::step_command(label).is_some()).collect();
|
||||
assert!(matched.is_empty(), "{matched:?}");
|
||||
let mut app = app_with_sets();
|
||||
let r = app.run(LOAD_SET, json!({ "data": aia_text("Closes", &[("A", &[("Close", true), ("Group", true)])]) })).unwrap();
|
||||
assert_eq!(r["leftOut"], 1);
|
||||
assert_eq!(app.ui.action_sets[3].actions[0].steps, [("object.group".to_string(), json!({}))]);
|
||||
}
|
||||
|
||||
/// A set loaded from another app's file saves to VectorCraft's own file with its excluded steps,
|
||||
/// and loads back the same.
|
||||
#[test]
|
||||
fn another_apps_set_saves_and_loads_back_with_its_excluded_steps() {
|
||||
let mut app = app_with_sets();
|
||||
let text = aia_text("Tidy", &[("A", &[("Group", true), ("Ungroup", false)])]);
|
||||
app.run(LOAD_SET, json!({ "data": text })).unwrap();
|
||||
let saved = files::write_set(&app.ui.action_sets[3]);
|
||||
let r = app.run(LOAD_SET, json!({ "data": saved })).unwrap();
|
||||
assert_eq!(r, json!({"set": 4, "name": "Tidy 2", "actions": 1}));
|
||||
assert_eq!(app.ui.action_sets[4].actions[0].excluded_steps, [1].into());
|
||||
}
|
||||
|
||||
/// Save Actions… does not write a set that Load Actions… would refuse: more than 1,000 actions.
|
||||
#[test]
|
||||
fn save_actions_does_not_write_a_set_load_actions_refuses() {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
//! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay
|
||||
//! excluded steps, and sets with stops use version 3 so older builds refuse them rather than fail
|
||||
//! at a stop. Other sets use version 1, and earlier version 1 and 2 files still work.
|
||||
//! Load Actions… also reads the action sets another app saves as text (`.aia`, through
|
||||
//! `actionSet.read`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -34,6 +36,9 @@ const MAX_SET_ACTIONS: usize = 1000;
|
||||
/// The most steps [`read_set`] reads in one action.
|
||||
const MAX_ACTION_STEPS: usize = 10_000;
|
||||
|
||||
/// The most left-out steps that Load Actions… lists for an action set another app saved.
|
||||
const MAX_SKIPPED: usize = 1000;
|
||||
|
||||
/// An action set file: a header, the set's name and its actions.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SetFile {
|
||||
@@ -201,12 +206,158 @@ pub(crate) fn can_load(app: &VectorcraftApp) -> bool {
|
||||
crate::picks::can(app, &crate::picks::PickRequest::Open(crate::FilePick::default()))
|
||||
}
|
||||
|
||||
/// The bytes start with `/version`, as the action sets another app saves as text (`.aia`) do.
|
||||
fn is_aia(bytes: &[u8]) -> bool {
|
||||
bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes).trim_ascii_start().starts_with(b"/version")
|
||||
}
|
||||
|
||||
/// `label` in any case, without a final `…` or `...`.
|
||||
fn bare_label(label: &str) -> String {
|
||||
label.trim().trim_end_matches("...").trim_end_matches('…').trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Whether a step of another app's action set may stand for command `id`. `command.batch` runs a
|
||||
/// step without the app's handling of it, so a file or document command would close, revert or write
|
||||
/// documents without asking, and a Paste would skip the view's center and the swatch conflict
|
||||
/// question. Undo and Redo would act on the action's own undo step, and an add-on command with a
|
||||
/// label a file also uses is not what the file recorded.
|
||||
fn may_stand_for(id: &str) -> bool {
|
||||
!(id.starts_with("file.")
|
||||
|| id.starts_with("document.")
|
||||
|| id.starts_with("addon.")
|
||||
|| id.starts_with("edit.paste")
|
||||
|| matches!(id, "edit.undo" | "edit.redo"))
|
||||
}
|
||||
|
||||
/// The name of a step of `actionSet.read`'s answer, and whether the step can stand for a command. A
|
||||
/// step goes by its label. Only a step with a label and no recorded values can stand for a command;
|
||||
/// a step recorded with values, a recorded menu item included, is left out. A step with no label is
|
||||
/// listed by its name.
|
||||
fn step_name(step: &Value) -> (String, bool) {
|
||||
let label = step.get("label").and_then(Value::as_str);
|
||||
let params = step.get("params").and_then(Value::as_u64).unwrap_or_default();
|
||||
match label {
|
||||
Some(label) => (label.to_string(), params == 0),
|
||||
None => (step.get("name").and_then(Value::as_str).unwrap_or_default().to_string(), false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands a step of another app's action set can stand for, by label, in English and in the
|
||||
/// interface language ([`bare_label`]). A label stands for a command when no other menu command has
|
||||
/// it, and the command is recordable, is run by a menu bar item with no params
|
||||
/// ([`runs_with_no_values`](crate::shortcut_editor::runs_with_no_values)), has no `…` in its label,
|
||||
/// sits directly in a top menu and is one [`may_stand_for`] allows. The
|
||||
/// label of a submenu's item does not say which command it is: Select › Same › Opacity is "Opacity".
|
||||
fn step_commands() -> std::collections::HashMap<String, Option<&'static str>> {
|
||||
let mut by_label = std::collections::HashMap::new();
|
||||
for c in vectorcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty()) {
|
||||
let can_stand =
|
||||
may_stand_for(c.id) && c.journal && c.menu.len() == 1 && !c.label.contains('…') && crate::shortcut_editor::runs_with_no_values(c);
|
||||
let mut labels = vec![bare_label(c.label), bare_label(crate::i18n::t_id(c.id, c.label))];
|
||||
labels.dedup();
|
||||
for label in labels.into_iter().filter(|l| !l.is_empty()) {
|
||||
by_label
|
||||
.entry(label)
|
||||
.and_modify(|id: &mut Option<&'static str>| *id = id.filter(|id| can_stand && *id == c.id))
|
||||
.or_insert(can_stand.then_some(c.id));
|
||||
}
|
||||
}
|
||||
by_label
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
step_commands().get(&bare_label(label)).copied().flatten()
|
||||
}
|
||||
|
||||
/// What Load Actions… made of an action set another app saved as text.
|
||||
struct AiaLoad {
|
||||
set: ActionSet,
|
||||
/// The first [`MAX_SKIPPED`] steps left out: `[{action, step}]`.
|
||||
skipped: Vec<Value>,
|
||||
/// How many steps were left out.
|
||||
left_out: usize,
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
fn aia_set(read: &Value, name: &str) -> AiaLoad {
|
||||
let (mut skipped, mut left_out) = (vec![], 0usize);
|
||||
let commands = step_commands();
|
||||
let text = |v: &Value, key: &str| v.get(key).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let actions = read
|
||||
.get("actions")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
let action = Some(text(a, "name")).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Action {}", i + 1));
|
||||
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 {
|
||||
left_out += 1;
|
||||
if skipped.len() < MAX_SKIPPED {
|
||||
skipped.push(json!({ "action": action, "step": name }));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if s.get("on").and_then(Value::as_bool) == Some(false) {
|
||||
excluded_steps.insert(steps.len());
|
||||
}
|
||||
steps.push((id.to_string(), json!({})));
|
||||
}
|
||||
Action { excluded_steps, ..Action::new(action, steps) }
|
||||
})
|
||||
.collect();
|
||||
let set = text(read, "set");
|
||||
AiaLoad { set: ActionSet::new(if set.trim().is_empty() { name.to_string() } else { set }, actions), skipped, left_out }
|
||||
}
|
||||
|
||||
/// [`load_set`] of an action set another app saved as text, which `actionSet.read` reads with
|
||||
/// `params`.
|
||||
fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||||
// Text that starts `/version` and no number is not an action set either.
|
||||
let read = app.run("actionSet.read", params).map_err(|_| "not an action set file")?;
|
||||
// A set read only in part would play actions that differ from the file's.
|
||||
if read.get("truncated").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return Err("the action set file can't be read in full".into());
|
||||
}
|
||||
let AiaLoad { mut set, skipped, left_out } = aia_set(&read, "Actions");
|
||||
set.name = free_set_name(&app.ui.action_sets, &set.name);
|
||||
let (name, actions) = (set.name.clone(), set.actions.len());
|
||||
app.ui.action_sets.push(set);
|
||||
if left_out > 0 {
|
||||
app.status(crate::i18n::msg_n(
|
||||
left_out as u64,
|
||||
"Loaded “{name}”: {n} step was left out",
|
||||
"Loaded “{name}”: {n} steps were left out",
|
||||
&[("name", &name)],
|
||||
));
|
||||
}
|
||||
Ok(json!({
|
||||
"set": app.ui.action_sets.len() - 1, "name": name, "actions": actions,
|
||||
"skipped": skipped, "leftOut": left_out,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `ui.actionSet.load {path? | data?}`: reads the action set file at `path` (else a picked one), or
|
||||
/// the file's text `data`, and adds its set after the others, with its actions. A set with the
|
||||
/// same name as one in the panel gets the next free number (`Print 2`). Loaded actions keep their
|
||||
/// function keys. The current set and the other sets stay as they are →
|
||||
/// `{set: index, name, actions: n}`. Where the host can read part of a file (the desktop), a file
|
||||
/// larger than 8 MiB is refused after its first 8 MiB and one byte are read.
|
||||
///
|
||||
/// 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
|
||||
/// 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 no set is added.
|
||||
pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let (text, stem) = match p.get("data") {
|
||||
Some(Value::String(data)) => {
|
||||
@@ -214,6 +365,9 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if data.len() > MAX_SET_FILE {
|
||||
return Err("the action set file is larger than 8 MiB".into());
|
||||
}
|
||||
if is_aia(data.as_bytes()) {
|
||||
return load_aia(app, json!({ "data": data }));
|
||||
}
|
||||
(data.clone(), String::new())
|
||||
}
|
||||
Some(_) => return Err("not an action set file".into()),
|
||||
@@ -221,7 +375,8 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => path.to_string(),
|
||||
None => {
|
||||
let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Action sets", &[ACTIONS_EXT])], ..Default::default() };
|
||||
let exts = [&[ACTIONS_EXT], vectorcraft_engine::cmd::actionset::EXTS].concat();
|
||||
let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Action sets", &exts)], ..Default::default() };
|
||||
crate::picks::open(app, &pick).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
@@ -234,6 +389,9 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if bytes.len() > MAX_SET_FILE {
|
||||
return Err("the action set file is larger than 8 MiB".into());
|
||||
}
|
||||
if is_aia(&bytes) {
|
||||
return load_aia(app, json!({ "name": path, "dataBase64": vectorcraft_format::base64_encode(&bytes) }));
|
||||
}
|
||||
let stem = std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
(String::from_utf8(bytes).map_err(|_| "not an action set file")?, stem)
|
||||
}
|
||||
|
||||
@@ -539,6 +539,17 @@ fn needs_a_value(key: &str) -> bool {
|
||||
.contains(key)
|
||||
}
|
||||
|
||||
/// Does a menu bar item run the command with no params, so that it does what its label says and opens
|
||||
/// no dialog? A command no menu bar item runs (the Crop Image item runs `ui.cropImage`), a menu item
|
||||
/// that gives it params, a [`NEEDS_A_PARAM`] command and a field a call must give each rule it out.
|
||||
pub(crate) fn runs_with_no_values(c: &vectorcraft_engine::CommandSpec) -> bool {
|
||||
menu_bar_items().contains_key(c.id)
|
||||
&& menu_target(c.id).is_none()
|
||||
&& !NEEDS_A_PARAM.contains(&c.id)
|
||||
&& !crate::dialogs::variables::opens(c.id)
|
||||
&& !names_a_required_field(c.params)
|
||||
}
|
||||
|
||||
/// Does a params doc name a field a call must give? Its fields are the top-level parts of its
|
||||
/// first `{…}`. A field is optional when it is empty, starts with `…`, gives a default (`rows=10`)
|
||||
/// or each of its names ends with `?` (`id?|ids?: [id…]`); `{index | indices}` names a required
|
||||
|
||||
@@ -159,6 +159,28 @@ fn shortcut_sets_are_read_only_inside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// An action set another app saved (`.aia`) is read only from inside the read root, whichever way a
|
||||
/// control request names it: `actionSet.read` and Load Actions….
|
||||
#[test]
|
||||
fn action_sets_are_read_only_inside_the_read_root() {
|
||||
let (mut app, tx, base) = confined("aia");
|
||||
let set = "/version 3\n/name [ 4 4d696e65 ]\n/action-1 { /name [ 1 41 ] }\n";
|
||||
std::fs::write(base.join("in/a.aia"), set).unwrap();
|
||||
std::fs::write(base.join("outside/b.aia"), set).unwrap();
|
||||
let execute = |command: &str, path: PathBuf| json!({"command": command, "params": {"path": s(path)}});
|
||||
let r = request(&mut app, &tx, "engine.execute", execute("actionSet.read", base.join("in/a.aia")));
|
||||
assert_eq!(r["result"]["set"], "Mine", "{r}");
|
||||
let r = request(&mut app, &tx, "engine.execute", execute(crate::panels::actions::LOAD_SET, base.join("in/a.aia")));
|
||||
assert_eq!(r["result"]["name"], "Mine", "{r}");
|
||||
let sets = app.ui.action_sets.len();
|
||||
for command in ["actionSet.read", crate::panels::actions::LOAD_SET] {
|
||||
let r = request(&mut app, &tx, "engine.execute", execute(command, base.join("outside/b.aia")));
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the read root")), "{command}: {r}");
|
||||
}
|
||||
assert_eq!(app.ui.action_sets.len(), sets, "no set added");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `ui.render` (MCP's `render_preview` and `screenshot` in the app) draws the artboard pixel for
|
||||
/// pixel as `app.export` writes a PNG on white: two rectangles that meet at x 40.3 and a diagonal
|
||||
/// edge.
|
||||
|
||||
+37
-2
@@ -106,7 +106,7 @@ the suite) keep the agent to the folders you give it:
|
||||
|
||||
- Every file a command reads must lie inside a **read root**: `open_file`, `file.place`, relinking and
|
||||
updating links, the images an SVG links to, swatch library, graphic style library, symbol library, brush library and colour-profile loads, plug-ins,
|
||||
`file.newFromTemplate`, preset imports, shortcut sets (`shortcutSet.read`), the folders `text.findFontFiles` and `links.findFiles` search, the
|
||||
`file.newFromTemplate`, preset imports, shortcut sets (`shortcutSet.read`), action sets (`actionSet.read`), the folders `text.findFontFiles` and `links.findFiles` search, the
|
||||
scripts `script.check` reads and the files they include.
|
||||
- Every file a command writes must lie inside a **write root**: `save_file`, `export`, the save and
|
||||
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
|
||||
@@ -1978,7 +1978,8 @@ already walks Image Trace. Short agent entry points:
|
||||
instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than
|
||||
1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are
|
||||
refused, and no set is added. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is
|
||||
disabled where there is no open dialog (the web).
|
||||
disabled where there is no open dialog (the web). `ui.actionSet.load` also reads the action sets other apps save as
|
||||
text (`.aia`); see "Action sets from other apps".
|
||||
- Actions: `ui.action.rename {set, action, name}` renames the Actions panel's action at `action` in set `set` (0 is
|
||||
the top set) to `name` with spaces trimmed from both ends, keeps its steps, function key and color, and returns
|
||||
`{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed.
|
||||
@@ -6870,6 +6871,40 @@ the entry names and decodes the codes that VectorCraft's default shortcuts agree
|
||||
{"name":"run_command","arguments":{"command":"importMatches.set","params":{"matches":[{"kind":"name","name":"Group","target":"object.group"},{"kind":"key","name":"901","target":"G"}]}}}
|
||||
```
|
||||
|
||||
## Action sets from other apps
|
||||
|
||||
`actionSet.read {path? | data? | dataBase64?, name?}` reads an action set another app saved as text (an `.aia` file
|
||||
that starts with `/version`) without changing anything. Give exactly one of `path`, `data` and `dataBase64`. It returns
|
||||
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
|
||||
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
|
||||
(a negative count, one too large for a number and one that is not a number always differ), or when a hexadecimal name,
|
||||
label or recorded `/value` is not as many bytes as its count says. Text that does not start with `/version` and a
|
||||
number is an error.
|
||||
|
||||
`ui.actionSet.load` (the Actions panel menu's Load Actions…) reads such a file too, from `path`, a picked file or
|
||||
`data`, and adds its set after the other sets with all its actions. The set's name is the file's `/name`, else the
|
||||
file's name, else `Actions`. A step with a label and no recorded values comes in as the VectorCraft command with that
|
||||
label, in English or in the interface language, in any case and without a final `…`, when exactly one menu command has
|
||||
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),
|
||||
`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
|
||||
load.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"actionSet.read","params":{"path":"/Users/me/Prep.aia"}}}
|
||||
{"name":"run_command","arguments":{"command":"ui.actionSet.load","params":{"path":"/Users/me/Prep.aia"}}}
|
||||
```
|
||||
|
||||
## Artwork Cleanup add-on
|
||||
|
||||
Artwork Cleanup is a built-in add-on: its commands are `addon.cleanup.*`, and in the desktop app it lives in Object ›
|
||||
|
||||
@@ -635,6 +635,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document.
|
||||
- Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`.
|
||||
- Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html); `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number.
|
||||
- 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: 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 kept with the action for Button Mode, which VectorCraft does not have yet.
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
|
||||
+2
-1
@@ -366,7 +366,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|
||||
| Live trace result bounds and selection highlights exclude the retained bitmap; source-image views retain it | done | All TraceView modes and rotated result bounds regression-covered; ordinary hidden group behavior retained |
|
||||
| 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; the reference app's `.aia` files and Reset, Clear and Replace Actions remain |
|
||||
| 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: ≡ › 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). The color shows nowhere until Button Mode exists |
|
||||
| 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 |
|
||||
| Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain |
|
||||
|
||||
Reference in New Issue
Block a user