Merge pull request #2853 from dotCooCoo/feat/load-variable-library

Variables: Load Variable Library… reads a variable library's text variables and data sets
This commit is contained in:
BFlat
2026-10-22 00:28:05 +07:00
committed by GitHub
32 changed files with 750 additions and 10 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
fn nesting(xml: &str) -> usize {
pub(crate) fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
+1 -1
View File
@@ -30,7 +30,7 @@
pub(crate) mod adopt;
pub(crate) mod document;
mod docx;
pub(crate) mod docx;
mod rtf;
mod text;
+307 -5
View File
@@ -7,8 +7,9 @@
//! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name`
//! then variable names); import also reads tab-separated files, files without the
//! `name` column, and header cells that define new variables (`#` for visibility, `@` for
//! image, no prefix for text). Agents reach every command through `run_command`, like all engine
//! commands.
//! image, no prefix for text). `variable.loadLibrary` replaces the variables and data sets with the
//! text variables and data sets of a variable library, the XML file of the reference app's Save
//! Variable Library. Agents reach every command through `run_command`, like all engine commands.
use std::collections::{BTreeMap, BTreeSet};
@@ -30,9 +31,18 @@ const MAX_CSV_BYTES: usize = 1_048_576;
const MAX_CSV_ROWS: usize = 1_000;
const MAX_CSV_COLS: usize = 256;
/// Caps on a variable library (paths and MCP payloads are untrusted): its size, its XML nodes, how
/// deep its elements nest, and the entities, variables and data sets it declares.
const MAX_LIBRARY_BYTES: usize = 8 << 20;
const MAX_LIBRARY_ENTITIES: usize = 64;
const MAX_LIBRARY_NODES: u32 = 2_000_000;
const MAX_LIBRARY_NESTING: usize = 256;
const MAX_LIBRARY_VARIABLES: usize = 256;
const MAX_LIBRARY_DATASETS: usize = 1_000;
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options),
/// Make … Dynamic and an import whose header would define a variable; binding, unbinding and the
/// other dataset commands still run.
/// Make … Dynamic, an import whose header would define a variable and a Load Variable Library
/// that would delete or define one; binding, unbinding and the other dataset commands still run.
fn variables_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|st| st.doc.variables.locked) { Err("variables are locked".into()) } else { Ok(()) }
@@ -891,6 +901,289 @@ fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
apply_dataset(s, &name)
}
/// Bytes of a variable library from `path`, `xml` or `dataBase64`.
fn library_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
let bytes = match (str_param(p, "path"), str_param(p, "xml"), str_param(p, "dataBase64")) {
// One byte past the cap is enough to refuse the file below.
(Some(path), ..) => read_file_head(&absolute_path(path), MAX_LIBRARY_BYTES + 1)?,
(None, Some(xml), _) => xml.as_bytes().to_vec(),
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?,
_ => return Err(bad(cmd, "give `path`, `xml` or `dataBase64`")),
};
if bytes.len() > MAX_LIBRARY_BYTES {
return Err(bad(cmd, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes")));
}
Ok(bytes)
}
/// `xml` without the document type declaration of its prolog, with each reference to a general
/// entity that the declaration defines replaced by the entity's value, its quotes and `>` escaped,
/// outside CDATA sections, comments and processing instructions. The declaration may hold an
/// external identifier and an internal subset of white space, comments, processing instructions and
/// up to [`MAX_LIBRARY_ENTITIES`] entity declarations, each with a name of at most [`MAX_NAME`]
/// bytes and a quoted value that holds no `<`, `&` or `%`. Anything else in it is refused, and so is
/// a text that the replacements make longer than [`MAX_LIBRARY_BYTES`]. The XML parser then refuses
/// any other document type declaration, so [`nesting`](super::place::docx::nesting) counts the
/// elements the parser reads.
fn without_doctype(xml: &str, cmd: &str) -> Result<String> {
let b = xml.as_bytes();
let past = |from: usize, end: &str| xml.get(from..).and_then(|rest| rest.find(end)).map(|at| from + at + end.len());
let spaces = |from: usize| from + b.get(from..).map_or(0, |rest| rest.iter().take_while(|c| c.is_ascii_whitespace()).count());
let at = |i: usize, s: &str| xml.get(i..).is_some_and(|rest| rest.starts_with(s));
let refused = || bad(cmd, "the variable library's document type declaration holds something other than entities of plain text");
// The prolog: an XML declaration, white space, comments and processing instructions.
let mut i = 0;
loop {
i = spaces(i);
if at(i, "<?") {
i = past(i + 2, "?>").ok_or_else(refused)?;
} else if at(i, "<!--") {
i = past(i + 4, "-->").ok_or_else(refused)?;
} else {
break;
}
}
if !at(i, "<!DOCTYPE") {
return Ok(xml.to_string());
}
let start = i;
// A quoted literal starting at `i` → the index past it and its text.
let literal = |i: usize| -> Option<(usize, &str)> {
let quote = *b.get(i).filter(|c| matches!(c, b'"' | b'\''))?;
let len = b.get(i + 1..)?.iter().position(|c| *c == quote)?;
Some((i + len + 2, xml.get(i + 1..i + 1 + len)?))
};
// `<!DOCTYPE` S Name (S ExternalID)? S? ('[' intSubset ']' S?)? '>'
i = spaces(i + "<!DOCTYPE".len());
i += b.get(i..).map_or(0, |rest| rest.iter().take_while(|c| !c.is_ascii_whitespace() && !matches!(c, b'[' | b'>')).count());
i = spaces(i);
for (id, literals) in [("SYSTEM", 1), ("PUBLIC", 2)] {
if at(i, id) {
i += id.len();
for _ in 0..literals {
i = literal(spaces(i)).ok_or_else(refused)?.0;
}
i = spaces(i);
}
}
let mut entities: Vec<(&str, String)> = Vec::new();
if at(i, "[") {
i += 1;
loop {
i = spaces(i);
if at(i, "]") {
i = spaces(i + 1);
break;
} else if at(i, "<!--") {
i = past(i + 4, "-->").ok_or_else(refused)?;
} else if at(i, "<?") {
i = past(i + 2, "?>").ok_or_else(refused)?;
} else if at(i, "<!ENTITY") {
i = spaces(i + "<!ENTITY".len());
let len =
b.get(i..).map_or(0, |rest| rest.iter().take_while(|c| !c.is_ascii_whitespace() && !matches!(c, b'"' | b'\'' | b'>')).count());
let name = xml.get(i..i + len).filter(|n| !n.is_empty() && n.len() <= MAX_NAME && !n.starts_with('%')).ok_or_else(refused)?;
let (end, value) = literal(spaces(i + len)).ok_or_else(refused)?;
if value.contains(['<', '&', '%']) {
return Err(refused());
}
i = spaces(end);
if !at(i, ">") {
return Err(refused());
}
i += 1;
if entities.len() == MAX_LIBRARY_ENTITIES {
return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_ENTITIES} entities")));
}
entities.push((name, value.replace('"', "&quot;").replace('\'', "&apos;").replace('>', "&gt;")));
} else {
return Err(refused());
}
}
}
if !at(i, ">") {
return Err(refused());
}
let mut out = String::with_capacity(xml.len());
out.push_str(xml.get(..start).unwrap_or_default());
// The rest, copied with the references replaced outside CDATA sections, comments and
// processing instructions, where the parser reads none.
let mut i = i + 1;
while let Some(c) = xml.get(i..).and_then(|rest| rest.chars().next()) {
let verbatim = [("<![CDATA[", "]]>"), ("<!--", "-->"), ("<?", "?>")].into_iter().find(|(open, _)| at(i, open));
if let Some((open, close)) = verbatim {
let end = past(i + open.len(), close).unwrap_or(xml.len());
out.push_str(xml.get(i..end).unwrap_or_default());
i = end;
continue;
}
// Entity names are at most `MAX_NAME` bytes long, so the `;` is looked for that far.
let entity = if c == '&' {
let name = xml.get(i + 1..).and_then(|rest| rest.get(..rest.bytes().take(MAX_NAME + 1).position(|b| b == b';')?));
name.and_then(|name| entities.iter().find(|(n, _)| *n == name))
} else {
None
};
match entity {
Some((name, value)) => {
out.push_str(value);
i += name.len() + 2;
if out.len() > MAX_LIBRARY_BYTES {
return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes")));
}
}
None => {
out.push(c);
i += c.len_utf8();
}
}
}
if out.len() > MAX_LIBRARY_BYTES {
return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes")));
}
Ok(out)
}
/// A variable library as Load Variable Library reads it: the names of its text variables in the
/// order it declares them, the name and `trait` of each variable of another kind, and its data
/// sets with their text values.
struct Library {
text: Vec<String>,
other: Vec<(String, String)>,
datasets: Vec<DataSet>,
}
/// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each
/// with a `variables` element of `variable` elements (`varName`, `trait`) and a `sampleDataSets`
/// element of `sampleDataSet` elements (`dataSetName`). Elements are matched by their local names.
/// A variable whose `trait` is `textcontent` is a text variable. In a data set, an element named
/// after a text variable gives its value: the text of its `p` elements, one paragraph each, and
/// none when it has no `p` element. Values for variables of other kinds are left out. A variable
/// or data set declared twice, a value for a variable the library doesn't declare, and a library
/// past the caps are refused. Entities that its document type declaration defines are read as
/// [`without_doctype`] says.
fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
let xml = without_doctype(xml, cmd)?;
// roxmltree parses nested elements recursively.
if super::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING {
return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep")));
}
let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() };
let doc =
roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?;
let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name;
let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else {
return Err(bad(cmd, "the file holds no `variableSets` element"));
};
let sets: Vec<roxmltree::Node> = root.children().filter(|n| is(n, "variableSet")).collect();
let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new());
for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) {
let name = named(var.attribute("varName"), "varName", cmd)?;
if !declared.insert(name.clone()) {
return Err(bad(cmd, format!("variable `{name}` is declared twice")));
}
if declared.len() > MAX_LIBRARY_VARIABLES {
return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables")));
}
match var.attribute("trait") {
Some("textcontent") => text.push(name),
Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())),
None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))),
}
}
if declared.is_empty() {
return Err(bad(cmd, "the variable library declares no variables"));
}
let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect();
let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new());
for row in
sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet"))
{
let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?;
if !seen.insert(name.clone()) {
return Err(bad(cmd, format!("data set `{name}` is declared twice")));
}
if seen.len() > MAX_LIBRARY_DATASETS {
return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets")));
}
let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new());
for el in row.children().filter(roxmltree::Node::is_element) {
let var = el.tag_name().name();
if !declared.contains(var) {
return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare")));
}
if !held.insert(var) {
return Err(bad(cmd, format!("data set `{name}` holds two values for `{var}`")));
}
if !text_names.contains(var) {
continue;
}
let paras: Vec<String> = el
.children()
.filter(|n| is(n, "p"))
.map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect())
.collect();
if !paras.is_empty() {
values.insert(var.to_string(), DataValue::Text(paras.join("\n")));
}
}
datasets.push(DataSet { name, values });
}
Ok(Library { text, other, datasets })
}
/// Load Variable Library: replace the document's variables and data sets with a variable library's
/// ([`parse_library`]) in one undo step. Every variable of the document is deleted with its bindings
/// and values, and every data set is deleted. The library's text variables are then defined,
/// unbound, and its data sets are added. The library's variables of other kinds are left out, named
/// in `skipped` and in a warning.
fn load_library(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.loadLibrary";
let bytes = library_bytes(p, C)?;
let xml = std::str::from_utf8(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).map_err(|_| bad(C, "the variable library is not UTF-8"))?;
let lib = parse_library(xml, C)?;
let vars = &s.doc()?.doc.variables;
let deleted: Vec<String> = vars.variables.iter().map(|v| v.name.clone()).collect();
let deleted_sets: Vec<String> = vars.datasets.iter().map(|d| d.name.clone()).collect();
// Lock Variables refuses New Variable and Delete Variable, so the load is refused when it would
// delete or define a variable.
if !deleted.is_empty() || !lib.text.is_empty() {
variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?;
}
let added: Vec<Variable> = lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect();
let names: Vec<String> = lib.datasets.iter().map(|d| d.name.clone()).collect();
s.edit("Load Variable Library", |d, _| {
d.variables.prune(&deleted);
d.variables.variables.extend(added);
d.variables.datasets = lib.datasets;
d.variables.active_dataset = None;
Ok(())
})?;
// A highlight of a variable that is gone highlights nothing.
let st = s.doc_mut()?;
if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) {
st.variables_highlight = None;
}
st.revision += 1;
let skipped: Vec<Value> = lib.other.iter().map(|(name, t)| json!({"name": name, "trait": t})).collect();
let mut out = json!({
"variables": lib.text, "deleted": deleted, "deletedDataSets": deleted_sets, "names": names, "skipped": skipped,
});
// Each `trait` once, in the order the library first uses it, with how many variables have it.
let mut kinds: Vec<(&str, usize)> = Vec::new();
for (_, t) in &lib.other {
match kinds.iter_mut().find(|(k, _)| k == t) {
Some((_, n)) => *n += 1,
None => kinds.push((t, 1)),
}
}
if !kinds.is_empty() {
let kinds = kinds.iter().map(|(k, n)| format!("{k} ({n})")).collect::<Vec<_>>().join(", ");
out["warning"] = json!(format!("variables that aren't text were left out: {kinds}"));
}
Ok(out)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -980,7 +1273,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Lock Variables",
["Window", "Variables"],
None,
"{locked?: bool} lock or unlock the variables (default: toggle); saved with the document; a change is one undo step. While locked, variable.define, variable.delete, variable.rename and variable.make…Dynamic are refused; binding, unbinding and the dataset commands still run → {locked}",
"{locked?: bool} lock or unlock the variables (default: toggle); saved with the document; a change is one undo step. While locked, variable.define, variable.delete, variable.rename, variable.make…Dynamic and a variable.loadLibrary that would delete or define a variable are refused; binding, unbinding and the dataset commands still run → {locked}",
has_doc,
lock
),
@@ -1075,5 +1368,14 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
dataset_export
),
cmd!(
"variable.loadLibrary",
"Load Variable Library…",
["Window", "Variables"],
None,
"{path? | xml? | dataBase64?} replace the document's variables and data sets with a variable library's (XML: `variableSets` › `variableSet` › `variables` of `variable` elements with `varName` and `trait`, and `sampleDataSets` of `sampleDataSet` elements with `dataSetName`) in one undo step. Every variable of the document is deleted with its bindings and values, and every data set is deleted; then variables whose `trait` is `textcontent` are defined as text variables, unbound, and each data set takes a value for a variable from the `p` elements of the element named after it, one paragraph each. Variables of other kinds are left out and named in `skipped` and `warning`. Refused while the variables are locked when it would delete or define a variable → {variables, deleted, deletedDataSets, names, skipped, warning?}",
has_doc,
load_library
),
]
}
+257
View File
@@ -1346,6 +1346,263 @@ fn datasets_csv_round_trips_and_rejects_bad_input() {
s.execute("edit.undo", &json!({})).unwrap();
}
/// A data set of [`library`]: its name, and each variable's name with its paragraphs.
type LibrarySet<'a> = (&'a str, &'a [(&'a str, &'a [&'a str])]);
/// A variable library written from its public grammar: a document type declaration with the
/// namespace entities, `variableSets` › `variableSet` › `variables` with one `variable` element per
/// `(varName, trait)`, then `sampleDataSets` with one `sampleDataSet` per data set, holding an
/// element per variable with a `p` element per paragraph.
fn library(vars: &[(&str, &str)], sets: &[LibrarySet]) -> String {
let mut x = String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\n\
\t<!ENTITY ns_vars \"http://ns.adobe.com/Variables/1.0/\">\n\
\t<!ENTITY ns_custom \"http://ns.adobe.com/GenericCustomNamespace/1.0/\">\n\
\t<!ENTITY ns_flows \"http://ns.adobe.com/Flows/1.0/\">\n\
]>\n<svg>\n<variableSets xmlns=\"&ns_vars;\">\n<variableSet varSetName=\"binding1\" locked=\"none\">\n<variables>\n",
);
for (name, kind) in vars {
x += &format!("<variable varName=\"{name}\" trait=\"{kind}\" category=\"&ns_flows;\"></variable>\n");
}
x += "</variables>\n<v:sampleDataSets xmlns:v=\"&ns_vars;\" xmlns=\"&ns_custom;\">\n";
for (set, values) in sets {
x += &format!("<v:sampleDataSet dataSetName=\"{set}\">\n");
for (var, paras) in *values {
let paras: String = paras.iter().map(|p| format!("<p>{p}</p>")).collect();
x += &format!("<{var}>{paras}</{var}>\n");
}
x += "</v:sampleDataSet>\n";
}
x + "</v:sampleDataSets>\n</variableSet>\n</variableSets>\n</svg>\n"
}
/// Load Variable Library defines the library's text variables, unbound, and adds its data sets,
/// in one undo step. A value is the text of the `p` elements, one paragraph each; an element
/// without a `p` gives no value. A file, and base64 with a UTF-8 byte-order mark, load the same.
#[test]
fn a_variable_library_defines_text_variables_and_data_sets() {
let mut s = session();
let t = text(&mut s, "Alice");
let xml = library(
&[("Name", "textcontent"), ("Note", "textcontent")],
&[("Row 1", &[("Name", &["Bravo"]), ("Note", &["One", "Two &amp; three"])]), ("Row 2", &[("Name", &["Charlie"]), ("Note", &[])])],
);
let r = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap();
assert_eq!(r["variables"], json!(["Name", "Note"]));
assert_eq!((r["deleted"].clone(), r["deletedDataSets"].clone()), (json!([]), json!([])));
assert_eq!(r["names"], json!(["Row 1", "Row 2"]));
assert_eq!((r["skipped"].clone(), r.get("warning")), (json!([]), None));
let listed = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(listed["variables"], json!([{"name": "Name", "kind": "text", "bindings": 0}, {"name": "Note", "kind": "text", "bindings": 0}]));
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "Bravo"}, "Note": {"text": "One\nTwo & three"}}));
assert_eq!(sets["datasets"][1]["values"], json!({"Name": {"text": "Charlie"}}));
assert_eq!(sets["active"], Value::Null);
assert_eq!(plain(&s, t), "Alice", "loading binds nothing and applies nothing");
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("dataset.select", &json!({"name": "Row 2"})).unwrap();
assert_eq!(plain(&s, t), "Charlie");
// One undo step each: the select, the bind, then the load.
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
let vars = &s.doc().unwrap().doc.variables;
assert!(vars.variables.is_empty() && vars.datasets.is_empty());
let dir = Folder::new("vars-library");
let path = dir.file("library.xml");
write(&path, xml.as_bytes());
let from_file = s.execute("variable.loadLibrary", &json!({"path": path})).unwrap();
assert_eq!(from_file["names"], json!(["Row 1", "Row 2"]));
let bom = [b"\xEF\xBB\xBF".as_slice(), xml.as_bytes()].concat();
let from_base64 = s.execute("variable.loadLibrary", &json!({"dataBase64": vectorcraft_format::base64_encode(&bom)})).unwrap();
assert_eq!((from_base64["deleted"].clone(), from_base64["deletedDataSets"].clone()), (json!(["Name", "Note"]), json!(["Row 1", "Row 2"])));
assert_eq!(s.doc().unwrap().doc.variables.datasets.len(), 2);
}
/// Load Variable Library replaces the document's variables and data sets: every variable goes with
/// its bindings and values, a text variable of the same name included, and every data set goes,
/// the active one included. The highlight of a variable that went highlights nothing. Undo brings
/// back the variables, bindings, data sets and active data set as they were.
#[test]
fn a_variable_library_replaces_every_variable_and_data_set() {
let mut s = session();
let t = text(&mut s, "Alice");
let r = rect(&mut s);
for (name, kind, id) in [("Name", "text", t), ("Title", "visibility", r), ("Kept", "visibility", r)] {
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap();
}
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Old", "Title": true}})).unwrap();
s.execute("dataset.new", &json!({"name": "Other", "values": {"Name": "Stays", "Title": false, "Kept": true}})).unwrap();
s.execute("dataset.select", &json!({"name": "Other"})).unwrap();
s.execute("variable.highlight", &json!({"variable": "Kept"})).unwrap();
let before = s.doc().unwrap().doc.variables.clone();
let xml = library(&[("Name", "textcontent"), ("Title", "textcontent")], &[("A", &[("Name", &["New"]), ("Title", &["Heading"])])]);
let loaded = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap();
assert_eq!(loaded["deleted"], json!(["Name", "Title", "Kept"]));
assert_eq!((loaded["deletedDataSets"].clone(), loaded["names"].clone()), (json!(["A", "Other"]), json!(["A"])));
let listed = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(listed["variables"], json!([{"name": "Name", "kind": "text", "bindings": 0}, {"name": "Title", "kind": "text", "bindings": 0}]));
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1));
assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "New"}, "Title": {"text": "Heading"}}));
assert_eq!(sets["active"], Value::Null);
assert!(s.doc().unwrap().doc.variables.bindings.is_empty(), "the bindings went with the variables");
assert!(s.doc().unwrap().variables_highlight.is_none(), "the highlighted variable went");
assert_eq!(plain(&s, t), "Stays", "loading applies nothing");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.variables, before, "one undo step brings everything back");
}
/// A library that is refused leaves the document's variables and data sets as they were.
#[test]
fn a_refused_variable_library_deletes_nothing() {
let mut s = session();
let t = text(&mut s, "Alice");
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Old"}})).unwrap();
let before = s.doc().unwrap().doc.variables.clone();
let dup = library(&[("Name", "textcontent")], &[("B", &[]), ("B", &[])]);
assert!(s.execute("variable.loadLibrary", &json!({"xml": dup})).is_err());
assert_eq!(s.doc().unwrap().doc.variables, before);
}
/// A library's variables of other kinds are left out with their values, named in `skipped` and
/// in a warning that gives each kind once with its count; its text variables load.
#[test]
fn a_variable_library_leaves_out_variables_that_are_not_text() {
let mut s = session();
let xml = library(
&[("Shown", "kindB"), ("Name", "textcontent"), ("Photo", "kindC"), ("Other", "kindB")],
&[("A", &[("Shown", &["true"]), ("Name", &["Bravo"]), ("Photo", &["a.png"])])],
);
let r = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap();
assert_eq!(r["variables"], json!(["Name"]));
assert_eq!(r["skipped"], json!([{"name": "Shown", "trait": "kindB"}, {"name": "Photo", "trait": "kindC"}, {"name": "Other", "trait": "kindB"}]));
assert_eq!(r["warning"], "variables that aren't text were left out: kindB (2), kindC (1)");
let vars = &s.doc().unwrap().doc.variables;
assert_eq!(vars.variables.len(), 1);
assert_eq!(vars.dataset("A").map(|d| d.values.len()), Some(1));
}
/// A library that isn't one, or that breaks its grammar or the caps, is refused, and the document
/// is left as it was.
#[test]
fn malformed_variable_libraries_are_refused() {
let mut s = session();
let ok = library(&[("Name", "textcontent")], &[("A", &[("Name", &["x"])])]);
let deep = format!("{}<variableSets/>{}", "<a>".repeat(300), "</a>".repeat(300));
let markup = "<!DOCTYPE svg [<!ENTITY deep \"<a><a></a></a>\">]><svg>&deep;<variableSets/></svg>".to_string();
// A document type declaration in a comment before the real one, and markup reached through
// another entity.
let hidden = format!("<!-- <!DOCTYPE svg> -->{markup}");
let nested = "<!DOCTYPE svg [<!ENTITY a \"&b;&b;\"><!ENTITY b 'x'>]><svg>&a;<variableSets/></svg>".to_string();
let parameter = "<!DOCTYPE svg [<!ENTITY % p \"x\">]><svg><variableSets/></svg>".to_string();
let element = "<!DOCTYPE svg [<!ELEMENT svg ANY>]><svg><variableSets/></svg>".to_string();
// A `>` and a comment opener inside a quoted system identifier, before elements nested too deep.
let quoted = format!("<!DOCTYPE svg SYSTEM \"x><!--\"><svg>{}<variableSets/>{}</svg>", "<a>".repeat(300), "</a>".repeat(300));
let entities: String = (0..65).map(|i| format!("<!ENTITY e{i} \"x\">")).collect();
let many_entities = format!("<!DOCTYPE svg [{entities}]><svg><variableSets/></svg>");
// 1 KiB of text referenced 9,000 times.
let expanding = format!("<!DOCTYPE svg [<!ENTITY big \"{}\">]><svg>{}<variableSets/></svg>", "x".repeat(1024), "&big;".repeat(9000));
// References that expand to just under the cap, then a comment that takes the text past it.
let then_literal = format!(
"<!DOCTYPE svg [<!ENTITY big \"{}\">]><svg>{}<!--{}--><variableSets/></svg>",
"x".repeat(1024),
"&big;".repeat(8000),
" ".repeat(300_000)
);
let cases = [
("not well-formed XML", "<variableSets><variableSet>".to_string()),
("no `variableSets` element", "<svg><variables/></svg>".to_string()),
("declares no variables", "<variableSets><variableSet><variables/></variableSet></variableSets>".to_string()),
("is declared twice", library(&[("Name", "textcontent"), ("Name", "textcontent")], &[])),
("has no `trait`", ok.replace(" trait=\"textcontent\"", "")),
("missing `varName`", ok.replace("varName=\"Name\"", "varName=\" \"")),
("missing `dataSetName`", ok.replace(" dataSetName=\"A\"", "")),
("data set `A` is declared twice", library(&[("Name", "textcontent")], &[("A", &[]), ("A", &[])])),
("which the library doesn't declare", library(&[("Name", "textcontent")], &[("A", &[("Nmae", &["x"])])])),
("holds two values for `Name`", library(&[("Name", "textcontent")], &[("A", &[("Name", &["x"]), ("Name", &["y"])])])),
("nests elements more than 256 deep", deep),
("something other than entities of plain text", markup),
("something other than entities of plain text", hidden),
("something other than entities of plain text", nested),
("something other than entities of plain text", parameter),
("something other than entities of plain text", element),
("nests elements more than 256 deep", quoted),
("more than 64 entities", many_entities),
("entities expand to more than", expanding),
("entities expand to more than", then_literal),
("larger than", format!("{ok}<!--{}-->", " ".repeat(8 << 20))),
];
for (why, xml) in cases {
let e = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap_err().to_string();
assert!(e.contains(why), "{why}: {e}");
}
let names: Vec<String> = (0..257).map(|i| format!("V{i}")).collect();
let many: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "textcontent")).collect();
let e = s.execute("variable.loadLibrary", &json!({"xml": library(&many, &[])})).unwrap_err().to_string();
assert!(e.contains("more than 256 variables"), "{e}");
// A Latin-1 é in a comment.
let latin1 = [ok.as_bytes(), b"<!-- \xE9 -->"].concat();
let e = s.execute("variable.loadLibrary", &json!({"dataBase64": vectorcraft_format::base64_encode(&latin1)})).unwrap_err().to_string();
assert!(e.contains("not UTF-8"), "{e}");
let e = s.execute("variable.loadLibrary", &json!({})).unwrap_err().to_string();
assert!(e.contains("give `path`, `xml` or `dataBase64`"), "{e}");
let vars = &s.doc().unwrap().doc.variables;
assert!(vars.variables.is_empty() && vars.datasets.is_empty(), "a refused library loads nothing");
}
/// Entities whose values hold plain text expand to it, in attribute values too, and a reference in
/// a CDATA section stays as written.
#[test]
fn a_variable_library_expands_entities_of_plain_text() {
let mut s = session();
let xml = "<!DOCTYPE svg [<!ENTITY a \"x\"><!ENTITY b 'y \"z\"'>]><svg><variableSets><variableSet><variables>\
<variable varName=\"Name\" trait=\"textcontent\"/></variables><sampleDataSets><sampleDataSet dataSetName=\"&b;\">\
<Name><p>&a;-&b;-&a;<![CDATA[&a;]]></p></Name></sampleDataSet></sampleDataSets></variableSet></variableSets></svg>";
s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap();
let value = s.doc().unwrap().doc.variables.dataset("y \"z\"").and_then(|d| d.values.get("Name")).cloned();
assert_eq!(value, Some(vectorcraft_doc::DataValue::Text("x-y \"z\"-x&a;".into())));
}
/// While the variables are locked, a library that would delete or define a variable is refused, as
/// Delete Variable and New Variable are, and the document keeps its variables and data sets. That
/// covers a library whose only variable is a text variable the document already has, and one
/// without text variables loaded into a document with variables. A library without text variables
/// loads into a document without variables, replacing its data sets.
#[test]
fn locked_variables_refuse_a_library_that_deletes_or_defines_variables() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Old"}})).unwrap();
s.execute("variable.lock", &json!({"locked": true})).unwrap();
let before = s.doc().unwrap().doc.variables.clone();
for vars in [&[("Name", "textcontent")][..], &[("Shown", "other")][..]] {
let e = s.execute("variable.loadLibrary", &json!({"xml": library(vars, &[("B", &[])])})).unwrap_err();
assert!(matches!(&e, EngineError::Disabled(cmd, why) if cmd == "variable.loadLibrary" && why == "variables are locked"), "{e}");
assert_eq!(s.doc().unwrap().doc.variables, before);
}
let mut s = session();
s.execute("dataset.new", &json!({"name": "A"})).unwrap();
s.execute("variable.lock", &json!({"locked": true})).unwrap();
let e = s.execute("variable.loadLibrary", &json!({"xml": library(&[("Name", "textcontent")], &[])})).unwrap_err();
assert!(matches!(&e, EngineError::Disabled(_, why) if why == "variables are locked"), "{e}");
let r = s.execute("variable.loadLibrary", &json!({"xml": library(&[("Shown", "other")], &[("B", &[])])})).unwrap();
assert_eq!((r["deletedDataSets"].clone(), r["names"].clone()), (json!(["A"]), json!(["B"])));
let vars = &s.doc().unwrap().doc.variables;
assert!(vars.variables.is_empty() && vars.locked);
assert_eq!(vars.datasets.iter().map(|d| d.name.as_str()).collect::<Vec<_>>(), ["B"]);
}
#[test]
fn graph_variable_applies_csv_and_captures() {
let mut s = session();
+62
View File
@@ -1252,6 +1252,68 @@ proptest! {
}
}
// ---------- variable library XML files ----------
/// A variable library written from its public grammar: two text variables (`Title` is a
/// variable of [`merge_session`]'s document), a variable of another kind and two data sets.
const VARIABLE_LIBRARY: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<!DOCTYPE svg [\n<!ENTITY ns_vars \"http://ns.adobe.com/Variables/1.0/\">\n<!ENTITY ns_custom \"http://ns.adobe.com/GenericCustomNamespace/1.0/\">\n]>\n\
<svg>\n<variableSets xmlns=\"&ns_vars;\">\n<variableSet varSetName=\"binding1\" locked=\"none\">\n<variables>\n\
<variable varName=\"Title\" trait=\"textcontent\"></variable>\n<variable varName=\"Note\" trait=\"textcontent\"></variable>\n\
<variable varName=\"Show\" trait=\"other\"></variable>\n</variables>\n\
<v:sampleDataSets xmlns:v=\"&ns_vars;\" xmlns=\"&ns_custom;\">\n\
<v:sampleDataSet dataSetName=\"A\">\n<Title><p>One</p><p>Two &amp; three</p></Title>\n<Note><p>x</p></Note>\n<Show><p>true</p></Show>\n</v:sampleDataSet>\n\
<v:sampleDataSet dataSetName=\"C\">\n<Title><p>Four</p></Title>\n<Note></Note>\n</v:sampleDataSet>\n\
</v:sampleDataSets>\n</variableSet>\n</variableSets>\n</svg>\n";
/// `variable.loadLibrary` with `params` on [`merge_session`]'s document, the first three data sets
/// it read selected in turn and every data set exported; the document then renders and exports.
fn variable_library(what: &str, params: Value) -> Result<(), TestCaseError> {
survive(what, || {
let mut s = merge_session();
if let Ok(r) = no_panic(s.execute("variable.loadLibrary", &params)) {
for name in r["names"].as_array().into_iter().flatten().take(3) {
let _ = no_panic(s.execute("dataset.select", &json!({"name": name})));
}
}
let _ = no_panic(s.execute("dataset.export", &json!({})));
Some((*s.doc().ok()?.doc).clone())
})
}
proptest! {
#![proptest_config(config())]
#[test]
fn variable_library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec![
"",
"<variableSets>",
"<variableSets><variableSet><variables><variable varName=\"Title\" trait=\"textcontent\"/></variables><sampleDataSets><sampleDataSet dataSetName=\"A\">",
"<variableSets><variableSet><variables><variable varName=\"Title\" trait=\"textcontent\"/></variables><sampleDataSets><sampleDataSet dataSetName=\"A\"><Title><p>",
"<!DOCTYPE svg [<!ENTITY a \"",
])) {
variable_library("variable library garbage", json!({"xml": format!("{head}{s}")}))?;
}
#[test]
fn variable_library_bytes_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..300)) {
variable_library("variable library bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?;
}
/// The library cut short and with characters replaced, then with `nest` elements nested in one
/// another inside its `svg` element.
#[test]
fn mutated_variable_libraries_never_panic(
cut in 0usize..1_200,
edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['<', '>', '"', '/', '=', '&', ';', '!', '[', ']', 'p', 'x', ' '])), 0..10),
nest in prop::sample::select(vec![0usize, 0, 0, 240, 100_000]),
) {
let xml = mutate_text(VARIABLE_LIBRARY, cut, &edits);
let xml = xml.replacen("<svg>", &format!("<svg>{}", "<a>".repeat(nest)), 1).replacen("</svg>", &format!("{}</svg>", "</a>".repeat(nest)), 1);
variable_library("mutated variable library", json!({"xml": xml}))?;
}
}
// ---------- shortcut sets ----------
/// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an
+2
View File
@@ -4202,6 +4202,7 @@ axis Both И двете
@msg unknown workspace (one of {_1}) непознато работно пространство (едно от {_1})
@msg unsupported: {0} не се поддържа: {0}
@msg variables are locked променливите са заключени
@msg variables that aren't text were left out: {kinds} променливите, които не са текстови, бяха пропуснати: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файловете от версия {0} не могат да бъдат компресирани: само версия {COMPRESSED_SINCE} и по-нови отварят компресирани файлове
@msg width and height are at least 1 pixel ширината и височината са най-малко 1 пиксел
@msg workspace name can't be empty името на работното пространство не може да бъде празно
@@ -5184,6 +5185,7 @@ theme Appearance Външен вид
Make Image Dynamic Динамично изображение
Import Data Sets… Импортиране на набори от данни…
Export Data Sets… Експортиране на набори от данни…
Load Variable Library… Зареждане на библиотека с променливи…
Make Graph Dynamic Динамична диаграма
Batch… Пакет…
Source Folder: Изходна папка:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4597,6 +4597,7 @@ imageTrace Outline Obrys
@msg unknown workspace (one of {_1}) neznámý pracovní prostor (jeden z {_1})
@msg unsupported: {0} nepodporováno: {0}
@msg variables are locked proměnné jsou zamčené
@msg variables that aren't text were left out: {kinds} proměnné, které nejsou textové, byly vynechány: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files soubory verze {0} nelze komprimovat: komprimované soubory otevírá až verze {COMPRESSED_SINCE} a novější
@msg width and height are at least 1 pixel šířka i výška jsou alespoň 1 obrazový bod
@msg workspace name can't be empty název pracovního prostoru nesmí být prázdný
@@ -5230,6 +5231,7 @@ mark-style Japanese Japonské
Make Image Dynamic Nastavit obraz jako dynamický
Import Data Sets… Importovat sady dat…
Export Data Sets… Exportovat sady dat…
Load Variable Library… Načíst knihovnu proměnných…
Make Graph Dynamic Nastavit graf jako dynamický
Batch… Dávka…
Source Folder: Zdrojová složka:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4969,6 +4969,8 @@ mark-style Japanese Japansk
Make Image Dynamic Gør billede dynamisk
Import Data Sets… Importér datasæt…
Export Data Sets… Eksportér datasæt…
Load Variable Library… Indlæs variabelbibliotek…
@msg variables that aren't text were left out: {kinds} variabler, der ikke er tekst, blev udeladt: {kinds}
Make Graph Dynamic Gør diagram dynamisk
Batch… Batch…
Source Folder: Kildemappe:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4228,6 +4228,7 @@ theme Appearance Erscheinungsbild
@msg unknown workspace (one of {_1}) unbekannter Arbeitsbereich (einer von {_1})
@msg unsupported: {0} nicht unterstützt: {0}
@msg variables are locked die Variablen sind gesperrt
@msg variables that aren't text were left out: {kinds} die Variablen, die kein Text sind, wurden ausgelassen: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files Dateien der Version {0} können nicht komprimiert werden: Erst Version {COMPRESSED_SINCE} und neuer öffnen komprimierte Dateien
@msg width and height are at least 1 pixel Breite und Höhe betragen mindestens 1 Pixel
@msg workspace name can't be empty der Name des Arbeitsbereichs darf nicht leer sein
@@ -4809,6 +4810,7 @@ effect Soft Weich
Make Image Dynamic Bild dynamisch machen
Import Data Sets… Datensätze importieren…
Export Data Sets… Datensätze exportieren…
Load Variable Library… Variablenbibliothek laden…
Make Graph Dynamic Diagramm dynamisch machen
Batch… Stapelverarbeitung…
Source Folder: Quellordner:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4260,6 +4260,7 @@ theme Appearance Εμφάνιση
@msg unknown workspace (one of {_1}) άγνωστος χώρος εργασίας (ένας από τους {_1})
@msg unsupported: {0} δεν υποστηρίζεται: {0}
@msg variables are locked οι μεταβλητές είναι κλειδωμένες
@msg variables that aren't text were left out: {kinds} οι μεταβλητές που δεν είναι κειμένου παραλείφθηκαν: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files τα αρχεία έκδοσης {0} δεν μπορούν να συμπιεστούν: μόνο η έκδοση {COMPRESSED_SINCE} και νεότερες ανοίγουν συμπιεσμένα αρχεία
@msg width and height are at least 1 pixel το πλάτος και το ύψος πρέπει να είναι τουλάχιστον 1 εικονοστοιχείο
@msg workspace name can't be empty το όνομα χώρου εργασίας δεν μπορεί να είναι κενό
@@ -5095,6 +5096,7 @@ effect Points Σημεία
Drawing : real size — 1:10 labels 1 unit on the page as 10. Σχέδιο : πραγματικό μέγεθος — με 1:10 η 1 μονάδα στη σελίδα εμφανίζεται ως 10.
Editable Επεξεργάσιμο
Export Data Sets… Εξαγωγή συνόλων δεδομένων…
Load Variable Library… Φόρτωση βιβλιοθήκης μεταβλητών…
Filter Φίλτρο
Filter Layers Panel Rows Φιλτράρισμα γραμμών πίνακα επιπέδων
Flatten Ισοπέδωση
Can't render this file because it contains an unexpected character in line 2031 and column 19.
+2
View File
@@ -4296,6 +4296,7 @@ axis Both Ambos
@msg unknown workspace (one of {_1}) espacio de trabajo desconocido (uno de estos: {_1})
@msg unsupported: {0} no compatible: {0}
@msg variables are locked las variables están bloqueadas
@msg variables that aren't text were left out: {kinds} se omitieron las variables que no son de texto: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files los archivos de la versión {0} no se pueden comprimir: solo la versión {COMPRESSED_SINCE} y posteriores abren archivos comprimidos
@msg width and height are at least 1 pixel el ancho y el alto son de al menos 1 píxel
@msg workspace name can't be empty el nombre del espacio de trabajo no puede estar vacío
@@ -5172,6 +5173,7 @@ mark-style Japanese Japonesas
Make Image Dynamic Hacer imagen dinámica
Import Data Sets… Importar conjuntos de datos…
Export Data Sets… Exportar conjuntos de datos…
Load Variable Library… Cargar biblioteca de variables…
Make Graph Dynamic Hacer gráfico dinámico
Batch… Lote…
Source Folder: Carpeta de origen:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4506,6 +4506,7 @@ imageTrace Outline Ääriviiva
@msg unknown workspace (one of {_1}) tuntematon työtila (jokin seuraavista: {_1})
@msg unsupported: {0} ei tuettu: {0}
@msg variables are locked muuttujat on lukittu
@msg variables that aren't text were left out: {kinds} muut kuin tekstimuuttujat jätettiin pois: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files version {0} tiedostoja ei voi pakata: pakattuja tiedostoja voi avata vain versiosta {COMPRESSED_SINCE} alkaen
@msg width and height are at least 1 pixel leveys ja korkeus ovat vähintään 1 pikseli
@msg workspace name can't be empty työtilan nimi ei voi olla tyhjä
@@ -5107,6 +5108,7 @@ mark-style Japanese Japanilainen
Drawing : real size — 1:10 labels 1 unit on the page as 10. Piirustus : todellinen koko — 1:10 merkitsee sivun 1 yksikön arvoksi 10.
Editable Muokattava
Export Data Sets… Vie tietojoukot…
Load Variable Library… Lataa muuttujakirjasto…
Filter Suodata
Filter Layers Panel Rows Suodata Tasot-paneelin rivit
Flatten Litistä
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4293,6 +4293,7 @@ axis Both Les deux
@msg unknown workspace (one of {_1}) espace de travail inconnu (valeurs possibles : {_1})
@msg unsupported: {0} non pris en charge : {0}
@msg variables are locked les variables sont verrouillées
@msg variables that aren't text were left out: {kinds} les variables qui ne sont pas du texte ({kinds}) ont été ignorées
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files les fichiers de version {0} ne peuvent pas être compressés : seules les versions {COMPRESSED_SINCE} et ultérieures ouvrent les fichiers compressés
@msg width and height are at least 1 pixel la largeur et la hauteur valent au moins 1 pixel
@msg workspace name can't be empty le nom de l’espace de travail ne peut pas être vide
@@ -5229,6 +5230,7 @@ theme Appearance Apparence
Make Image Dynamic Rendre l'image dynamique
Import Data Sets… Importer des jeux de données…
Export Data Sets… Exporter des jeux de données…
Load Variable Library… Charger une bibliothèque de variables…
Make Graph Dynamic Rendre le graphe dynamique
Batch… Traitement par lots…
Source Folder: Dossier source :
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5023,6 +5023,7 @@ imageTrace Outline Körvonal
@msg Updated {_1} image(s) Frissített képek: {_1}
@msg Updated {_1} linked file(s) changed on disk Frissített, a lemezen módosult csatolt fájlok: {_1}
@msg variables are locked a változók zárolva vannak
@msg variables that aren't text were left out: {kinds} a nem szöveges változók kimaradtak: {kinds}
@msg VectorCraft doesn't search other apps' or the system's folders: {path} a VectorCraft nem keres más alkalmazások és a rendszer mappáiban: {path}
@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} a VectorCraft nem keres a Users alatti Shared és Public mappában: {path}
@msg VectorCraft failed to start: {e} A VectorCraft nem tudott elindulni: {e}
@@ -5216,6 +5217,7 @@ imageTrace Outline Körvonal
Make Image Dynamic Kép dinamikussá tétele
Import Data Sets… Adatkészletek importálása…
Export Data Sets… Adatkészletek exportálása…
Load Variable Library… Változókönyvtár betöltése…
Make Graph Dynamic Diagram dinamikussá tétele
Batch… Kötegelt feldolgozás…
Source Folder: Forrásmappa:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4969,6 +4969,8 @@ mark-style Japanese Jepang
Make Image Dynamic Jadikan gambar dinamis
Import Data Sets… Impor himpunan data…
Export Data Sets… Ekspor himpunan data…
Load Variable Library… Muat pustaka variabel…
@msg variables that aren't text were left out: {kinds} variabel yang bukan teks dihilangkan: {kinds}
Make Graph Dynamic Jadikan grafik dinamis
Batch… Batch…
Source Folder: Folder sumber:
Can't render this file because it contains an unexpected character in line 3801 and column 19.
+2
View File
@@ -4336,6 +4336,7 @@ axis Both Entrambi
@msg unknown workspace (one of {_1}) area di lavoro sconosciuta (una delle seguenti: {_1})
@msg unsupported: {0} non supportato: {0}
@msg variables are locked le variabili sono bloccate
@msg variables that aren't text were left out: {kinds} le variabili che non sono di testo sono state escluse: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files i file della versione {0} non possono essere compressi: solo la versione {COMPRESSED_SINCE} e successive aprono file compressi
@msg width and height are at least 1 pixel larghezza e altezza sono di almeno 1 pixel
@msg workspace name can't be empty il nome dell'area di lavoro non può essere vuoto
@@ -4843,6 +4844,7 @@ lighting Light Luce
Make Image Dynamic Rendi immagine dinamica
Import Data Sets… Importa set di dati…
Export Data Sets… Esporta set di dati…
Load Variable Library… Carica libreria di variabili…
Make Graph Dynamic Rendi grafico dinamico
Batch… Elaborazione batch…
Source Folder: Cartella di origine:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3924,6 +3924,7 @@ swatch Bright Violet ビビッドバイオレット
@msg Make Guides: select paths ガイドを作成: パスを選択してください
@msg Release Guides: no guides ガイドを解除: ガイドがありません
@msg variables are locked 変数がロックされています
@msg variables that aren't text were left out: {kinds} テキスト以外の変数は除外されました: {kinds}
@msg guides are locked ガイドがロックされています
@msg handle must be in or out handle には in または out を指定してください
@msg no saved selection recalled 保存した選択範囲を呼び出していません
@@ -4883,6 +4884,7 @@ lighting Light ライト
Make Image Dynamic 画像を動的にする
Import Data Sets… データセットを読み込み…
Export Data Sets… データセットを書き出し…
Load Variable Library… 変数ライブラリを読み込み…
Make Graph Dynamic グラフを動的にする
Batch… バッチ…
Source Folder: ソースフォルダー:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4901,6 +4901,7 @@ imageTrace Outline კონტური
Make Image Dynamic გამოსახულების დინამიკურად ქცევა
Import Data Sets… მონაცემთა ნაკრებების იმპორტი…
Export Data Sets… მონაცემთა ნაკრებების ექსპორტი…
Load Variable Library… ცვლადების ბიბლიოთეკის ჩატვირთვა…
Make Graph Dynamic დიაგრამის დინამიკურად ქცევა
Batch… პაკეტური დამუშავება…
Source Folder: წყარო საქაღალდე:
@@ -5177,6 +5178,7 @@ imageTrace Outline კონტური
@msg the spine has no segments ხერხემალს სეგმენტები არ აქვს
@msg too many library panels were opened in this session ამ სესიაში ზედმეტად ბევრი ბიბლიოთეკის პანელი გაიხსნა
@msg variables are locked ცვლადები ჩაკეტილია
@msg variables that aren't text were left out: {kinds} ცვლადები, რომლებიც ტექსტი არ არის, გამოტოვებულია: {kinds}
@msg x and y must be finite x და y სასრული უნდა იყოს
@msg {id} opens another app: only the person at the keyboard can run it {id} სხვა აპს ხსნის: მისი გაშვება მხოლოდ კლავიატურასთან მყოფ ადამიანს შეუძლია
@msg “{name}” is not a file „{name}“ ფაილი არ არის
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3856,6 +3856,7 @@ theme Appearance 외관
@msg Make Guides: select paths 안내선 만들기: 패스를 선택하세요
@msg Release Guides: no guides 안내선 해제: 안내선이 없습니다
@msg variables are locked 변수가 잠겨 있습니다
@msg variables that aren't text were left out: {kinds} 텍스트가 아닌 변수는 제외되었습니다: {kinds}
@msg guides are locked 안내선이 잠겨 있습니다
@msg handle must be in or out handle은 in 또는 out이어야 합니다
@msg no saved selection recalled 불러온 저장된 선택 영역이 없습니다
@@ -4806,6 +4807,7 @@ lighting Light 광원
Make Image Dynamic 이미지를 동적으로 만들기
Import Data Sets… 데이터 세트 가져오기…
Export Data Sets… 데이터 세트 내보내기…
Load Variable Library… 변수 라이브러리 불러오기…
Make Graph Dynamic 그래프를 동적으로 만들기
Batch… 일괄 처리…
Source Folder: 소스 폴더:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4981,6 +4981,7 @@ mark-style Japanese Japans
Make Image Dynamic Afbeelding dynamisch maken
Import Data Sets… Gegevenssets importeren…
Export Data Sets… Gegevenssets exporteren…
Load Variable Library… Variabelenbibliotheek laden…
Make Graph Dynamic Grafiek dynamisch maken
Batch… Batch…
Source Folder: Bronmap:
@@ -5294,6 +5295,7 @@ swatch Bright Violet Felviolet
@msg the image's pixels can't be read de pixels van de afbeelding kunnen niet worden gelezen
@msg the spine has no segments de as heeft geen segmenten
@msg variables are locked variabelen zijn vergrendeld
@msg variables that aren't text were left out: {kinds} variabelen die geen tekst zijn, zijn weggelaten: {kinds}
@msg x and y must be finite x en y moeten eindig zijn
@msg Persistent libraries not found: {files} Permanente bibliotheken niet gevonden: {files}
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` is geen bibliotheekpaneel (zie window.swatchLibrary)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4422,6 +4422,7 @@ imageTrace Outline Kontur
@msg unknown workspace (one of {_1}) nieznany obszar roboczy (jeden z: {_1})
@msg unsupported: {0} nieobsługiwane: {0}
@msg variables are locked zmienne są zablokowane
@msg variables that aren't text were left out: {kinds} pominięto zmienne, które nie są tekstowe: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files plików w wersji {0} nie można kompresować: skompresowane pliki otwierają tylko wersje {COMPRESSED_SINCE} i nowsze
@msg width and height are at least 1 pixel szerokość i wysokość muszą wynosić co najmniej 1 piksel
@msg workspace name can't be empty nazwa obszaru roboczego nie może być pusta
@@ -5081,6 +5082,7 @@ mark-style Japanese Japońskie
Make Image Dynamic Uczyń obraz dynamicznym
Import Data Sets… Importuj zestawy danych…
Export Data Sets… Eksportuj zestawy danych…
Load Variable Library… Wczytaj bibliotekę zmiennych…
Make Graph Dynamic Uczyń wykres dynamicznym
Batch… Przetwarzanie wsadowe…
Source Folder: Folder źródłowy:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -908,6 +908,7 @@
Export CSS Exportar CSS
Export CSS… Exportar CSS…
Export Data Sets… Exportar Conjuntos de Dados…
Load Variable Library… Carregar Biblioteca de Variáveis…
Export DXF Exportar DXF
Export Document Exportar Documento
Export EPS Exportar EPS
@@ -3874,6 +3875,7 @@ axis Both Ambos
@msg unknown workspace (one of {_1}) espaço de trabalho desconhecido (um de {_1})
@msg unsupported: {0} não suportado: {0}
@msg variables are locked as variáveis estão bloqueadas
@msg variables that aren't text were left out: {kinds} as variáveis que não são de texto foram omitidas: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files arquivos da versão {0} não podem ser compactados: somente a versão {COMPRESSED_SINCE} e posteriores abrem arquivos compactados
@msg width and height are at least 1 pixel largura e altura têm pelo menos 1 pixel
@msg workspace name can't be empty o nome do espaço de trabalho não pode estar vazio
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4332,6 +4332,7 @@ axis Both Оба
@msg unknown workspace (one of {_1}) неизвестная рабочая среда (одна из: {_1})
@msg unsupported: {0} не поддерживается: {0}
@msg variables are locked переменные заблокированы
@msg variables that aren't text were left out: {kinds} переменные, которые не являются текстовыми, пропущены: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файлы версии {0} не могут быть сжаты: только версия {COMPRESSED_SINCE} и новее открывают сжатые файлы
@msg width and height are at least 1 pixel ширина и высота должны быть не менее 1 пикселя
@msg workspace name can't be empty имя рабочей среды не может быть пустым
@@ -4839,6 +4840,7 @@ lighting Light Свет
Make Image Dynamic Сделать изображение динамическим
Import Data Sets… Импортировать наборы данных…
Export Data Sets… Экспортировать наборы данных…
Load Variable Library… Загрузить библиотеку переменных…
Make Graph Dynamic Сделать диаграмму динамической
Batch… Пакетная обработка…
Source Folder: Исходная папка:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4207,6 +4207,7 @@ axis Both Båda
@msg unknown workspace (one of {_1}) okänd arbetsyta (en av {_1})
@msg unsupported: {0} stöds inte: {0}
@msg variables are locked variablerna är låsta
@msg variables that aren't text were left out: {kinds} variabler som inte är text utelämnades: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files filer i version {0} kan inte komprimeras: endast version {COMPRESSED_SINCE} och senare kan öppna komprimerade filer
@msg width and height are at least 1 pixel bredd och höjd är minst 1 pixel
@msg workspace name can't be empty arbetsytans namn får inte vara tomt
@@ -5099,6 +5100,7 @@ theme Appearance Utseende
Drawing : real size — 1:10 labels 1 unit on the page as 10. Ritning : verklig storlek — 1:10 visar 1 enhet på sidan som 10.
Editable Redigerbar
Export Data Sets… Exportera datauppsättningar…
Load Variable Library… Läs in variabelbibliotek…
Filter Filter
Filter Layers Panel Rows Filtrera rader i panelen Lager
Flatten Platta till
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -4320,6 +4320,7 @@ axis Both Обидві
@msg unknown workspace (one of {_1}) невідоме робоче середовище (одна з: {_1})
@msg unsupported: {0} не підтримується: {0}
@msg variables are locked змінні заблоковані
@msg variables that aren't text were left out: {kinds} змінні, що не є текстовими, пропущено: {kinds}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файли версії {0} не можуть бути стиснуті: тільки версія {COMPRESSED_SINCE} і новіші відкривають стислі файли
@msg width and height are at least 1 pixel ширина та висота повинні бути не менше 1 пікселя
@msg workspace name can't be empty ім’я робочого середовища не може бути порожнім
@@ -4823,6 +4824,7 @@ lighting Light Світло
Make Image Dynamic Зробити зображення динамічним
Import Data Sets… Імпортувати набори даних…
Export Data Sets… Експортувати набори даних…
Load Variable Library… Завантажити бібліотеку змінних…
Make Graph Dynamic Зробити діаграму динамічною
Batch… Пакетна обробка…
Source Folder: Вихідна папка:
Can't render this file because it contains an unexpected character in line 726 and column 40.
+2
View File
@@ -4682,6 +4682,7 @@ theme Appearance Giao diện
@msg Updated {_1} image(s) Đã cập nhật {_1} ảnh
@msg Updated {_1} linked file(s) changed on disk Đã cập nhật {_1} tệp liên kết đã thay đổi trên đĩa
@msg variables are locked biến số bị khóa
@msg variables that aren't text were left out: {kinds} các biến số không phải văn bản đã bị bỏ qua: {kinds}
@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft không tìm kiếm trong thư mục của ứng dụng khác hoặc hệ thống: {path}
@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft không tìm kiếm thư mục Shared hoặc Public trong Users: {path}
@msg VectorCraft failed to start: {e} VectorCraft khởi động thất bại: {e}
@@ -4823,6 +4824,7 @@ theme Appearance Giao diện
Make Image Dynamic Tạo hình ảnh động
Import Data Sets… Nhập tập dữ liệu…
Export Data Sets… Xuất tập dữ liệu…
Load Variable Library… Tải thư viện biến số…
Make Graph Dynamic Tạo biểu đồ động
Batch… Hàng loạt…
Source Folder: Thư mục nguồn:
Can't render this file because it contains an unexpected character in line 4003 and column 10.
+2
View File
@@ -3340,6 +3340,7 @@ font-script Japanese 汉字
Lock Variables 锁定变量
Unlock Variables 解锁变量
@msg variables are locked 变量已锁定
@msg variables that aren't text were left out: {kinds} 已省略非文本变量:{kinds}
List Variables 列出变量
Bind Variable 绑定变量
Unbind Variable 解除变量绑定
@@ -5070,6 +5071,7 @@ lighting Light 光源
Make Image Dynamic 使图像动态化
Import Data Sets… 导入数据集…
Export Data Sets… 导出数据集…
Load Variable Library… 载入变量库…
Make Graph Dynamic 使图表动态化
Batch… 批处理…
Source Folder: 源文件夹:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4249,6 +4249,7 @@ font-script Japanese 漢字
Lock Variables 鎖定變數
Unlock Variables 解除鎖定變數
@msg variables are locked 變數已鎖定
@msg variables that aren't text were left out: {kinds} 已略過非文字變數:{kinds}
List Variables 列出變數
Bind Variable 綁定變數
Unbind Variable 解除變數綁定
@@ -4879,6 +4880,7 @@ lighting Light 光源
Make Image Dynamic 使影像動態化
Import Data Sets… 匯入資料集…
Export Data Sets… 匯出資料集…
Load Variable Library… 載入變數資料庫…
Make Graph Dynamic 使圖表動態化
Batch… 批次處理…
Source Folder: 來源資料夾:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+32
View File
@@ -322,6 +322,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::menu_item(ui, tl!("Export Data Sets…"), !vars.is_empty(), false) {
crate::picks::button(app, export_datasets);
}
// Its file dialog, shown off the UI thread, loads the library when it answers.
if widgets::menu_item(ui, tl!("Load Variable Library…"), true, false) {
crate::picks::button(app, load_library);
}
ui.separator();
// What the highlighted variable drives, selected — its bindings are left alone.
if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) {
@@ -350,6 +354,16 @@ fn export_datasets(app: &mut VectorcraftApp) {
}
}
/// Variables panel: Load Variable Library… opens a file dialog for an XML file and runs
/// `variable.loadLibrary` on the chosen file.
fn load_library(app: &mut VectorcraftApp) {
let pick = crate::FilePick { filters: vec![crate::FilePick::filter("XML", &["xml"])], ..Default::default() };
let Some(path) = crate::picks::open(app, &pick) else { return };
if let Err(e) = app.run("variable.loadLibrary", json!({"path": path})) {
app.status(e);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -436,6 +450,7 @@ mod tests {
"Update Data Set",
"Rename Data Set…",
"Delete Data Set",
"Load Variable Library…",
"Select Bound Object",
"Select All Bound Objects",
] {
@@ -457,6 +472,23 @@ mod tests {
assert!(text.contains("Name") && text.contains("Data Set 1"), "{text}");
}
/// Load Variable Library's warning about variables that aren't text shows in the status bar,
/// translated in every language but English.
#[test]
fn the_variable_library_warning_shows_in_the_status_bar() {
use crate::i18n::{Lang, message};
let mut app = app_with_data();
let xml = r#"<variableSets><variableSet><variables><variable varName="Logo" trait="other"/></variables></variableSet></variableSets>"#;
app.run("variable.loadLibrary", json!({"xml": xml})).unwrap();
assert_eq!(app.ui.status, "variables that aren't text were left out: other (1)");
let langs: Vec<Lang> = Lang::all().filter(|l| l.code() != "en").collect();
assert!(langs.len() >= 23, "{} languages", langs.len());
for l in langs {
let shown = message(l, &app.ui.status);
assert!(!shown.contains("left out") && shown.contains("other") && shown.contains('1'), "{}: {shown}", l.code());
}
}
/// What the panel acts on is the highlight, and only while it names a variable: a stale one
/// (deleted elsewhere, undone) highlights nothing, so Delete and Options… stay disabled.
#[test]
+23
View File
@@ -290,6 +290,29 @@ fn other_graphic_style_library_asks_off_the_ui_thread() {
let _ = std::fs::remove_dir_all(dir);
}
/// The Variables panel menu's Load Variable Library… asks off the UI thread: the library picked
/// loads when the dialog answers.
#[test]
fn load_variable_library_asks_off_the_ui_thread() {
let (mut app, shown, _) = app();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
let dir = std::env::temp_dir().join(format!("vc-picks-variables-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let library = dir.join("library.xml");
let xml = r#"<variableSets><variableSet><variables><variable varName="Name" trait="textcontent"/></variables>
<sampleDataSets><sampleDataSet dataSetName="A"><Name><p>x</p></Name></sampleDataSet></sampleDataSets></variableSet></variableSets>"#;
std::fs::write(&library, xml).unwrap();
let sets = |app: &VectorcraftApp| app.session.doc().unwrap().doc.variables.datasets.iter().map(|d| d.name.clone()).collect::<Vec<_>>();
click(&mut app, "Load Variable Library…", crate::panels::variables::menu);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
assert!(sets(&app).is_empty(), "nothing loads before the dialog answers");
shown.answer(&[&library.to_string_lossy()]);
poll(&mut app);
assert_eq!(sets(&app), ["A"], "{}", app.ui.status);
let _ = std::fs::remove_dir_all(dir);
}
/// The Keyboard Shortcuts dialog's Import… and Export… ask off the UI thread: the set imported
/// replaces the dialog's working set (kept on OK), Export… writes it where the dialog answers, and
/// an answer once the dialog is closed does nothing.
+20 -3
View File
@@ -1288,9 +1288,10 @@ already walks Image Trace. Short agent entry points:
`variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph
values CSV as `graph.setData`). `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables;
without `locked` it toggles, and the lock is saved with the document. While the variables are locked,
`variable.define`, `variable.delete`, `variable.rename` and the four `variable.make…Dynamic` commands fail with
"variables are locked"; `variable.bind`, `variable.unbind` and the `dataset.*` commands still run (except a
`dataset.import` whose header would define a variable, below), and `variable.list` reports `locked`.
`variable.define`, `variable.delete`, `variable.rename`, the four `variable.make…Dynamic` commands and a
`variable.loadLibrary` that would delete or define a variable (below) fail with "variables are locked";
`variable.bind`, `variable.unbind` and the `dataset.*` commands still run (except a `dataset.import` whose header
would define a variable, below), and `variable.list` reports `locked`.
- Data sets: `dataset.list`, `dataset.new {name, values?}`, `dataset.set {name, values?}`, `dataset.rename`,
`dataset.delete`, `dataset.capture`, `dataset.update`, `dataset.select {name}`, `dataset.next` /
`dataset.prev` (each `dataset.select` / next / prev is one undo step over every bound object), `dataset.import {path|data|dataBase64|csv}` /
@@ -1303,6 +1304,22 @@ already walks Image Trace. Short agent entry points:
one whose name is a variable of another kind fails the import. A `%` cell that isn't a variable's exact name (graph
data files) is refused. The reply's `defined` lists the variables the import defined. While the variables are
locked, an import whose header would define a variable fails with "variables are locked".
- Variable libraries: `variable.loadLibrary {path|xml|dataBase64}` (the Variables panel menu's Load Variable
Library…) replaces the document's variables and data sets with a variable library's in one undo step. The library
is XML whose `variableSets` element holds `variableSet` elements, each with a `variables` element of `variable`
elements (`varName`, `trait`) and a `sampleDataSets` element of `sampleDataSet` elements (`dataSetName`). Every
variable of the document is deleted with its bindings and values, and every data set is deleted. Then the library's
variables whose `trait` is `textcontent` are defined as text variables, unbound. In each data set, the element named
after a text variable gives its value: the text of its `p` elements, one paragraph each. An element without a `p`
element gives no value. Variables of other kinds are left out with their values: the reply lists them in `skipped`
(`name`, `trait`), and its `warning` names each `trait` with its count. The reply's `deleted` and `deletedDataSets`
list the variables and data sets the load deleted. While the variables are locked, a library that would delete or
define a variable fails with "variables are locked". The reply is `{variables, deleted, deletedDataSets, names,
skipped, warning?}`. A library larger than 8 MiB, one whose elements nest more than 256 deep, and one with more
than 256 variables or 1,000 data sets are refused. Its document type declaration may hold an external identifier,
comments, processing instructions and up to 64 entity declarations whose values are plain text (no `<`, `&` or
`%`). A declaration that holds anything else, and entity references that expand the library past 8 MiB, are
refused. So are a variable or data set declared twice and a value for a variable the library doesn't declare.
- Batch: `action.batch {commands, source?: folder|datasets, folder?, destination?: none|saveAndClose|folder,
destinationFolder?, overrideOpen?, overrideSave?, overrideExport?, includeSubdirs?, stopOnError?}` plays an
action on every file in a folder or every data set (the Actions panel menu's Batch…). Override Save and Override
+1
View File
@@ -463,6 +463,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
- Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on.
- Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused.
- Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every variable is deleted with its bindings and values, and every data set is deleted; then the library's text variables are defined, unbound, and its data sets take the text of each value's `p` elements, one paragraph each. Variables of other kinds are left out with a warning that names their kinds.
- JSON-lines control channel with real egui pointer and keyboard injection.
- Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709).
- The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s.