diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 5393abd0d..efe50d9df 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -21,11 +21,15 @@ pub enum VariableKind { Image, /// A graph object's data (CSV of categories and series, as Graph Data uses). Graph, + /// A variable a file declares without a kind (or with one VectorCraft doesn't know): it drives + /// nothing, no value fits it, and nothing can be bound to it. + Unknown, } impl VariableKind { /// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted - /// as the same thing, as the panel and the docs call it both), `image` or `graph`. + /// as the same thing, as the panel and the docs call it both), `image` or `graph`. A variable + /// of [`Self::Unknown`] kind is never defined this way. pub fn parse(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "text" => Some(Self::Text), @@ -43,6 +47,7 @@ impl VariableKind { Self::Visibility => "visibility", Self::Image => "image", Self::Graph => "graph", + Self::Unknown => "unknown", } } } @@ -272,7 +277,7 @@ impl Document { (VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)), (VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())), (VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())), - (VariableKind::Text | VariableKind::Image, _) => None, + (VariableKind::Text | VariableKind::Image | VariableKind::Unknown, _) => None, } } diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 2d76843a4..50d09b025 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -614,32 +614,65 @@ fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() { assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}"); } -/// A `.ai` whose document data has a variable library opens with a note that its variables and -/// data sets weren't read, whether its layers are read or it comes in as its PDF part. +/// A `.ai` whose document data has a variable library opens with its variables, data sets, active +/// data set and lock, whether its layers are read or it comes in as its PDF part: `variable.list` +/// and `dataset.list` report them. A variable of unknown kind can't be bound. What of the library +/// can't be read is noted. #[test] -fn an_ai_file_with_a_variable_library_says_its_variables_werent_read() { - const NOTE: &str = "the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them"; - let library = ai::variable_library(&["Name", "Number"], &["One", "Two", "Three"]); +fn an_ai_file_opens_its_variable_library() { + let library = ai::variable_library_data(&ai::sample_variable_sets("variableAndTypes", &[])); let square = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0)); - let notes = - |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("variables")).cloned().collect::>(); + let notes = |r: &Value| { + r["warnings"].as_array().map_or(vec![], |w| w.iter().filter(|w| w.as_str().unwrap().contains("variable")).cloned().collect::>()) + }; + let check = |s: &mut Session| { + let r = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!( + r, + json!({"variables": [ + {"name": "v_text", "kind": "text", "bindings": 0}, + {"name": "v_visible", "kind": "visibility", "bindings": 0}, + {"name": "v_file", "kind": "image", "bindings": 0}, + {"name": "v_graph", "kind": "graph", "bindings": 0}, + {"name": "v_unbound", "kind": "unknown", "bindings": 0}, + ], "locked": true}) + ); + let r = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(r["active"], "ds_two", "{r}"); + let names: Vec<&str> = r["datasets"].as_array().unwrap().iter().map(|d| d["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["ds_one", "ds_two", "ds_three"]); + assert_eq!( + r["datasets"][1]["values"], + json!({"v_text": {"text": "line one\nline two & < é"}, "v_visible": {"visible": false}, "v_file": {"image": ai::SAMPLE_MISSING_FILE}}) + ); + }; let mut s = Session::new(); let layered = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", &square))); let file = ai::ai(&compressed(layered.as_bytes()), "1 0 0 rg 10 50 30 40 re f"); let r = open(&mut s, "variables.ai", &file, json!({})).unwrap(); assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}"); - assert_eq!(notes(&r), [json!(NOTE)], "{r}"); - let r = s.execute("variable.list", &json!({})).unwrap(); - assert_eq!(r["variables"], json!([]), "{r}"); - // Its layers can't be read (a symbol): it comes in as its PDF part, with the note. + assert!(notes(&r).is_empty(), "{r}"); + check(&mut s); + let id = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0; + let e = s.execute("variable.bind", &json!({"variable": "v_unbound", "ids": [id]})).unwrap_err(); + assert!(e.to_string().contains("a variable of unknown kind can't be bound to objects"), "{e}"); + // Its layers can't be read (a symbol): it comes in as its PDF part, with the library. let symbols = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"))); let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap(); assert!(r["warnings"].to_string().contains("PDF part"), "{r}"); - assert_eq!(notes(&r), [json!(NOTE)], "{r}"); - // Without a library, no note. + assert!(notes(&r).is_empty(), "{r}"); + check(&mut s); + // A graph value isn't read: the note says how many entries were left out. + let graph = ai::xml_element("v_graph", &[], &[ai::xml_text(ai::XmlValue::Str("a,b"))]); + let partly = ai::variable_library_data(&ai::sample_variable_sets("none", &[graph])); + let layered = ai::editing_data(200.0, 100.0, &format!("{partly}{}", ai::layer("Art", &square))); + let r = open(&mut s, "graph.ai", &ai::ai(&compressed(layered.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap(); + assert_eq!(notes(&r), [json!("the file's variable library was partly read (entries skipped: 1): the Variables panel lists the rest")], "{r}"); + // Without a library, no note and no variables. let plain = ai::editing_data(200.0, 100.0, &ai::layer("Art", &square)); let r = open(&mut s, "plain.ai", &ai::ai(&compressed(plain.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap(); assert!(notes(&r).is_empty(), "{r}"); + assert_eq!(s.execute("variable.list", &json!({})).unwrap()["variables"], json!([])); } /// An Objects on Path group in a `.ai` file opens as one: its record names the group's children by diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 596848ac9..76d39ea56 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -140,8 +140,11 @@ fn names_param(p: &Value, cmd: &str, fallback: Option, nothing: &str) -> /// Refuse what a variable of `kind` can't be bound to: art that is locked, on a hidden or locked /// layer, or on a locked artboard; a text variable only drives type; an image variable only drives -/// an image object. +/// an image object; a variable of unknown kind drives nothing. fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> { + if kind == VariableKind::Unknown { + return Err(bad(cmd, "a variable of unknown kind can't be bound to objects")); + } let locks = doc.locked_artboards(); for id in ids { match doc.node(*id) { @@ -189,6 +192,7 @@ fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result< .map(str::trim) .filter(|t| !t.is_empty()) .and_then(|t| if t.len() > MAX_GRAPH_CSV { None } else { Some(DataValue::Graph(t.to_string())) }), + VariableKind::Unknown => None, } .ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind"))) } diff --git a/crates/engine/src/cmd/variablecmds/csv.rs b/crates/engine/src/cmd/variablecmds/csv.rs index fe405500b..5a4ba5100 100644 --- a/crates/engine/src/cmd/variablecmds/csv.rs +++ b/crates/engine/src/cmd/variablecmds/csv.rs @@ -203,6 +203,7 @@ fn cell_to_value(kind: VariableKind, cell: &str, quoted: bool, var: &str, line: VariableKind::Graph if t.len() <= MAX_GRAPH_CSV => DataValue::Graph(t.to_string()), VariableKind::Graph => return Err(bad(cmd, format!("line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes"))), VariableKind::Visibility => DataValue::Visible(parse_visible_cell(t, var, line, cmd)?), + VariableKind::Unknown => return Err(bad(cmd, format!("line {line}: value for `{var}` must match its variable kind"))), })) } @@ -220,7 +221,7 @@ fn export_header(v: &Variable) -> String { match v.kind { VariableKind::Visibility => format!("#{}", v.name), VariableKind::Image => format!("@{}", v.name), - VariableKind::Text | VariableKind::Graph => v.name.clone(), + VariableKind::Text | VariableKind::Graph | VariableKind::Unknown => v.name.clone(), } } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 60bcc3fbe..1b001cf87 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3307,6 +3307,51 @@ proptest! { survive("mutated .ai variable library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// A `.ai` whose variable library holds values of every kind, values of the wrong type, elements + /// nested deep, and more variables and data sets than are read, then is damaged: its layers are + /// read, or it comes in as its PDF part (an unknown operator), with or without layers. + #[test] + fn ai_variable_library_values_never_panic( + cut in 0usize..40_000, + edits in prop::collection::vec((0usize..40_000, prop::sample::select(vec!['0', '1', '2', '3', '9', ' ', '\n', '(', ')', '\\', '/', ':', ';', ',', '%', '_', 'Z'])), 0..16), + locked in prop::sample::select(vec!["none", "variableAndTypes", "", "other"]), + extra_variables in 0usize..300, + extra_rows in 0usize..1_100, + depth in 0usize..300, + bad in prop::sample::select(vec![0u8, 1, 2, 3, 4]), + layers in prop::sample::select(vec![0u8, 1, 2]), + ) { + use vectorcraft_testkit::ai::{self, XmlValue}; + let mut nested = ai::xml_text(XmlValue::Str("deep")); + for _ in 0..depth { + nested = ai::xml_element("p", &[], &[nested]); + } + // A value of the wrong type, a second value, a value for no variable, wrong-typed children. + let wrong = match bad { + 0 => ai::xml_element("v_visible", &[], &[ai::xml_text(XmlValue::Str("true"))]), + 1 => ai::xml_element("v_text", &[], &[ai::xml_text(XmlValue::Bool(true))]), + 2 => ai::xml_element("v_nope", &[], &[ai::xml_text(XmlValue::Raw("5 /Int"))]), + 3 => ai::xml_element("v_graph", &[], &[ai::xml_text(XmlValue::Str("a,b\n1,2"))]), + _ => ai::xml_element("v_file", &[], &[]).replacen("%_/Array :", "%_/Dictionary :", 1), + }; + let mut library = ai::sample_variable_sets(locked, &[ai::xml_element("v_text", &[], &[nested]), wrong]); + if extra_variables + extra_rows > 0 { + let declared: Vec = (0..extra_variables).map(|i| ai::xml_element("variable", &[("varName", &format!("x{i}")), ("trait", "textcontent")], &[])).collect(); + let rows: Vec = (0..extra_rows).map(|i| ai::xml_element("v:sampleDataSet", &[("dataSetName", &format!("r{}", i % 900))], &[])).collect(); + let set = ai::xml_element("variableSet", &[], &[ai::xml_element("variables", &[], &declared), ai::xml_element("v:sampleDataSets", &[], &rows)]); + library = ai::xml_element("variableSets", &[], &[set]); + } + let art = match layers { + 0 => ai::layer("L", "0 g\n10 10 m\n20 20 L\nS\n"), + 1 => ai::layer("L", "1 Zz\n"), + _ => String::new(), + }; + let data = ai::editing_data(100.0, 100.0, &format!("{}{art}", ai::variable_library_data(&library))); + let text = mutate_text(&data, cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("hostile .ai variable library values", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose Objects on Path record and group are damaged or hostile. #[test] fn ai_objects_on_path_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, prop::sample::select(vec!['0', '1', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U'])), 0..12)) { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 7fedcc826..2dfee6f2c 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -26,8 +26,9 @@ //! - Document settings: Document Raster Effects Settings from the `/Document` dictionary's //! `AI Auto Rasterize`; the author from the header's `%%For`; swatches for the custom colors of //! `%%CMYKCustomColor` and `%%RGBCustomColor` that the document has none of. -//! - Variables: the variable library, XML nodes of the `/Document` dictionary, isn't read; a note -//! gives how many variables and data sets it has ([`VariableLibrary`]). +//! - Variables: the variable library, XML nodes of the `/Document` dictionary: its variables, data +//! sets, active data set and lock, without bindings; a note gives how many entries were left out +//! ([`VariableRead`]). //! - Objects on Path: a group whose objects' dictionaries carry ids, and a record of the //! `/Document` dictionary naming them, read by [`on_path`]. //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` @@ -109,7 +110,7 @@ mod types; pub(crate) use art::join_and; use art::preview_png; -pub use document::{PaletteSwatches, VariableLibrary, artboards, palette, variables}; +pub use document::{PaletteSwatches, VariableRead, artboards, palette, variables}; pub(crate) use document::{slot_frame, slot_of}; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster}; @@ -204,8 +205,8 @@ struct Reader<'a> { custom_more: Option, /// The header ended (`%%EndComments`). header_done: bool, - /// The variables and data sets of the `/Document` dictionary's variable library. - variables: VariableLibrary, + /// The `/Document` dictionary's variable library. + variables: VariableRead, /// The Objects on Path records of the `/Document` dictionary and the ids of the objects. on_path: on_path::OnPath, gradients: HashMap, @@ -364,7 +365,7 @@ impl<'a> Reader<'a> { custom_colors: Vec::new(), custom_more: None, header_done: false, - variables: VariableLibrary::default(), + variables: VariableRead::default(), on_path: on_path::OnPath::default(), gradients: HashMap::new(), gradient_def: None, diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 36974f559..806cd270f 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -673,11 +673,6 @@ pub(super) fn element_name(o: &Obj) -> Option { Some(name.rsplit(':').next().unwrap_or(&name).to_string()) } -/// The child nodes named `name` (see [`element_name`]) of the XML node `o`. -pub(super) fn elements<'o>(o: &'o Obj, name: &'o str) -> impl Iterator { - o.obj("xmlnode-children").into_iter().flat_map(Obj::items).filter(move |c| c.ty == "XMLNode" && element_name(c).as_deref() == Some(name)) -} - /// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by /// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is /// larger than [`MAX_PIXELS`] or is cut short. diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 94adadc04..ee82175a6 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1,4 +1,4 @@ -use super::art::{element_name, elements, join_and, sane}; +use super::art::{element_name, join_and, sane}; use super::containers::drop_commented_copies; use super::ops::appearance_from_art_style; use super::types::{FLIP, Kind, MAX_DEPTH}; @@ -6,7 +6,7 @@ use super::{ Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, HashMap, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj, }; -use vectorcraft_doc::{GraphicStyle, Guide, ImageBlob, PatternDef, TextWrap}; +use vectorcraft_doc::{DataSet, DataValue, GraphicStyle, Guide, ImageBlob, PatternDef, TextWrap, Variable, VariableKind, Variables}; /// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). fn ruler_unit(v: u8) -> Option { @@ -215,42 +215,46 @@ fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { r.artboards.into_iter().map(|b| (b.name, b.rect)).collect() } -/// How many variables and data sets the variable library of the editing data has: XML nodes -/// (`/XMLNode`) of the `/Document` dictionary, a `variableSets` element holding `variableSet`s, each -/// with its `variables` (`variable` elements) and `sampleDataSets` (`sampleDataSet` elements). -/// VectorCraft doesn't read them. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct VariableLibrary { - pub variables: usize, - pub data_sets: usize, +/// The variable library of the editing data, as [`variable_library`] reads it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct VariableRead { + /// Its variables, data sets, active data set and lock, without bindings. + pub variables: Variables, + /// How many of its declarations, data sets, values and settings were left out. + pub skipped: usize, + /// Whether the editing data has a variable library (a `variableSets` element). + pub present: bool, } -impl VariableLibrary { - /// The import's note that the library wasn't read, when it has a variable or a data set. - pub fn note(self) -> Option { - let Self { variables, data_sets } = self; - // Counts after a colon need no plural, so every language can translate the note whole. - (variables + data_sets > 0).then(|| { - format!("the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them") - }) +impl VariableRead { + /// The import's note about what of the library wasn't read: nothing when all of it was. + pub fn note(&self) -> Option { + if !self.present || self.skipped == 0 { + return None; + } + if self.variables.variables.is_empty() && self.variables.datasets.is_empty() { + return Some("the file's variable library couldn't be read: the Variables panel doesn't list it".into()); + } + // A count after a colon needs no plural, so every language can translate the note whole. + Some(format!("the file's variable library was partly read (entries skipped: {}): the Variables panel lists the rest", self.skipped)) } } -/// The variable library of the editing data `data` ([`VariableLibrary`]), for a file whose layers +/// The variable library of the editing data `data` ([`VariableRead`]), for a file whose layers /// [`read`] can't read. Data without a `variableSets` element isn't read. -pub fn variables(data: &[u8]) -> VariableLibrary { +pub fn variables(data: &[u8]) -> VariableRead { if crate::import::ai::find(data, b"variableSets").is_none() { - return VariableLibrary::default(); + return VariableRead::default(); } let mut r = Reader::new(data); let _ = r.run(); + if r.variables.present { + return r.variables; + } // A `/Document` still open when reading stopped. let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); - for o in open.iter().filter(|o| o.ty == "Document") { - variable_library(o, 0, &mut r.variables); - } - if r.variables != VariableLibrary::default() { - return r.variables; + if let Some(found) = open.iter().filter(|o| o.ty == "Document").map(variable_library).find(|v| v.present) { + return found; } // Reading can stop before the library: read its node on its own. variable_sets_node(data).map_or(r.variables, |node| { @@ -418,10 +422,11 @@ impl<'a> Reader<'a> { } /// The `/Document` dictionary: the artboards and their display options, Document Raster Effects - /// Settings, how many variables and data sets its variable library has, and its Objects on - /// Path records. + /// Settings, its variable library (the first one read), and its Objects on Path records. pub(super) fn document(&mut self, o: &Obj) { - variable_library(o, 0, &mut self.variables); + if !self.variables.present { + self.variables = variable_library(o); + } self.constraint_data(o); if let Some(r) = find_entry(o, "AI Auto Rasterize", 0) { raster_settings(r, &mut self.doc); @@ -695,7 +700,11 @@ impl<'a> Reader<'a> { self.drawn_looks )); } - warnings.extend(self.variables.note()); + let variables = std::mem::take(&mut self.variables); + warnings.extend(variables.note()); + if variables.present { + doc.variables = variables.variables.into(); + } Ok(Structure { doc, to_doc, @@ -807,20 +816,368 @@ pub(super) fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1))) } -/// Add the variables and data sets of the variable libraries in `o` or the dictionaries it holds -/// (see [`VariableLibrary`]) to `found`. -fn variable_library(o: &Obj, depth: usize, found: &mut VariableLibrary) { +// ---- the variable library ----------------------------------------------------------------- + +/// Caps on the variable library of the editing data, as Load Variable Library has them: the XML +/// nodes read, the variables and data sets kept, the characters of a name, and the bytes of the +/// names and values kept. +const MAX_LIBRARY_NODES: usize = 2_000_000; +const MAX_LIBRARY_VARIABLES: usize = 256; +const MAX_LIBRARY_DATASETS: usize = 1_000; +const MAX_LIBRARY_NAME: usize = 255; +const MAX_LIBRARY_BYTES: usize = 8 << 20; + +/// The variable library in `o` or the dictionaries it holds: XML nodes (`/XMLNode`), each with its +/// name (`xmlnode-nodename`), value (`xmlnode-nodevalue`), type (`xmlnode-nodetype`: 1 an element, 2 +/// an attribute, 3 text), children (`xmlnode-children`, an array in order) and attributes +/// (`xmlnode-attributes`, a dictionary of attribute nodes). The library is a `variableSets` element +/// holding a `variableSet` (its `locked` attribute `none`, or `variableAndTypes` when the variables +/// are locked), with a `variables` element of `variable` elements (`varName`, and `trait`: +/// `textcontent`, `visibility`, `fileref` or `graphdata`; another trait or none is a variable of +/// unknown kind), and a `sampleDataSets` element (`activeDataSet`, the data set shown) of +/// `sampleDataSet` elements (`dataSetName`). In a data set, the element named after a variable holds +/// its value: `p` elements of text, one paragraph each; a `/Bool` text, whether its objects show; a +/// linked file's URL (`file://`) or path. Graph values aren't read. What is malformed, repeated or +/// past the caps (a declaration, a data set, a value, a setting, a second `variableSet`) is left out +/// and counted; a library past [`MAX_LIBRARY_NODES`] isn't read. Bindings aren't read. +fn variable_library(o: &Obj) -> VariableRead { + find_variable_sets(o, 0).map_or_else(VariableRead::default, read_variable_sets) +} + +/// The `variableSets` XML node in `o` or the dictionaries it holds. +fn find_variable_sets(o: &Obj, depth: usize) -> Option<&Obj> { if depth > MAX_DEPTH { - return; + return None; } if o.ty == "XMLNode" && element_name(o).as_deref() == Some("variableSets") { - for set in elements(o, "variableSet") { - found.variables += elements(set, "variables").flat_map(|v| elements(v, "variable")).count(); - found.data_sets += elements(set, "sampleDataSets").flat_map(|d| elements(d, "sampleDataSet")).count(); - } - return; + return Some(o); } - for c in o.items() { - variable_library(c, depth + 1, found); + o.items().find_map(|c| find_variable_sets(c, depth + 1)) +} + +/// A value of an XML node's entry, by the type the editing data gives it. +#[derive(Clone, Debug, PartialEq)] +enum XmlValue { + /// A `/String` or `/UnicodeString`: its strings joined (a long one is written across lines). + Text(String), + /// A `/Bool`. + Bool(bool), +} + +/// The value of entry `key` of the XML node `o`; `None` without it, when it is of another type, or +/// when its text is longer than [`MAX_LIBRARY_BYTES`]. +fn xml_value(o: &Obj, key: &str) -> Option { + let (V::Name(ty), rest) = o.get(key)?.split_last()? else { return None }; + match ty.as_str() { + "String" | "UnicodeString" => { + let len = rest.iter().map(|v| if let V::Str(s) = v { s.len() } else { 0 }).fold(0usize, usize::saturating_add); + if len > MAX_LIBRARY_BYTES { + return None; + } + let mut bytes = Vec::with_capacity(len); + for v in rest { + let V::Str(s) = v else { return None }; + bytes.extend_from_slice(s); + } + Some(XmlValue::Text(obj::text(&bytes))) + } + "Bool" => match rest { + [V::Num(n)] if *n == 0.0 => Some(XmlValue::Bool(false)), + [V::Num(n)] if *n == 1.0 => Some(XmlValue::Bool(true)), + _ => None, + }, + _ => None, } } + +/// Whether `o` is an XML node of type `ty` (1 an element, 2 an attribute, 3 text). +fn is_node(o: &Obj, ty: f64) -> bool { + o.ty == "XMLNode" && o.nums("xmlnode-nodetype") == [ty] +} + +/// The name of the XML node `o` as the file writes it, prefix and all. +fn node_name(o: &Obj) -> Option { + match xml_value(o, "xmlnode-nodename")? { + XmlValue::Text(name) => Some(name), + XmlValue::Bool(_) => None, + } +} + +/// Whether `o` is an element whose name without its prefix is `name`. +fn is_element(o: &Obj, name: &str) -> bool { + is_node(o, 1.0) && node_name(o).is_some_and(|n| n.rsplit(':').next() == Some(name)) +} + +/// The text of the attribute `name` (without its prefix) of the element `o`. +fn xml_attribute(o: &Obj, name: &str) -> Option { + let attributes = o.obj("xmlnode-attributes").filter(|a| a.ty == "Dictionary")?; + let node = attributes.items().find(|a| is_node(a, 2.0) && node_name(a).is_some_and(|n| n.rsplit(':').next() == Some(name)))?; + match xml_value(node, "xmlnode-nodevalue")? { + XmlValue::Text(text) => Some(text), + XmlValue::Bool(_) => None, + } +} + +/// A name of the library (a variable's or a data set's): trimmed, not empty and at most +/// [`MAX_LIBRARY_NAME`] characters. +fn library_name(name: Option) -> Option { + let name = name?.trim().to_string(); + (!name.is_empty() && name.chars().count() <= MAX_LIBRARY_NAME).then_some(name) +} + +/// The file path of a linked file's value: a `file://` URL (`file:////Users/…`) as its path, its +/// `%XX` escapes decoded (a URL that names a host other than `localhost` as a network path, +/// `//host/…`), or an absolute path as it is. White space around it isn't part of it. +fn linked_path(text: &str) -> Option { + let text = text.trim_matches([' ', '\t', '\n', '\r']); + let drive = |p: &str| matches!(p.as_bytes(), [l, b':', ..] if l.is_ascii_alphabetic()); + let path = match text.strip_prefix("file://") { + Some(rest) => { + let rest = rest.strip_prefix("localhost").filter(|r| r.starts_with('/')).unwrap_or(rest); + if rest.starts_with('/') { + let p = percent_decoded(rest.trim_start_matches('/')); + if drive(&p) { p } else { format!("/{p}") } + } else { + // A host: the share it names. + format!("//{}", percent_decoded(rest)) + } + } + None => text.to_string(), + }; + (path.len() > 1 && (path.starts_with('/') || path.starts_with("\\\\") || drive(&path))).then_some(path) +} + +/// `s` with `%XX` escapes decoded (a file URL's path); as it is when the decoded bytes aren't UTF-8. +fn percent_decoded(s: &str) -> String { + if !s.contains('%') { + return s.to_string(); + } + let mut out = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail + .get(..2) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) if b == b'%' => { + out.push(v); + rest = tail.get(2..).unwrap_or_default(); + } + _ => { + out.push(b); + rest = tail; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// The walk through a variable library: the nodes read, the bytes kept, and what was left out. +#[derive(Default)] +struct LibraryWalk { + nodes: usize, + bytes: usize, + skipped: usize, +} + +impl LibraryWalk { + /// The child nodes of the XML node `o`, in order. `None` when they aren't an array, or when the + /// library has more nodes than [`MAX_LIBRARY_NODES`]. + fn children<'o>(&mut self, o: &'o Obj) -> Option> { + let list = o.obj("xmlnode-children").filter(|c| c.ty == "Array")?; + let children: Vec<&Obj> = list.items().collect(); + self.nodes = self.nodes.saturating_add(children.len()); + (self.nodes <= MAX_LIBRARY_NODES).then_some(children) + } + + /// Whether `len` more bytes of names and values fit in [`MAX_LIBRARY_BYTES`] (they are then counted). + fn keep(&mut self, len: usize) -> bool { + let fits = self.bytes.saturating_add(len) <= MAX_LIBRARY_BYTES; + if fits { + self.bytes += len; + } + fits + } + + /// Whether a value of `len` bytes would still fit in [`MAX_LIBRARY_BYTES`] (nothing is counted). + fn room(&self, len: usize) -> bool { + self.bytes.saturating_add(len) <= MAX_LIBRARY_BYTES + } + + /// The `variableSet` `set`, into `vars`. + fn variable_set(&mut self, set: &Obj, vars: &mut Variables) { + vars.locked = match xml_attribute(set, "locked").as_deref() { + None | Some("none") => false, + Some("variableAndTypes") => true, + Some(_) => { + self.skipped += 1; + false + } + }; + let Some(children) = self.children(set) else { + self.skipped += 1; + return; + }; + for list in children.iter().filter(|c| is_element(c, "variables")) { + self.declarations(list, vars); + } + for list in children.iter().filter(|c| is_element(c, "sampleDataSets")) { + self.data_sets(list, vars); + } + } + + /// The `variable` elements of the `variables` element `list`, into `vars`. + fn declarations(&mut self, list: &Obj, vars: &mut Variables) { + let Some(children) = self.children(list) else { + self.skipped += 1; + return; + }; + for v in children.into_iter().filter(|c| !is_node(c, 3.0)) { + let name = is_element(v, "variable").then(|| library_name(xml_attribute(v, "varName"))).flatten(); + let Some(name) = name.filter(|n| vars.variable(n).is_none() && vars.variables.len() < MAX_LIBRARY_VARIABLES && self.keep(n.len())) else { + self.skipped += 1; + continue; + }; + let kind = match xml_attribute(v, "trait").as_deref() { + Some("textcontent") => VariableKind::Text, + Some("visibility") => VariableKind::Visibility, + Some("fileref") => VariableKind::Image, + Some("graphdata") => VariableKind::Graph, + _ => VariableKind::Unknown, + }; + vars.variables.push(Variable { name, kind }); + } + } + + /// The `sampleDataSet` elements of the `sampleDataSets` element `list`, into `vars`, and the + /// data set it shows. + fn data_sets(&mut self, list: &Obj, vars: &mut Variables) { + let Some(children) = self.children(list) else { + self.skipped += 1; + return; + }; + for row in children.into_iter().filter(|c| !is_node(c, 3.0)) { + let name = is_element(row, "sampleDataSet").then(|| library_name(xml_attribute(row, "dataSetName"))).flatten(); + let Some(name) = name.filter(|n| vars.dataset(n).is_none() && vars.datasets.len() < MAX_LIBRARY_DATASETS && self.keep(n.len())) else { + self.skipped += 1; + continue; + }; + let values = self.values(row, vars); + vars.datasets.push(DataSet { name, values }); + } + if let Some(active) = xml_attribute(list, "activeDataSet").and_then(|a| library_name(Some(a))) { + vars.active_dataset = vars.dataset(&active).map(|_| active); + } + } + + /// The values of the `sampleDataSet` element `row`, for the variables of `vars`. + fn values(&mut self, row: &Obj, vars: &Variables) -> std::collections::BTreeMap { + let mut values = std::collections::BTreeMap::new(); + let Some(children) = self.children(row) else { + self.skipped += 1; + return values; + }; + for el in children.into_iter().filter(|c| !is_node(c, 3.0)) { + let variable = is_node(el, 1.0).then(|| node_name(el)).flatten().and_then(|n| vars.variable(n.rsplit(':').next().unwrap_or(&n))); + let Some(variable) = variable.filter(|v| !values.contains_key(&v.name)) else { + self.skipped += 1; + continue; + }; + match self.value(el, variable.kind) { + Ok(Some(value)) => { + values.insert(variable.name.clone(), value); + } + Ok(None) => {} + Err(()) => self.skipped += 1, + } + } + values + } + + /// The value of a variable of `kind` that the element `el` holds: `Ok(None)` when it holds none, + /// `Err` when it can't be read (graph values aren't read). + fn value(&mut self, el: &Obj, kind: VariableKind) -> Result, ()> { + let children = self.children(el).ok_or(())?; + if children.is_empty() { + return Ok(None); + } + let value = match kind { + VariableKind::Text => { + let (mut paragraphs, mut total) = (Vec::new(), 0usize); + // Text between the paragraphs is white space the XML had. + for p in children.into_iter().filter(|c| !is_node(c, 3.0)) { + if !is_element(p, "p") { + return Err(()); + } + let mut paragraph = String::new(); + for t in self.children(p).ok_or(())? { + match (is_node(t, 3.0), xml_value(t, "xmlnode-nodevalue")) { + (true, Some(XmlValue::Text(text))) if self.room(total.saturating_add(text.len())) => { + total += text.len(); + paragraph.push_str(&text); + } + _ => return Err(()), + } + } + // The line break that joins it to the next one. + total = total.saturating_add(1); + paragraphs.push(paragraph); + } + if paragraphs.is_empty() { + return Ok(None); + } + DataValue::Text(paragraphs.join("\n")) + } + VariableKind::Visibility => match children.as_slice() { + [t] if is_node(t, 3.0) => match xml_value(t, "xmlnode-nodevalue") { + Some(XmlValue::Bool(show)) => DataValue::Visible(show), + _ => return Err(()), + }, + _ => return Err(()), + }, + VariableKind::Image => { + let mut text = String::new(); + for t in children { + match (is_node(t, 3.0), xml_value(t, "xmlnode-nodevalue")) { + (true, Some(XmlValue::Text(s))) if self.room(text.len().saturating_add(s.len())) => text.push_str(&s), + _ => return Err(()), + } + } + if text.trim_matches([' ', '\t', '\n', '\r']).is_empty() { + return Ok(None); + } + DataValue::Image(linked_path(&text).ok_or(())?) + } + VariableKind::Graph | VariableKind::Unknown => return Err(()), + }; + let len = match &value { + DataValue::Text(s) | DataValue::Image(s) | DataValue::Graph(s) => s.len(), + DataValue::Visible(_) => 1, + }; + if self.keep(len) { Ok(Some(value)) } else { Err(()) } + } +} + +/// The library of the `variableSets` element `sets` (see [`variable_library`]). +fn read_variable_sets(sets: &Obj) -> VariableRead { + let mut walk = LibraryWalk::default(); + let mut vars = Variables::default(); + match walk.children(sets) { + Some(children) => { + let (sets, other): (Vec<&Obj>, Vec<&Obj>) = children.into_iter().filter(|c| !is_node(c, 3.0)).partition(|c| is_element(c, "variableSet")); + // A node that isn't a variable set, and a second set (VectorCraft keeps one set of + // variables), are left out. + walk.skipped += other.len() + sets.len().saturating_sub(1); + if let Some(first) = sets.first() { + walk.variable_set(first, &mut vars); + } + } + None => walk.skipped += 1, + } + if walk.nodes > MAX_LIBRARY_NODES { + return VariableRead { variables: Variables::default(), skipped: 1, present: true }; + } + VariableRead { variables: vars, skipped: walk.skipped, present: true } +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 549df50e6..13316561d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1151,34 +1151,272 @@ fn art_after_the_printers_crop_marks_reads() { assert_eq!(art(&doc).len(), 1); } -/// A variable library in the document data isn't read: a note gives how many variables and data -/// sets it has, counting the elements of the library alone (a data set's element for a variable named -/// `variable` isn't a variable). -#[test] -fn a_variable_library_is_noted_with_its_counts() { - use vectorcraft_testkit::ai::variable_library; +/// The document data `library` (a `variableSets` element) read with a layer holding a square: the +/// variables read and the notes about the library. +fn read_library(library: &str) -> (vectorcraft_doc::Variables, Vec) { let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); - let notes = |library: &str| read_ok(&format!("{library}{square}")).warnings.into_iter().filter(|w| w.contains("variables")).collect::>(); + let r = read_ok(&format!("{}{square}", vectorcraft_testkit::ai::variable_library_data(library))); + let notes = r.warnings.into_iter().filter(|w| w.contains("variable library")).collect(); + ((*r.doc.variables).clone(), notes) +} + +/// The note of a library read but for `n` entries. +fn partly_read(n: usize) -> String { + format!("the file's variable library was partly read (entries skipped: {n}): the Variables panel lists the rest") +} + +/// A variable library in the document data comes in with its variables in order, each of the kind +/// its trait gives (a variable without one is of unknown kind), and without bindings. A library +/// read whole gives no note. +#[test] +fn ai_variable_library_definitions() { + use vectorcraft_doc::VariableKind; + let (vars, notes) = read_library(&vectorcraft_testkit::ai::sample_variable_sets("none", &[])); + let kinds: Vec<(&str, VariableKind)> = vars.variables.iter().map(|v| (v.name.as_str(), v.kind)).collect(); assert_eq!( - notes(&variable_library(&["variable", "Team"], &["Home", "Away", "Third"])), - ["the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them"] + kinds, + [ + ("v_text", VariableKind::Text), + ("v_visible", VariableKind::Visibility), + ("v_file", VariableKind::Image), + ("v_graph", VariableKind::Graph), + ("v_unbound", VariableKind::Unknown) + ] ); + assert!(vars.bindings.is_empty()); + assert!(notes.is_empty(), "{notes:?}"); + // The library of the testkit's text variables, and an empty one. + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read_ok(&format!("{}{square}", vectorcraft_testkit::ai::variable_library(&["variable", "Team"], &["Home", "Away"]))); + let names: Vec<&str> = r.doc.variables.variables.iter().map(|v| v.name.as_str()).collect(); + assert_eq!(names, ["variable", "Team"], "{:?}", r.warnings); + assert_eq!(r.doc.variables.dataset("Away").and_then(|d| d.values.get("Team")), Some(&vectorcraft_doc::DataValue::Text("Away Team".into()))); + let r = read_ok(&format!("{}{square}", vectorcraft_testkit::ai::variable_library(&[], &[]))); + assert_eq!(*r.doc.variables, vectorcraft_doc::Variables::default()); + assert!(!r.warnings.iter().any(|w| w.contains("variable")), "{:?}", r.warnings); +} + +/// The data sets come in in order with their values: text of one paragraph per `p` element (`&`, +/// `<` and accented letters as they are), whether the objects show, and a linked file's path from +/// its URL, kept when it names no file. A variable of unknown kind has no value. +#[test] +fn ai_variable_library_dataset_values() { + use vectorcraft_doc::DataValue; + use vectorcraft_testkit::ai::{SAMPLE_MISSING_FILE, sample_variable_sets}; + let (vars, notes) = read_library(&sample_variable_sets("none", &[])); + assert!(notes.is_empty(), "{notes:?}"); + let names: Vec<&str> = vars.datasets.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["ds_one", "ds_two", "ds_three"]); + let two = &vars.datasets[1].values; + assert_eq!(two.get("v_text"), Some(&DataValue::Text("line one\nline two & < é".into()))); + assert_eq!(two.get("v_visible"), Some(&DataValue::Visible(false))); + assert_eq!(two.get("v_file"), Some(&DataValue::Image(SAMPLE_MISSING_FILE.into()))); + assert!(!std::path::Path::new(SAMPLE_MISSING_FILE).exists()); + assert_eq!(two.len(), 3, "{two:?}"); + let one = &vars.datasets[0].values; + assert_eq!(one.get("v_text"), Some(&DataValue::Text("one".into()))); + assert_eq!(one.get("v_visible"), Some(&DataValue::Visible(true))); + assert_eq!(one.get("v_file"), Some(&DataValue::Image("/Users/test/Art (one)/tile-a.eps".into()))); +} + +/// A data set's value element can carry a namespace prefix. +#[test] +fn ai_variable_library_dataset_values_with_a_prefix() { + use vectorcraft_doc::DataValue; + use vectorcraft_testkit::ai::sample_variable_sets; + let library = sample_variable_sets("none", &[]).replace("%_(v_visible) /String (xmlnode-nodename)", "%_(c:v_visible) /String (xmlnode-nodename)"); + assert!(library.contains("c:v_visible")); + let (vars, notes) = read_library(&library); + assert!(notes.is_empty(), "{notes:?}"); + assert_eq!(vars.datasets[0].values.get("v_visible"), Some(&DataValue::Visible(true))); + assert_eq!(vars.datasets[1].values.get("v_visible"), Some(&DataValue::Visible(false))); +} + +/// The data set shown comes in as the active one, and the lock as it was set. +#[test] +fn ai_variable_library_state() { + use vectorcraft_testkit::ai::sample_variable_sets; + let (vars, notes) = read_library(&sample_variable_sets("variableAndTypes", &[])); + assert_eq!(vars.active_dataset.as_deref(), Some("ds_two")); + assert!(vars.locked, "{notes:?}"); + assert!(notes.is_empty(), "{notes:?}"); + let (vars, _) = read_library(&sample_variable_sets("none", &[])); + assert!(!vars.locked); + assert_eq!(vars.active_dataset.as_deref(), Some("ds_two")); + // A lock VectorCraft doesn't know reads as unlocked, with a note. + let (vars, notes) = read_library(&sample_variable_sets("everything", &[])); + assert!(!vars.locked); + assert_eq!(notes, [partly_read(1)]); + // A data set shown that isn't one of the library's leaves none active. + let library = sample_variable_sets("none", &[]).replace("%_(ds_two) /UnicodeString (xmlnode-nodevalue) ,\n%_2 /Int (xmlnode-nodetype) ,\n%_/Array :\n%_; (xmlnode-children) ,\n%_/Dictionary :\n%_; (xmlnode-attributes) ,\n%_; (i:activeDataSet)", "%_(ds_nine) /UnicodeString (xmlnode-nodevalue) ,\n%_2 /Int (xmlnode-nodetype) ,\n%_/Array :\n%_; (xmlnode-children) ,\n%_/Dictionary :\n%_; (xmlnode-attributes) ,\n%_; (i:activeDataSet)"); + assert!(library.contains("ds_nine")); + let (vars, notes) = read_library(&library); + assert_eq!(vars.active_dataset, None); + assert_eq!(vars.datasets.len(), 3, "{notes:?}"); +} + +/// A value that can't be read is left out with a note, and the values and data sets after it still +/// come in. +#[test] +fn ai_variable_library_bad_sibling() { + use vectorcraft_doc::DataValue; + use vectorcraft_testkit::ai::{XmlValue, sample_variable_sets, xml_element, xml_text}; + // A value for a variable the library doesn't declare, and a second value for `v_visible`. + let more = [xml_element("v_nope", &[], &[xml_text(XmlValue::Str("x"))]), xml_element("v_visible", &[], &[xml_text(XmlValue::Bool(true))])]; + let (vars, notes) = read_library(&sample_variable_sets("none", &more)); + assert_eq!(notes, [partly_read(2)]); + assert_eq!(vars.datasets[1].values.get("v_visible"), Some(&DataValue::Visible(false))); + assert_eq!(vars.datasets[2].values.get("v_text"), Some(&DataValue::Text("three".into()))); + assert_eq!(vars.active_dataset.as_deref(), Some("ds_two")); +} + +/// A graph variable comes in, but its values aren't read: each is left out with a note. +#[test] +fn ai_variable_library_graph_partial() { + use vectorcraft_doc::VariableKind; + use vectorcraft_testkit::ai::{XmlValue, sample_variable_sets, xml_element, xml_text}; + let graph = xml_element("v_graph", &[], &[xml_text(XmlValue::Str("a,b\n1,2"))]); + let (vars, notes) = read_library(&sample_variable_sets("none", &[graph])); + assert_eq!(notes, [partly_read(1)]); + assert_eq!(vars.variable("v_graph").map(|v| v.kind), Some(VariableKind::Graph)); + assert!(vars.datasets.iter().all(|d| !d.values.contains_key("v_graph"))); + assert_eq!(vars.datasets[1].values.len(), 3); +} + +/// Declarations, data sets and values that are malformed, repeated or past the caps are left out +/// and counted; a value is never read as one of another kind. A library nothing can be read from +/// says so. +#[test] +fn ai_variable_library_edge_cases() { + use vectorcraft_doc::{DataValue, VariableKind}; + use vectorcraft_testkit::ai::{XmlValue, xml_element, xml_node, xml_text}; + let set = |locked: &str, declared: Vec, rows: Vec| { + let sets = xml_element("v:sampleDataSets", &[], &rows); + xml_element("variableSets", &[], &[xml_element("variableSet", &[("locked", locked)], &[xml_element("variables", &[], &declared), sets])]) + }; + let var = |name: &str, t: &str| xml_element("variable", &[("varName", name), ("trait", t)], &[]); + let declared = vec![ + var("A", "textcontent"), + // The same name again, no name, an empty one, one that is too long, and an attribute node + // whose name isn't text. + var("A", "visibility"), + xml_element("variable", &[("trait", "textcontent")], &[]), + var(" ", "textcontent"), + var(&"n".repeat(256), "textcontent"), + xml_node(1, "variable", XmlValue::Str(""), &[("varName", XmlValue::Bool(true))], &[]), + // A trait VectorCraft doesn't know: unknown kind. + var("Fancy", "sparkle"), + var("Shown", "visibility"), + var("File", "fileref"), + // Something that isn't a variable. + xml_element("note", &[], &[]), + ]; + let rows = vec![ + xml_element( + "v:sampleDataSet", + &[("dataSetName", "One")], + &[ + xml_element("A", &[], &[xml_element("p", &[], &[xml_text(XmlValue::Unicode(" spaced "))]), xml_element("p", &[], &[])]), + // A value for the unknown kind, a visibility that is text, a file that is a relative + // path. + xml_element("Fancy", &[], &[xml_text(XmlValue::Str("x"))]), + xml_element("Shown", &[], &[xml_text(XmlValue::Str("true"))]), + xml_element("File", &[], &[xml_text(XmlValue::Str("art/tile.eps"))]), + ], + ), + // The same data set again, and one without a name. + xml_element("v:sampleDataSet", &[("dataSetName", "One")], &[]), + xml_element("v:sampleDataSet", &[], &[]), + xml_element( + "v:sampleDataSet", + &[("dataSetName", "Two")], + &[ + // Text whose paragraph holds an element, a file on a drive, a visibility of two texts. + xml_element("A", &[], &[xml_element("p", &[], &[xml_element("b", &[], &[])])]), + xml_element("File", &[], &[xml_text(XmlValue::Str("file:///C:/My%20Art/50%off.eps"))]), + xml_element("Shown", &[], &[xml_text(XmlValue::Bool(true)), xml_text(XmlValue::Bool(false))]), + ], + ), + ]; + let (vars, notes) = read_library(&set("none", declared, rows)); + let kinds: Vec<(&str, VariableKind)> = vars.variables.iter().map(|v| (v.name.as_str(), v.kind)).collect(); assert_eq!( - notes(&variable_library(&["Team"], &["Home"])), - ["the file's variables and data sets weren't read (variables: 1, data sets: 1): the Variables panel doesn't list them"] + kinds, + [("A", VariableKind::Text), ("Fancy", VariableKind::Unknown), ("Shown", VariableKind::Visibility), ("File", VariableKind::Image)] ); - assert!(notes(&variable_library(&[], &[])).is_empty(), "an empty library gives no warning"); - assert!(notes("").is_empty()); - // When the reader can't read the layers, `variables` still counts the library, for the warning - // of the file read from its PDF part. - let unread = stream(&format!("{}{}", variable_library(&["Team"], &["Home", "Away"]), layer("1 Zz\n"))); + let names: Vec<&str> = vars.datasets.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["One", "Two"]); + let one = &vars.datasets[0].values; + assert_eq!(one.get("A"), Some(&DataValue::Text(" spaced \n".into()))); + assert_eq!(one.len(), 1, "{one:?}"); + let two = &vars.datasets[1].values; + assert_eq!(two.get("File"), Some(&DataValue::Image("C:/My Art/50%off.eps".into()))); + assert_eq!(two.len(), 1, "{two:?}"); + // 6 declarations, 2 data sets and 5 values. + assert_eq!(notes, [partly_read(13)]); + + // Children that aren't an array: nothing can be read. + let broken = xml_element("variableSets", &[], &[xml_element("variableSet", &[], &[])]).replace( + "%_/Array :\n%_; (xmlnode-children) ,\n%_/Dictionary :\n%_; (xmlnode-attributes) ,\n%_; ,", + "%_/Dictionary :\n%_; (xmlnode-children) ,\n%_/Dictionary :\n%_; (xmlnode-attributes) ,\n%_; ,", + ); + assert!(broken.contains("%_/Dictionary :\n%_; (xmlnode-children)")); + let (vars, notes) = read_library(&broken); + assert_eq!(vars, vectorcraft_doc::Variables::default()); + assert_eq!(notes, ["the file's variable library couldn't be read: the Variables panel doesn't list it"]); + + // Past the caps: 256 variables and 1,000 data sets are kept. + let declared: Vec = (0..257).map(|i| var(&format!("v{i}"), "textcontent")).collect(); + let rows: Vec = (0..1001).map(|i| xml_element("v:sampleDataSet", &[("dataSetName", &format!("d{i}"))], &[])).collect(); + let (vars, notes) = read_library(&set("none", declared, rows)); + assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1000)); + assert_eq!(notes, [partly_read(2)]); + // A text longer than the library's 8 MiB is left out, and so is text past what is left of them. + let long = |name: &str, len: usize| { + let text = xml_element("p", &[], &[xml_text(XmlValue::Str(&"x".repeat(len)))]); + xml_element("v:sampleDataSet", &[("dataSetName", name)], &[xml_element("A", &[], &[text])]) + }; + let rows = vec![long("Big", (8 << 20) + 1), long("Half", 5 << 20), long("More", 4 << 20)]; + let (vars, notes) = read_library(&set("none", vec![var("A", "textcontent")], rows)); + let held: Vec<(&str, usize)> = vars.datasets.iter().map(|d| (d.name.as_str(), d.values.len())).collect(); + assert_eq!(held, [("Big", 0), ("Half", 1), ("More", 0)]); + assert_eq!(notes, [partly_read(2)]); + // Linked files on a network share, and on `localhost`. + let file = |name: &str, url: &str| { + xml_element("v:sampleDataSet", &[("dataSetName", name)], &[xml_element("File", &[], &[xml_text(XmlValue::Str(url))])]) + }; + let rows = vec![file("Share", "file://server/My%20Share/a.eps"), file("Local", "file://localhost/Users/test/a.eps")]; + let (vars, notes) = read_library(&set("none", vec![var("File", "fileref")], rows)); + let paths: Vec> = vars.datasets.iter().map(|d| d.values.get("File")).collect(); + assert_eq!(paths, [Some(&DataValue::Image("//server/My Share/a.eps".into())), Some(&DataValue::Image("/Users/test/a.eps".into()))]); + assert!(notes.is_empty(), "{notes:?}"); + // A library whose only node isn't a variable set. + let (_, notes) = read_library(&xml_element("variableSets", &[], &[xml_element("note", &[], &[])])); + assert_eq!(notes, ["the file's variable library couldn't be read: the Variables panel doesn't list it"]); + // A second variable set is left out. + let two_sets = xml_element("variableSets", &[], &[xml_element("variableSet", &[], &[]), xml_element("variableSet", &[], &[])]); + let (_, notes) = read_library(&two_sets); + assert_eq!(notes, ["the file's variable library couldn't be read: the Variables panel doesn't list it"]); +} + +/// When the layers can't be read, or reading stops before the library, [`variables`] still reads +/// it, for the file read from its PDF part. +#[test] +fn ai_variable_library_without_the_layers() { + use vectorcraft_testkit::ai::{sample_variable_sets, variable_library_data}; + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let library = variable_library_data(&sample_variable_sets("variableAndTypes", &[])); + let unread = stream(&format!("{library}{}", layer("1 Zz\n"))); assert!(read(unread.as_bytes()).is_err()); - assert_eq!(variables(unread.as_bytes()), read::VariableLibrary { variables: 1, data_sets: 2 }); - // The same when the reading stops before it reaches the library (at an `LB` without its layer). - let early = stream(&format!("LB\n{}{square}", variable_library(&["Team"], &["Home", "Away"]))); + let found = variables(unread.as_bytes()); + assert!(found.present && found.variables.locked && found.skipped == 0); + assert_eq!((found.variables.variables.len(), found.variables.datasets.len()), (5, 3)); + assert_eq!(found.variables.active_dataset.as_deref(), Some("ds_two")); + // The reading stops before it reaches the library (at an `LB` without its layer). + let early = stream(&format!("LB\n{library}{square}")); assert!(read(early.as_bytes()).is_err()); - assert_eq!(variables(early.as_bytes()), read::VariableLibrary { variables: 1, data_sets: 2 }); - assert_eq!(variables(stream(&square).as_bytes()), read::VariableLibrary::default()); + assert_eq!(variables(early.as_bytes()), found); + assert_eq!(variables(stream(&square).as_bytes()), read::VariableRead::default()); } #[test] diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index e8f25370c..7f91438be 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -7,7 +7,8 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why, with the swatches of the -/// editing copy's palette ([`use_palette`]) when it could be read, and with its layer tree, empty, +/// editing copy's palette ([`use_palette`]) and its variable library ([`use_variables`]) when it +/// could be read, and with its layer tree, empty, /// below the page's layer when that could be read (#2033). A program in the legacy format without /// layers (`illustrator`, see [`program_alone`]) is read with its art on one layer; when that /// can't be used, the file is `visible` as it was. @@ -37,6 +38,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus let mut v = visible; if let Ok(data) = &data { use_palette(data, &mut v.document); + use_variables(data, &mut v.document, &mut v.warnings); } if tree.is_empty() { v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); @@ -95,8 +97,9 @@ fn layer_tree(nodes: &[Arc]) -> Vec> { /// notes, or a note saying why the layers weren't read when the editing data can't be decompressed /// or its layers can't be read. The flag returned is `true` when the document is the one read from /// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing -/// data was decompressed but its layers can't be read, and its swatches those of the editing data's -/// palette ([`use_palette`]) when it was decompressed. `outlined`: the PDF part's type was read as +/// data was decompressed but its layers can't be read, and its swatches and variables those of the +/// editing data's palette ([`use_palette`]) and variable library ([`use_variables`]) when it was +/// decompressed. `outlined`: the PDF part's type was read as /// outlines (a file whose type shows then comes in as its PDF part). /// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. /// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, @@ -113,8 +116,9 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl Err(why) => return (visible, unread(warnings, &why), false), }; if !has_layers(&data) { - let mut visible = visible; + let (mut visible, mut warnings) = (visible, warnings); use_palette(&data, &mut visible); + use_variables(&data, &mut visible, &mut warnings); return (visible, warnings, false); } // The PDF part, its pages placed and named as the editing data's artboards are, when the @@ -123,7 +127,7 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl place_pages(&ai::artboards(&data), &mut visible, unit); use_palette(&data, &mut visible); let mut all = unread(warnings, why); - all.extend(ai::variables(&data).note()); + use_variables(&data, &mut visible, &mut all); (visible, all, false) }; match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { @@ -142,6 +146,18 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl } } +/// `doc` (a file read as its page or PDF part, in place of its layers) with the variables, data +/// sets, active data set and lock of the variable library of its editing data `data`, as [`build`] +/// gives a document read from the layers, and the note about what of the library wasn't read +/// added to `warnings`. Left as it is when the editing data has no library. +fn use_variables(data: &[u8], doc: &mut Document, warnings: &mut Vec) { + let read = ai::variables(data); + warnings.extend(read.note()); + if read.present { + doc.variables = read.variables.into(); + } +} + /// `doc` (a `.ai` read as its PDF part, in place of its layers) with the swatches and color groups /// of the palette of its editing data `data` in place of the default ones, as [`build`] gives a /// document read from the layers. The spot and pattern swatches of the PDF part's art that the diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 163718947..d39eb89aa 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1135,6 +1135,46 @@ fn an_ai_file_without_layers_keeps_the_patterns_of_its_palette() { assert!(keys.iter().all(|k| d.images.contains_key(k)), "{keys:?}"); } +/// A `.ai` that comes in as its PDF part, whose layers can't be used or that has none, gets the +/// variables, data sets, active data set and lock of its editing data's variable library, and so +/// does an EPS that comes in as its page. +#[test] +fn ai_variable_library_pdf_fallback() { + use vectorcraft_testkit::ai::{XmlValue, sample_variable_sets, variable_library_data, xml_element, xml_text}; + let check = |d: &Document, warnings: &[String]| { + let vars = &d.variables; + assert_eq!((vars.variables.len(), vars.datasets.len()), (5, 3), "{warnings:?}"); + assert!(vars.locked); + assert_eq!(vars.active_dataset.as_deref(), Some("ds_two")); + }; + let library = variable_library_data(&sample_variable_sets("variableAndTypes", &[])); + // Layers that can't be used (an operator the reader doesn't know). + let art = native(&format!("{library}{}", layer("Art", true, "1 Zz"))); + let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + check(&d, &warnings); + assert!(!warnings.iter().any(|w| w.contains("variable library")), "{warnings:?}"); + // No layers at all. + let (d, warnings, read) = crate::layered_ai(&private(&native(&library)), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + check(&d, &warnings); + // An EPS that comes in as its page. + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + check(&r.document, &r.warnings); + // What can't be read is noted on each path. + let graph = xml_element("v_graph", &[], &[xml_text(XmlValue::Str("a,b"))]); + let partly = variable_library_data(&sample_variable_sets("variableAndTypes", &[graph])); + let note = "the file's variable library was partly read (entries skipped: 1): the Variables panel lists the rest".to_string(); + let art = native(&format!("{partly}{}", layer("Art", true, "1 Zz"))); + let (_, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(warnings.contains(¬e), "{warnings:?}"); + let (_, warnings, _) = crate::layered_ai(&private(&native(&partly)), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(warnings.contains(¬e), "{warnings:?}"); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert!(r.warnings.contains(¬e), "{:?}", r.warnings); +} + /// An EPS whose editing data can't be used, a palette with no layers among others, comes in as its /// page with the swatches and color groups of that palette in place of the defaults. #[test] diff --git a/crates/testkit/src/ai.rs b/crates/testkit/src/ai.rs index 6cb7de2ed..b560255e3 100644 --- a/crates/testkit/src/ai.rs +++ b/crates/testkit/src/ai.rs @@ -1,6 +1,6 @@ //! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the //! EPS, `.ai` and PDF files that carry it, for import tests. Nothing here comes from files another -//! app wrote, except the key names that [`pdf`] uses, the XML node keys of [`variable_library`] and +//! app wrote, except the key names that [`pdf`] uses, the XML node keys of [`xml_node`] and //! the keys of [`on_path_record`] and [`on_path_member`]. /// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop @@ -41,41 +41,174 @@ pub fn named(name: &str) -> String { format!("%_/ArtDictionary :\n%_/XMLUID : ({}) ; (AI10_ArtUID) ,\n%_;\n%_\n", name.replace(' ', "_")) } -/// The document data with a variable library: a `/Document` dictionary whose `Recorded` dictionary -/// holds the library's XML document as XML nodes (`/XMLNode`) under `#document`. The library has -/// one variable set, the text variables `variables` and the data sets `data_sets`, each with a `p` -/// element for every variable. -pub fn variable_library(variables: &[&str], data_sets: &[&str]) -> String { - // A node: its name, value and type (1 an element, 2 an attribute, 3 text, 9 the document), its - // children and its attributes. - fn node(kind: u8, name: &str, value: &str, attributes: &[(&str, &str)], children: &[String]) -> String { - let attributes: String = attributes.iter().map(|(k, v)| node(2, k, v, &[], &[node(3, "#text", v, &[], &[])])).collect(); - format!( - "%_/XMLNode :\n%_({name}) /String (xmlnode-nodename) ,\n%_({value}) /String (xmlnode-nodevalue) ,\n%_{kind} /Int (xmlnode-nodetype) ,\n\ - %_/Array :\n{}%_; (xmlnode-children) ,\n%_/Array :\n{attributes}%_; (xmlnode-attributes) ,\n%_;\n", - children.concat() - ) +/// The value of an XML node of the document data ([`xml_node`]), with the type it is written with. +#[derive(Clone, Copy, Debug)] +pub enum XmlValue<'a> { + /// A `/String`. + Str(&'a str), + /// A `/UnicodeString`. + Unicode(&'a str), + /// A `/Bool`, `0` or `1`. + Bool(bool), + /// Anything else, written as it is before the key (`5 /Int`). + Raw(&'a str), +} + +/// `s` as PostScript string literals on `%_` lines: `\`, `(` and `)` escaped, and a string longer +/// than 40 bytes split into literals of at most 40 bytes on lines of their own, the last one left +/// open for what follows it on its line. +fn ps_strings(s: &str) -> String { + let escaped = |part: &str| part.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)"); + let mut parts = vec![]; + let mut start = 0; + for (at, c) in s.char_indices() { + if at + c.len_utf8() - start > 40 { + parts.push(&s[start..at]); + start = at; + } } - let element = |name: &str, attributes: &[(&str, &str)], children: &[String]| node(1, name, "", attributes, children); - let declared: Vec = variables.iter().map(|v| element("variable", &[("varName", v), ("trait", "textcontent")], &[])).collect(); + parts.push(&s[start..]); + let Some((last, first)) = parts.split_last() else { return String::new() }; + let lines: String = first.iter().map(|p| format!("%_({})\n", escaped(p))).collect(); + format!("{lines}%_({})", escaped(last)) +} + +/// `value` written before a key on a `%_` line, the line's `%_` included. +fn xml_value(value: XmlValue) -> String { + match value { + XmlValue::Str("") => "%_ /String".into(), + XmlValue::Str(s) => format!("{} /String", ps_strings(s)), + XmlValue::Unicode(s) => format!("{} /UnicodeString", ps_strings(s)), + XmlValue::Bool(b) => format!("%_{} /Bool", u8::from(b)), + XmlValue::Raw(r) => format!("%_{r}"), + } +} + +/// An XML node of the document data (`/XMLNode`): its name, its value, its type `kind` (1 an +/// element, 2 an attribute, 3 text, 9 the document), its children (an array) and its attribute +/// nodes (a dictionary, each under its name). +pub fn xml_node(kind: u8, name: &str, value: XmlValue, attributes: &[(&str, XmlValue)], children: &[String]) -> String { + let attributes: String = attributes.iter().map(|(k, v)| format!("{}%_; ({k}) ,\n", xml_node_open(2, k, *v, &[], ""))).collect(); + format!("{}%_;\n", xml_node_open(kind, name, value, children, &attributes)) +} + +/// [`xml_node`] up to the `;` that closes it. +fn xml_node_open(kind: u8, name: &str, value: XmlValue, children: &[String], attributes: &str) -> String { + format!( + "%_/XMLNode :\n{} (xmlnode-nodename) ,\n{} (xmlnode-nodevalue) ,\n%_{kind} /Int (xmlnode-nodetype) ,\n\ + %_/Array :\n{}%_; (xmlnode-children) ,\n%_/Dictionary :\n{attributes}%_; (xmlnode-attributes) ,\n", + xml_value(XmlValue::Str(name)), + xml_value(value), + // Each child is closed as an item of the array. + children.iter().map(|c| format!("{}%_; ,\n", c.strip_suffix("%_;\n").unwrap_or(c))).collect::() + ) +} + +/// An element `name` with the attributes `attributes` (each a `/UnicodeString`) and the children +/// `children`. +pub fn xml_element(name: &str, attributes: &[(&str, &str)], children: &[String]) -> String { + let attributes: Vec<(&str, XmlValue)> = attributes.iter().map(|(k, v)| (*k, XmlValue::Unicode(v))).collect(); + xml_node(1, name, XmlValue::Str(""), &attributes, children) +} + +/// A text node (`#text`) holding `value`. +pub fn xml_text(value: XmlValue) -> String { + xml_node(3, "#text", value, &[], &[]) +} + +/// The document data with the XML nodes `library` (a `variableSets` element): a `/Document` +/// dictionary whose `Recorded` dictionary holds them as the XML document `#document`, in its +/// `metadata` element. +pub fn variable_library_data(library: &str) -> String { + let document = xml_node(9, "#document", XmlValue::Str(""), &[], &[xml_element("metadata", &[], &[library.to_string()])]); + let document = document.strip_suffix("%_;\n").unwrap_or(&document); + format!("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n{document}%_; (#document) ,\n%_; /Recorded ,\n%_;\n%AI9_EndDocumentData\n") +} + +/// The document data with a variable library ([`variable_library_data`]) of one variable set, +/// unlocked, with the text variables `variables` and the data sets `data_sets`, each with a `p` +/// element for every variable (` `). +pub fn variable_library(variables: &[&str], data_sets: &[&str]) -> String { + let declared: Vec = variables.iter().map(|v| xml_element("variable", &[("trait", "textcontent"), ("varName", v)], &[])).collect(); let sets: Vec = data_sets .iter() .map(|d| { - let values: Vec = - variables.iter().map(|v| element(v, &[], &[element("p", &[], &[node(3, "#text", &format!("{d} {v}"), &[], &[])])])).collect(); - element("v:sampleDataSet", &[("dataSetName", d)], &values) + let values: Vec = variables + .iter() + .map(|v| xml_element(v, &[], &[xml_element("p", &[], &[xml_text(XmlValue::Unicode(&format!("{d} {v}")))])])) + .collect(); + xml_element("v:sampleDataSet", &[("dataSetName", d)], &values) }) .collect(); - let set = element( + let set = xml_element( "variableSet", - &[("varSetName", "binding1"), ("locked", "none")], - &[element("variables", &[], &declared), element("v:sampleDataSets", &[("xmlns", "&ns_custom;"), ("xmlns:v", "&ns_vars;")], &sets)], + &[("locked", "none"), ("varSetName", "binding1")], + &[xml_element("variables", &[], &declared), xml_element("v:sampleDataSets", &[("xmlns", "&ns_custom;"), ("xmlns:v", "&ns_vars;")], &sets)], ); - let library = element("metadata", &[], &[element("variableSets", &[("xmlns", "&ns_vars;")], &[set])]); - format!( - "%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n{}%_; (#document) ,\n%_; /Recorded ,\n%_;\n%AI9_EndDocumentData\n", - node(9, "#document", "", &[], &[library]) - ) + variable_library_data(&xml_element("variableSets", &[("xmlns", "&ns_vars;")], &[set])) +} + +/// The linked file of [`sample_variable_sets`]' second data set: a path that names no file. +pub const SAMPLE_MISSING_FILE: &str = "/Users/test/Missing Folder (old)/a linked file that isn't there.eps"; + +/// A `variableSets` element for [`variable_library_data`]: one variable set, its `locked` attribute +/// `locked`, with the variables `v_text` (text), `v_visible` (visibility), `v_file` (a linked file), +/// `v_graph` (a graph) and `v_unbound` (no kind), and the data sets `ds_one`, `ds_two` (the one +/// shown) and `ds_three`. `ds_two`'s text is two paragraphs, `line one` and `line two & < é`, its +/// visibility false, its linked file [`SAMPLE_MISSING_FILE`] and `v_unbound` empty; the elements +/// `more` follow its values. No data set has a graph value. +pub fn sample_variable_sets(locked: &str, more: &[String]) -> String { + let declare = |name: &str, kind: Option<(&str, &str)>, art: Option<&str>| { + let mut attributes = vec![]; + if let Some((t, category)) = kind { + attributes.push(("trait", XmlValue::Str(t))); + attributes.push(("category", XmlValue::Str(category))); + } + attributes.push(("varName", XmlValue::Unicode(name))); + let art = art.map(|a| format!("/XMLUIDREF : ({a}) ;")); + if let Some(a) = &art { + attributes.push(("docRef", XmlValue::Raw(a))); + } + xml_node(1, "variable", XmlValue::Str(""), &attributes, &[]) + }; + let declared = vec![ + declare("v_text", Some(("textcontent", "&ns_flows;")), Some("text_x5F_1")), + declare("v_visible", Some(("visibility", "&ns_vars;")), Some("path_x5F_1")), + declare("v_file", Some(("fileref", "&ns_vars;")), Some("image_x5F_1")), + declare("v_graph", Some(("graphdata", "&ns_vars;")), None), + declare("v_unbound", None, None), + ]; + let paragraph = |text: &str| xml_element("p", &[], &[xml_text(XmlValue::Unicode(text))]); + let row = |name: &str, text: &[&str], show: bool, file: &str, more: &[String]| { + let mut values = vec![ + xml_element("v_text", &[], &text.iter().map(|t| paragraph(t)).collect::>()), + xml_element("v_visible", &[], &[xml_text(XmlValue::Bool(show))]), + xml_element("v_file", &[], &[xml_text(XmlValue::Str(&format!("file:///{file}")))]), + xml_element("v_unbound", &[], &[]), + ]; + values.extend_from_slice(more); + xml_node(1, "v:sampleDataSet", XmlValue::Str(""), &[("dataSetName", XmlValue::Unicode(name))], &values) + }; + let rows = [ + row("ds_one", &["one"], true, "/Users/test/Art (one)/tile-a.eps", &[]), + row("ds_two", &["line one", "line two & < é"], false, SAMPLE_MISSING_FILE, more), + row("ds_three", &["three"], true, "/Users/test/Art (one)/tile-b.eps", &[]), + ]; + let data_sets = xml_node( + 1, + "v:sampleDataSets", + XmlValue::Str(""), + &[("i:activeDataSet", XmlValue::Unicode("ds_two")), ("xmlns", XmlValue::Str("&ns_custom;")), ("xmlns:v", XmlValue::Str("&ns_vars;"))], + &rows, + ); + let set = xml_node( + 1, + "variableSet", + XmlValue::Str(""), + &[("varSetName", XmlValue::Str("binding1")), ("locked", XmlValue::Str(locked))], + &[xml_element("variables", &[], &declared), data_sets], + ); + xml_element("variableSets", &[("xmlns", "&ns_vars;")], &[set]) } /// The document data with an Objects on Path record: a `/Document` dictionary whose `Constraint diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index 61c82f4ad..a18fc8597 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -145,6 +145,8 @@ fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap json!(""), (None, VariableKind::Visibility) => json!(true), + // A variable of unknown kind takes no value, so it has no field. + (None, VariableKind::Unknown) => continue, }; out.insert(value_key(&v.name), value); } @@ -233,6 +235,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { text_area(ui, d, &key, 220.0); } VariableKind::Visibility => checkbox(ui, d, &key, &v.name), + VariableKind::Unknown => {} } ui.end_row(); } @@ -305,6 +308,7 @@ fn values_of(d: &Dialog, vars: &Variables) -> Value { VariableKind::Visibility => { out.insert(v.name.clone(), json!(d.bool(&key))); } + VariableKind::Unknown => {} } } Value::Object(out) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index cd7c81359..073f57715 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5529,7 +5529,9 @@ theme Appearance Външен вид Effect Gallery Галерия с ефекти Filters Филтри Select artwork to apply effects Изберете графика, за да приложите ефекти -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them променливите и наборите от данни на файла не бяха прочетени (променливи: {variables}, набори от данни: {data_sets}): панелът „Променливи“ не ги показва +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest библиотеката с променливи на файла беше прочетена частично (пропуснати записи: {_1}): панелът „Променливи“ показва останалите +@msg the file's variable library couldn't be read: the Variables panel doesn't list it библиотеката с променливи на файла не можа да бъде прочетена: панелът „Променливи“ не я показва +@msg a variable of unknown kind can't be bound to objects променлива от неизвестен вид не може да бъде свързана с обекти # Select by Color (select.color) Select by Color Избиране по цвят Move Anchor Point Преместване на опорна точка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ce4f9b1fb..80e02fbe4 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5550,7 +5550,9 @@ mark-style Japanese Japanski Effect Gallery Galerija efekata Filters Filteri Select artwork to apply effects Odaberite crtež za primjenu efekata -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them varijable i skupovi podataka datoteke nisu pročitani (varijable: {variables}, skupovi podataka: {data_sets}): panel Varijable ih ne navodi +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest biblioteka varijabli datoteke je djelimično pročitana (preskočeni unosi: {_1}): panel Varijable navodi ostalo +@msg the file's variable library couldn't be read: the Variables panel doesn't list it biblioteku varijabli datoteke nije bilo moguće pročitati: panel Varijable je ne navodi +@msg a variable of unknown kind can't be bound to objects varijabla nepoznate vrste ne može se povezati s objektima Select by Color Odaberi po boji Move Anchor Point Pomjeri sidrenu tačku @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} zadržava 1 od {n} radnih ploča dokumenta: datoteka se ponovo otvara s jednom. Uključite Očuvaj mogućnosti uređivanja da ih zadržite sve. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 6cf1e5ae4..b6985fb13 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5515,7 +5515,9 @@ digits Malayalam Malaiàlam @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer no s’han llegit les capes del fitxer ({_1}): arriba com la seva pàgina, en una sola capa @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part no s’han llegit les capes del fitxer a partir de les seves dades d’edició ({_1}): provenen de la seva part PDF @msg the file's preview header points outside the file la capçalera de previsualització del fitxer apunta fora del fitxer -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them no s’han llegit les variables ni els conjunts de dades del fitxer (variables: {variables}, conjunts de dades: {data_sets}): el panell Variables no els llista +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest la biblioteca de variables del fitxer s’ha llegit només en part (entrades omeses: {_1}): el panell Variables llista la resta +@msg the file's variable library couldn't be read: the Variables panel doesn't list it no s’ha pogut llegir la biblioteca de variables del fitxer: el panell Variables no la llista +@msg a variable of unknown kind can't be bound to objects una variable de tipus desconegut no es pot vincular a objectes @msg the files come to more than 1 GB: add fewer at a time els fitxers sumen més d’1 GB: afegeix-ne menys alhora @msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} El processador gràfic provat primer no ha pogut mostrar la finestra, així que VectorCraft s’ha iniciat de nou amb {summary} @msg The graphics were lost and couldn't be restarted. Els gràfics s’han perdut i no s’han pogut reiniciar. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 310edbd4b..367be32b2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5639,7 +5639,9 @@ mark-style Japanese Japonské Effect Gallery Galerie efektů Filters Filtry Select artwork to apply effects Vyberte grafiku, na kterou chcete použít efekty -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them proměnné a sady dat souboru nebyly načteny (proměnné: {variables}, sady dat: {data_sets}): panel Proměnné je nezobrazuje +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest knihovna proměnných souboru byla načtena jen částečně (vynechané položky: {_1}): panel Proměnné zobrazuje zbytek +@msg the file's variable library couldn't be read: the Variables panel doesn't list it knihovnu proměnných souboru nebylo možné načíst: panel Proměnné ji nezobrazuje +@msg a variable of unknown kind can't be bound to objects proměnnou neznámého druhu nelze svázat s objekty # Select by Color (select.color) Select by Color Vybrat podle barvy Move Anchor Point Přesunout kotevní bod diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index c0bdb184b..7b077a50a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5216,6 +5216,9 @@ mark-style Japanese Japansk Path Length Stilængde @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer filens lag blev ikke læst ({_1}): den kommer ind som sin side, i ét lag @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part filens lag blev ikke læst fra dens redigeringsdata ({_1}): de kommer fra dens PDF-del +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest filens variabelbibliotek blev kun delvist læst (udeladte poster: {_1}): variabelpanelet viser resten +@msg the file's variable library couldn't be read: the Variables panel doesn't list it filens variabelbibliotek kunne ikke læses: variabelpanelet viser det ikke +@msg a variable of unknown kind can't be bound to objects en variabel af ukendt type kan ikke bindes til objekter @msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` skal være grayscale, rgb, hsb, cmyk eller websafe @msg the Color Picker is not open farvevælgeren er ikke åben @msg the screen color sampler is already open skærmfarvesampleren er allerede åben diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c85c90da2..c87b45d22 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5219,7 +5219,9 @@ effect Soft Weich Effect Gallery Effekt-Galerie Filters Filter Select artwork to apply effects Wählen Sie Grafik aus, um Effekte anzuwenden -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them die Variablen und Datensätze der Datei wurden nicht gelesen (Variablen: {variables}, Datensätze: {data_sets}): Das Bedienfeld „Variablen“ listet sie nicht auf +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest die Variablenbibliothek der Datei wurde nur teilweise gelesen (übersprungene Einträge: {_1}): Das Bedienfeld „Variablen“ listet den Rest auf +@msg the file's variable library couldn't be read: the Variables panel doesn't list it die Variablenbibliothek der Datei konnte nicht gelesen werden: Das Bedienfeld „Variablen“ listet sie nicht auf +@msg a variable of unknown kind can't be bound to objects eine Variable unbekannter Art kann nicht an Objekte gebunden werden # Select by Color (select.color) Select by Color Nach Farbe auswählen Move Anchor Point Ankerpunkt verschieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ac02f4986..615e0e6ee 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5590,7 +5590,9 @@ effect Points Σημεία Effect Gallery Συλλογή εφέ Filters Φίλτρα Select artwork to apply effects Επιλέξτε γραφικά για να εφαρμόσετε εφέ -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them οι μεταβλητές και τα σύνολα δεδομένων του αρχείου δεν διαβάστηκαν (μεταβλητές: {variables}, σύνολα δεδομένων: {data_sets}): ο πίνακας Μεταβλητές δεν τα εμφανίζει +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest η βιβλιοθήκη μεταβλητών του αρχείου διαβάστηκε μόνο εν μέρει (καταχωρίσεις που παραλείφθηκαν: {_1}): ο πίνακας Μεταβλητές εμφανίζει τις υπόλοιπες +@msg the file's variable library couldn't be read: the Variables panel doesn't list it δεν ήταν δυνατή η ανάγνωση της βιβλιοθήκης μεταβλητών του αρχείου: ο πίνακας Μεταβλητές δεν την εμφανίζει +@msg a variable of unknown kind can't be bound to objects μια μεταβλητή άγνωστου είδους δεν μπορεί να συνδεθεί με αντικείμενα # Select by Color (select.color) Select by Color Επιλογή βάσει χρώματος Move Anchor Point Μετακίνηση σημείου αγκύρωσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ef00c08fa..c4d4a196b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5580,7 +5580,9 @@ mark-style Japanese Japonesas Effect Gallery Galería de efectos Filters Filtros Select artwork to apply effects Seleccione gráficos para aplicar efectos -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them no se leyeron las variables ni los conjuntos de datos del archivo (variables: {variables}, conjuntos de datos: {data_sets}): el panel Variables no los muestra +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest la biblioteca de variables del archivo se leyó solo en parte (entradas omitidas: {_1}): el panel Variables muestra el resto +@msg the file's variable library couldn't be read: the Variables panel doesn't list it no se pudo leer la biblioteca de variables del archivo: el panel Variables no la muestra +@msg a variable of unknown kind can't be bound to objects una variable de tipo desconocido no se puede vincular a objetos # Select by Color (select.color) Select by Color Seleccionar por color Move Anchor Point Mover punto de ancla diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c7b018f80..7d571168f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5602,7 +5602,9 @@ mark-style Japanese Japanilainen Effect Gallery Tehostegalleria Filters Suodattimet Select artwork to apply effects Valitse kuvitus, johon tehosteet lisätään -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them tiedoston muuttujia ja tietojoukkoja ei luettu (muuttujia: {variables}, tietojoukkoja: {data_sets}): Muuttujat-paneeli ei näytä niitä +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest tiedoston muuttujakirjasto luettiin vain osittain (ohitettuja kohtia: {_1}): Muuttujat-paneeli näyttää loput +@msg the file's variable library couldn't be read: the Variables panel doesn't list it tiedoston muuttujakirjastoa ei voitu lukea: Muuttujat-paneeli ei näytä sitä +@msg a variable of unknown kind can't be bound to objects tuntemattoman tyyppistä muuttujaa ei voi sitoa objekteihin # Select by Color (select.color) Select by Color Valitse värin mukaan Angular Kulma diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 11bef0de4..4372280db 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5634,7 +5634,9 @@ theme Appearance Apparence Effect Gallery Galerie d'effets Filters Filtres Select artwork to apply effects Sélectionnez des éléments graphiques pour appliquer des effets -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them les variables et jeux de données du fichier n’ont pas été lus (variables : {variables}, jeux de données : {data_sets}) : le panneau Variables ne les affiche pas +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest la bibliothèque de variables du fichier n’a été lue qu’en partie (entrées ignorées : {_1}) : le panneau Variables affiche le reste +@msg the file's variable library couldn't be read: the Variables panel doesn't list it la bibliothèque de variables du fichier n’a pas pu être lue : le panneau Variables ne l’affiche pas +@msg a variable of unknown kind can't be bound to objects une variable de type inconnu ne peut pas être liée à des objets # Select by Color (select.color) Select by Color Sélectionner par couleur Move Anchor Point Déplacer le point d’ancrage diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index eb67ac75d..9890ecf5a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5622,7 +5622,9 @@ imageTrace Outline Körvonal Effect Gallery Effektusgaléria Filters Szűrők Select artwork to apply effects Válasszon ki grafikát az effektusok alkalmazásához -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them a fájl változói és adatkészletei nem lettek beolvasva (változók: {variables}, adatkészletek: {data_sets}): a Változók panel nem listázza őket +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest a fájl változókönyvtára csak részben lett beolvasva (kihagyott bejegyzések: {_1}): a Változók panel a többit listázza +@msg the file's variable library couldn't be read: the Variables panel doesn't list it a fájl változókönyvtárát nem sikerült beolvasni: a Változók panel nem listázza +@msg a variable of unknown kind can't be bound to objects ismeretlen típusú változó nem köthető objektumokhoz # Select by Color (select.color) Select by Color Kijelölés szín szerint Move Anchor Point Horgonypont áthelyezése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index fabe83bc6..d8ceeacad 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5216,6 +5216,9 @@ mark-style Japanese Jepang Path Length Panjang jalur @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer lapisan file tidak terbaca ({_1}): file masuk sebagai halamannya, dalam satu lapisan @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part lapisan file tidak terbaca dari data pengeditannya ({_1}): lapisannya berasal dari bagian PDF file +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest pustaka variabel file hanya terbaca sebagian (entri yang dilewati: {_1}): panel Variabel menampilkan sisanya +@msg the file's variable library couldn't be read: the Variables panel doesn't list it pustaka variabel file tidak dapat dibaca: panel Variabel tidak menampilkannya +@msg a variable of unknown kind can't be bound to objects variabel bertipe tidak dikenal tidak dapat diikat ke objek @msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` harus grayscale, rgb, hsb, cmyk atau websafe @msg the Color Picker is not open Pemilih Warna tidak terbuka @msg the screen color sampler is already open pengambil warna layar sudah terbuka diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index dfd836c86..1f6ffb4cf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5253,7 +5253,9 @@ lighting Light Luce Effect Gallery Galleria effetti Filters Filtri Select artwork to apply effects Seleziona la grafica a cui applicare gli effetti -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them le variabili e i set di dati del file non sono stati letti (variabili: {variables}, set di dati: {data_sets}): il pannello Variabili non li elenca +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest la libreria di variabili del file è stata letta solo in parte (voci saltate: {_1}): il pannello Variabili elenca il resto +@msg the file's variable library couldn't be read: the Variables panel doesn't list it impossibile leggere la libreria di variabili del file: il pannello Variabili non la elenca +@msg a variable of unknown kind can't be bound to objects una variabile di tipo sconosciuto non può essere associata a oggetti # Select by Color (select.color) Select by Color Seleziona per colore Move Anchor Point Sposta punto di ancoraggio diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 674e56cf3..3d6849e46 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5296,7 +5296,9 @@ lighting Light ライト Effect Gallery 効果ギャラリー Filters フィルター Select artwork to apply effects 効果を適用するグラフィックを選択してください -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ファイルの変数とデータセットは読み込まれませんでした (変数: {variables}、データセット: {data_sets})。変数パネルには表示されません +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest ファイルの変数ライブラリは一部だけ読み込まれました (スキップした項目: {_1})。残りは変数パネルに表示されます +@msg the file's variable library couldn't be read: the Variables panel doesn't list it ファイルの変数ライブラリを読み込めませんでした。変数パネルには表示されません +@msg a variable of unknown kind can't be bound to objects 種類が不明な変数はオブジェクトにバインドできません # Select by Color (select.color) Select by Color カラーで選択 Move Anchor Point アンカーポイントを移動 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 89171df20..1a01580e2 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5505,7 +5505,9 @@ imageTrace Outline კონტური @msg no history to clear გასასუფთავებელი ისტორია არ არის @msg {_1} files are written by file.saveAsTemplate {_1} ფაილებს წერს file.saveAsTemplate @msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 წერს ძველი ფორმატის .ai ფაილებს; .ait შაბლონი ყოველთვის PDF-თან თავსებადია -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ფაილის ცვლადებისა და მონაცემთა ნაკრებების წაკითხვა ვერ მოხერხდა (ცვლადები: {variables}, მონაცემთა ნაკრებები: {data_sets}): პანელი „ცვლადები“ მათ არ აჩვენებს +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest ფაილის ცვლადების ბიბლიოთეკა მხოლოდ ნაწილობრივ იქნა წაკითხული (გამოტოვებული ჩანაწერები: {_1}): პანელი „ცვლადები“ დანარჩენს აჩვენებს +@msg the file's variable library couldn't be read: the Variables panel doesn't list it ფაილის ცვლადების ბიბლიოთეკის წაკითხვა ვერ მოხერხდა: პანელი „ცვლადები“ მას არ აჩვენებს +@msg a variable of unknown kind can't be bound to objects უცნობი სახის ცვლადის ობიექტებთან დაკავშირება შეუძლებელია @msg its zlib compression is damaged მისი zlib შეკუმშვა დაზიანებულია @msg its Zstandard compression is damaged მისი Zstandard შეკუმშვა დაზიანებულია @msg it decompresses to more than {_1} MB გაშლისას {_1} MB-ს აღემატება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5fbd7cfad..5f2441972 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5214,7 +5214,9 @@ lighting Light 광원 Effect Gallery 효과 갤러리 Filters 필터 Select artwork to apply effects 효과를 적용할 그래픽을 선택하세요 -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 파일의 변수와 데이터 세트를 읽지 않았습니다(변수: {variables}, 데이터 세트: {data_sets}). 변수 패널에 표시되지 않습니다 +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest 파일의 변수 라이브러리를 일부만 읽었습니다(건너뛴 항목: {_1}). 나머지는 변수 패널에 표시됩니다 +@msg the file's variable library couldn't be read: the Variables panel doesn't list it 파일의 변수 라이브러리를 읽을 수 없습니다. 변수 패널에 표시되지 않습니다 +@msg a variable of unknown kind can't be bound to objects 종류를 알 수 없는 변수는 개체에 바인딩할 수 없습니다 # Select by Color (select.color) Select by Color 색상별 선택 Move Anchor Point 앵커 포인트 이동 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 698e576eb..d7ccca211 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5224,6 +5224,9 @@ mark-style Japanese Japans Path Length Padlengte @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer de lagen van het bestand zijn niet ingelezen ({_1}): het komt binnen als zijn pagina, in één laag @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part de lagen van het bestand zijn niet ingelezen uit de bewerkingsgegevens ({_1}): ze komen uit het PDF-gedeelte +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest de variabelenbibliotheek van het bestand is slechts gedeeltelijk ingelezen (overgeslagen items: {_1}): het Variabelen-paneel toont de rest +@msg the file's variable library couldn't be read: the Variables panel doesn't list it de variabelenbibliotheek van het bestand kan niet worden ingelezen: het Variabelen-paneel toont die niet +@msg a variable of unknown kind can't be bound to objects een variabele van onbekend type kan niet aan objecten worden gekoppeld @msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` moet grayscale, rgb, hsb, cmyk of websafe zijn @msg the Color Picker is not open de kleurenkiezer is niet open @msg the screen color sampler is already open de schermkleurkiezer is al open diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b5343349a..be0a2a24b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5492,7 +5492,9 @@ mark-style Japanese Japońskie Effect Gallery Galeria efektów Filters Filtry Select artwork to apply effects Wybierz grafikę, aby zastosować efekty -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them zmienne i zestawy danych pliku nie zostały wczytane (zmienne: {variables}, zestawy danych: {data_sets}): panel Zmienne ich nie wyświetla +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest biblioteka zmiennych pliku została wczytana tylko częściowo (pominięte wpisy: {_1}): panel Zmienne wyświetla resztę +@msg the file's variable library couldn't be read: the Variables panel doesn't list it nie udało się wczytać biblioteki zmiennych pliku: panel Zmienne jej nie wyświetla +@msg a variable of unknown kind can't be bound to objects zmiennej nieznanego rodzaju nie można powiązać z obiektami # Select by Color (select.color) Select by Color Zaznacz według koloru Move Anchor Point Przesuń punkt kontrolny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 525313a1e..2f07e5309 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5554,7 +5554,9 @@ mark-style Japanese Japonesas Effect Gallery Galeria de Efeitos Filters Filtros Select artwork to apply effects Selecione a arte para aplicar efeitos -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them as variáveis e os conjuntos de dados do arquivo não foram lidos (variáveis: {variables}, conjuntos de dados: {data_sets}): o painel Variáveis não os lista +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest a biblioteca de variáveis do arquivo foi lida só em parte (entradas ignoradas: {_1}): o painel Variáveis lista o restante +@msg the file's variable library couldn't be read: the Variables panel doesn't list it não foi possível ler a biblioteca de variáveis do arquivo: o painel Variáveis não a lista +@msg a variable of unknown kind can't be bound to objects uma variável de tipo desconhecido não pode ser vinculada a objetos # Select by Color (select.color) Select by Color Selecionar por Cor Move Anchor Point Mover Ponto de Ancoragem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 777c19eac..cb6cbd10b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5248,7 +5248,9 @@ lighting Light Свет Effect Gallery Галерея эффектов Filters Фильтры Select artwork to apply effects Выберите графику, чтобы применить эффекты -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them переменные и наборы данных файла не прочитаны (переменных: {variables}, наборов данных: {data_sets}): панель «Переменные» их не показывает +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest библиотека переменных файла прочитана только частично (пропущено записей: {_1}): панель «Переменные» показывает остальные +@msg the file's variable library couldn't be read: the Variables panel doesn't list it не удалось прочитать библиотеку переменных файла: панель «Переменные» её не показывает +@msg a variable of unknown kind can't be bound to objects переменную неизвестного вида нельзя связать с объектами # Select by Color (select.color) Select by Color Выделить по цвету Move Anchor Point Переместить опорную точку diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b682c5753..b8cc54933 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5661,7 +5661,9 @@ swatch Yellow Green Gulgrön Effect Gallery Effektgalleri Filters Filter Select artwork to apply effects Välj grafik för att tillämpa effekter -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them filens variabler och datauppsättningar lästes inte (variabler: {variables}, datauppsättningar: {data_sets}): panelen Variabler visar dem inte +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest filens variabelbibliotek lästes bara delvis (överhoppade poster: {_1}): panelen Variabler visar resten +@msg the file's variable library couldn't be read: the Variables panel doesn't list it filens variabelbibliotek kunde inte läsas: panelen Variabler visar det inte +@msg a variable of unknown kind can't be bound to objects en variabel av okänd typ kan inte kopplas till objekt # Select by Color (select.color) Select by Color Markera efter färg Move Anchor Point Flytta ankarpunkt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 19b2b983b..9009af8ae 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5406,8 +5406,10 @@ theme Appearance Görünüm @msg its zlib compression is damaged zlib sıkıştırması hasarlı @msg its Zstandard compression is damaged Zstandard sıkıştırması hasarlı @msg it decompresses to more than {_1} MB açıldığında {_1} MB'tan fazla alan kaplıyor +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest dosyanın değişken kitaplığı yalnızca kısmen okundu (atlanan girdiler: {_1}): Değişkenler paneli kalanları listeliyor +@msg the file's variable library couldn't be read: the Variables panel doesn't list it dosyanın değişken kitaplığı okunamadı: Değişkenler paneli onu listelemiyor +@msg a variable of unknown kind can't be bound to objects türü bilinmeyen bir değişken nesnelere bağlanamaz @msg columns {first} and {column} both name the variable `{name}` {first} ve {column} numaralı sütunların ikisi de `{name}` değişkenini adlandırıyor -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them dosyanın değişkenleri ve veri kümeleri okunmadı (değişkenler: {variables}, veri kümeleri: {data_sets}): Değişkenler paneli bunları listelemiyor @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for {name} veri kümesinden hiçbir şey uygulanmadı: değer içerdiği bir değişkene bağlı hiçbir nesne yok @msg no bound object reports a value (locked objects and embedded images report none) bağlı hiçbir nesne bir değer bildirmiyor (kilitli nesneler ve gömülü görüntüler değer bildirmez) @msg select an Objects on Path group bir Yol Üzerindeki Nesneler grubu seçin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5ee30d69f..7535b5357 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5233,7 +5233,9 @@ lighting Light Світло Effect Gallery Галерея ефектів Filters Фільтри Select artwork to apply effects Виберіть графіку, щоб застосувати ефекти -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them змінні та набори даних файлу не прочитано (змінних: {variables}, наборів даних: {data_sets}): панель «Змінні» їх не показує +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest бібліотеку змінних файлу прочитано лише частково (пропущено записів: {_1}): панель «Змінні» показує решту +@msg the file's variable library couldn't be read: the Variables panel doesn't list it не вдалося прочитати бібліотеку змінних файлу: панель «Змінні» її не показує +@msg a variable of unknown kind can't be bound to objects змінну невідомого виду не можна прив’язати до об’єктів # Select by Color (select.color) Select by Color Виділити за кольором Move Anchor Point Перемістити опорну точку diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 34ad90057..07852ac9f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5234,7 +5234,9 @@ theme Appearance Giao diện Effect Gallery Thư viện hiệu ứng Filters Bộ lọc Select artwork to apply effects Chọn đồ họa để áp dụng hiệu ứng -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them các biến và tập dữ liệu của tệp chưa được đọc (biến: {variables}, tập dữ liệu: {data_sets}): bảng Biến không liệt kê chúng +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest thư viện biến số của tệp chỉ được đọc một phần (mục đã bỏ qua: {_1}): bảng Biến liệt kê phần còn lại +@msg the file's variable library couldn't be read: the Variables panel doesn't list it không thể đọc thư viện biến số của tệp: bảng Biến không liệt kê nó +@msg a variable of unknown kind can't be bound to objects không thể liên kết biến thuộc loại không xác định với đối tượng # Select by Color (select.color) Select by Color Chọn theo màu Move Anchor Point Di chuyển điểm neo diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 83b12fe25..d609725c4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5476,7 +5476,9 @@ lighting Light 光源 Effect Gallery 效果库 Filters 滤镜 Select artwork to apply effects 选择要应用效果的图稿 -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 文件的变量和数据集未被读取(变量:{variables},数据集:{data_sets}):“变量”面板不会列出它们 +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest 文件的变量库只读取了一部分(跳过的条目:{_1}):“变量”面板列出其余部分 +@msg the file's variable library couldn't be read: the Variables panel doesn't list it 无法读取文件的变量库:“变量”面板不会列出它 +@msg a variable of unknown kind can't be bound to objects 类型未知的变量不能绑定到对象 # Select by Color (select.color) Select by Color 按颜色选择 Move Anchor Point 移动锚点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 76041d1a1..32dbb3ba2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2041,7 +2041,9 @@ file-filter Templates 範本 @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same 其上的圖稿與頁面的圖稿有 {_1}% 不同:仍從其編輯資料匯入 @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer 未讀取檔案的圖層 ({_1}):檔案以其頁面匯入,位於單一圖層中 @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part 未從檔案的編輯資料讀取圖層 ({_1}):圖層來自其 PDF 部分 -@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 未讀取檔案的變數與資料集 (變數:{variables},資料集:{data_sets}):「變數」面板不會列出它們 +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest 只讀取了檔案的部分變數資料庫 (略過的項目:{_1}):「變數」面板會列出其餘部分 +@msg the file's variable library couldn't be read: the Variables panel doesn't list it 無法讀取檔案的變數資料庫:「變數」面板不會列出它 +@msg a variable of unknown kind can't be bound to objects 種類不明的變數無法繫結至物件 @msg its Windows metafile preview was opened instead, which may simplify the art 已改為開啟其 Windows 中繼檔預覽,圖稿可能因此簡化 @msg its preview was placed instead, at screen resolution 已改為以螢幕解析度置入其預覽 @msg shadings of more than 4096 patches or triangles were left out 已略過超過 4096 個面片或三角形的著色 diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 882b0beac..ab89d2faf 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -173,6 +173,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { VariableKind::Visibility => "eye", VariableKind::Image => "image", VariableKind::Graph => "chart-column", + VariableKind::Unknown => "square-dashed", }; ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0))); // The object a variable drives is named beside it, as the reference panel does. @@ -542,6 +543,24 @@ mod tests { } } + /// The notes of a `.ai` whose variable library was read in part, or couldn't be read, are + /// translated in every language that translated the note they replace. + #[test] + fn the_notes_of_an_ai_variable_library_are_translated() { + use crate::i18n::{Lang, message}; + let partly = "the file's variable library was partly read (entries skipped: 7): the Variables panel lists the rest"; + let unread = "the file's variable library couldn't be read: the Variables panel doesn't list it"; + let codes = [ + "bg", "bs", "ca", "cs", "da", "de", "el", "es", "fi", "fr", "hu", "id", "it", "ja", "ka", "ko", "nl", "pl", "pt-br", "ru", "sv", "tr", + "uk", "vi", "zh-hans", "zh-hant", + ]; + for l in codes.iter().map(|c| Lang::from_code(c).expect("registered")) { + let shown = message(l, partly); + assert!(shown != partly && shown.contains('7'), "{}: {shown}", l.code()); + assert_ne!(message(l, unread), unread, "{}", 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] diff --git a/docs/mcp.md b/docs/mcp.md index 3f2da383a..07fbd7b55 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1654,7 +1654,9 @@ already walks Image Trace. Short agent entry points: `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`. + would define a variable, below), and `variable.list` reports `locked`. A variable that an opened `.ai` declares + without a kind VectorCraft reads is listed with `kind: "unknown"`: it takes no values, and `variable.bind` + refuses it. - Data sets: `dataset.list`, `dataset.new {name, values?}`, `dataset.set {name, newName?, values?}` (`newName` also renames the row, in the same undo step; Edit Data Set's Name field sends it), `dataset.rename`, `dataset.delete`, `dataset.capture`, `dataset.update`, `dataset.select {name}`, `dataset.next` / diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0f0c07452..7e3241865 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1094,7 +1094,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`). PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9). - A `.ai` or PDF whose editing data holds a variable library opens with a warning that its variables and data sets weren't read, with how many of each it has, whether its layers are read or it comes in as its PDF part (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). + A `.ai` or PDF whose editing data holds a readable variable library opens with its variable definitions, data-set rows, text, visibility and linked-file values, active row and lock in the Variables panel, whether its layers are read or it comes in as its PDF part; graph values and malformed entries are skipped with a note, and objects aren't bound yet (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data keeps each artboard's center mark, cross hairs, video safe areas and video ruler pixel aspect ratio, and the file's Document Raster Effects Settings; the author comes from the header's `%%For` (the editing data's, else an EPS's own) when the file's File Info gives none, the header's custom colors that no swatch has come in as spot swatches, a named color the file defines takes the place of a default swatch of that name when the file has no palette, and an older file's `%%TemplateBox` places its artboard. A value of another type or out of range keeps its default (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).