diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 2ad1c719c..17f717a11 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -28,6 +28,27 @@ //! `0 (name) count Pg` before its swatches, `PB`), then the artboards as the `ArtboardArray` of the //! `/Document` dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on //! `%_` lines. +//! - Variables (`writer::variables`): after the canvas size, the variable library as XML nodes +//! (`/XMLNode`: `xmlnode-nodename`, `xmlnode-nodevalue`, `xmlnode-nodetype`, `xmlnode-children`, +//! `xmlnode-attributes`) of an XML document `#document`: a `metadata` element holding +//! `variableSets`, one `variableSet` (`locked`: `none`, or `variableAndTypes` when the variables +//! are locked), its `variables` (a `variable` element with `varName` and `trait`: `textcontent`, +//! `visibility`, `fileref` or `graphdata`, none for a variable of unknown kind; a variable bound to +//! objects declared once for each, its `docRef` the object's XML id, `/XMLUIDREF`) and its +//! `v:sampleDataSets` (`i:activeDataSet`, the data set shown) of `v:sampleDataSet` elements +//! (`dataSetName`), each holding an element named after each variable it gives a value: a `p` +//! element for each line of a text value, a `/Bool` text whether the objects show, a linked file's +//! `file://` URL or path. An object bound to a variable without a name gets an XML id and an empty +//! `AIArtName`. Bindings to layers, to type written as outlines, to graphs and to linked images +//! written as other objects, and graph values, values that don't fit their variable, linked files +//! that aren't absolute paths, paths from the root that the reader reads as other paths (`/` +//! alone, `/C:…`), drive and network paths with white space at either end, values of variables +//! whose names have a colon, and a data set shown that isn't written, are left out, as is what +//! the reader leaves out +//! (variables and data sets whose names are empty, repeated, longer than 255 characters or start +//! or end with white space, variables past 256, data sets past 1,000, bindings past 100,000 or to XML ids longer than +//! 255 characters, names and values past 8,388,608 bytes in all), and values past 200,000 text +//! lines and values in all. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 //! dimPercent clipping Lb` (14 operands; a color that isn't one of the preset layer colors has the //! index -1; `clipping` is 1 for a clipping layer), `(name) Ln`, the layer's name as an XML id in an @@ -220,6 +241,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result u8 { /// [`Document::opened_artboard`] names, else the first) and the canvas's size. Every record holds /// all of these entries. The artboards, the active artboard and the canvas size are written in the /// `/Recorded` part of the `/Document` dictionary, followed by an empty `/NotRecorded` part. -fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { +fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64), library: &str) { let active = doc.opened_artboard.and_then(|id| doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0); s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); for (i, ab) in doc.artboards.iter().enumerate() { @@ -471,6 +493,8 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) s.push_str("%_; (ArtboardArray) ,\n"); let _ = writeln!(s, "%_{active} /Int (CropAreaActive) ,"); let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); + // The variable library, after the canvas size. + s.push_str(library); s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); } diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 06fa1dbd2..8043b499e 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4152,3 +4152,279 @@ fn type_with_an_opacity_mask_is_written_as_its_outlines() { assert!(!matches!(top.kind, NodeKind::Text(_))); assert!(top.extras().mask.is_some()); } + +// ---------- variables ---------- + +/// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility +/// variable `show` bound to an unnamed square and to a rectangle `Badge`, an image variable +/// `picture` bound to a linked image, a graph variable `chart` and a variable of unknown kind +/// `other`; the data sets `First` (two lines of text, hidden, a linked file in a folder with a space +/// and a `%`) and `Second` (empty text, shown, a linked file on a drive), `Second` shown; the +/// variables locked. +fn variables_doc() -> Document { + use vectorcraft_doc::{DataSet, DataValue, Variable, VariableKind}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let square = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(solid(GOLD), Paint::None, 0.0)); + let mut badge = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(40.0, 10.0, 90.0, 30.0)), Appearance::basic(solid(BRAND), Paint::None, 0.0)); + badge.name = Some("Badge".into()); + let pic = image_node( + &mut doc, + ImageBlob::png(rgb_png()), + (3, 2), + Affine::new([10.0, 0.0, 0.0, 10.0, 100.0, 10.0]), + Some(vectorcraft_doc::LinkInfo::new("/tmp/a b/one.png")), + ); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Ann", style(18.0, Color::BLACK)); + let t = text_node(&mut doc, t); + let ids = [square.id, badge.id, pic.id, t.id]; + for n in [square, badge, pic, t] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let vars = &mut doc.variables; + for (name, kind) in [ + ("name", VariableKind::Text), + ("show", VariableKind::Visibility), + ("picture", VariableKind::Image), + ("chart", VariableKind::Graph), + ("other", VariableKind::Unknown), + ] { + vars.variables.push(Variable { name: name.into(), kind }); + } + vars.bind(ids[0], "show"); + vars.bind(ids[1], "show"); + vars.bind(ids[2], "picture"); + vars.bind(ids[3], "name"); + let row = |name: &str, text: &str, show: bool, file: &str| DataSet { + name: name.into(), + values: [ + ("name".to_string(), DataValue::Text(text.into())), + ("show".to_string(), DataValue::Visible(show)), + ("picture".to_string(), DataValue::Image(file.into())), + ] + .into(), + }; + vars.datasets = vec![row("First", "Ann (one)\nLee & é", false, "/Users/test/Job 100%/pic.png"), row("Second", "", true, "C:\\Art\\two.png")]; + vars.active_dataset = Some("Second".into()); + vars.locked = true; + doc +} + +/// The objects of `doc` bound to each variable, by kind and name, in document order. +fn bound_objects(doc: &Document) -> Vec { + let mut out = vec![]; + for v in &doc.variables.variables { + for id in doc.variables.objects_of(&v.name) { + let n = doc.node(id).unwrap(); + let kind = match &n.kind { + NodeKind::Image(im) => format!("image {:?}", im.link.as_ref().map(|l| l.path.clone())), + NodeKind::Path { .. } => "path".to_string(), + NodeKind::Text(t) => format!("type {:?}", t.runs.iter().map(|r| r.text.as_str()).collect::()), + _ => "other".to_string(), + }; + out.push(format!("{} → {kind} {:?}", v.name, n.name)); + } + } + out +} + +/// The warnings the reader gives about the variable library of `doc`'s editing data. +fn library_notes(doc: &Document) -> Vec { + let (_, warnings) = crate::ai_alone(&private_data(doc, &opts()).unwrap().0.data).unwrap(); + warnings.into_iter().filter(|w| w.contains("variable")).collect() +} + +#[test] +fn variables_data_sets_and_bindings_are_read_back() { + let doc = variables_doc(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.variables.variables, doc.variables.variables); + assert_eq!(back.variables.datasets, doc.variables.datasets); + assert_eq!(back.variables.active_dataset.as_deref(), Some("Second")); + assert!(back.variables.locked); + assert_eq!(bound_objects(&back), bound_objects(&doc)); + assert_eq!( + bound_objects(&back), + ["name → type \"Ann\" None", "show → path None", "show → path Some(\"Badge\")", "picture → image Some(\"/tmp/a b/one.png\") None"] + ); + assert_eq!(library_notes(&doc), Vec::::new()); + // The document read back is written the same way again. + let library = |p: String| p[p.find("%_/XMLNode :").unwrap()..p.find("(#document)").unwrap()].to_string(); + assert_eq!(library(program_shown(&back)), library(program_shown(&doc))); +} + +#[test] +fn variables_and_data_sets_past_the_readers_caps_are_left_out() { + use vectorcraft_doc::{DataSet, Variable, VariableKind}; + let mut doc = variables_doc(); + for i in 0..300 { + doc.variables.variables.push(Variable { name: format!("extra{i}"), kind: VariableKind::Text }); + } + for i in 0..1_000 { + doc.variables.datasets.push(DataSet { name: format!("Row {i}"), values: Default::default() }); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.variables.variables[..], doc.variables.variables[..256]); + assert_eq!(back.variables.datasets[..], doc.variables.datasets[..1_000]); + assert_eq!(bound_objects(&back).len(), 4); + assert_eq!(library_notes(&doc), Vec::::new()); +} + +/// Bindings that fill the reader's byte budget: the bindings past it are left out, and every +/// variable and every binding written reads back. +#[test] +fn bindings_that_fill_the_readers_budget_keep_every_variable() { + use vectorcraft_doc::{Variable, VariableKind}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut ids = vec![]; + for i in 0..170 { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(1.0, 1.0, 2.0, 2.0)), Appearance::basic(solid(GOLD), Paint::None, 0.0)); + n.name = Some(format!("o{i:0>249}")); + ids.push(n.id); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + for v in 0..120 { + let name = format!("v{v:0>254}"); + doc.variables.variables.push(Variable { name: name.clone(), kind: VariableKind::Visibility }); + for id in &ids { + doc.variables.bind(*id, &name); + } + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.variables.variables, doc.variables.variables); + let bound: usize = back.variables.bindings.values().map(|names| names.len()).sum(); + assert!(bound > 0 && bound < 120 * 170, "{bound}"); + assert_eq!(library_notes(&doc), Vec::::new()); +} + +#[test] +fn long_xml_ids_and_text_of_many_lines_are_left_out() { + use vectorcraft_doc::DataValue; + let mut doc = variables_doc(); + let badge = doc.variables.objects_of("show")[1]; + doc.node_mut(badge).unwrap().name = Some("_".repeat(60)); + let lines = "\n".repeat(150_000); + doc.variables.datasets[0].values.insert("name".into(), DataValue::Text(lines)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(bound_objects(&back), ["name → type \"Ann\" None", "show → path None", "picture → image Some(\"/tmp/a b/one.png\") None"]); + assert_eq!(back.variables.datasets[0].values.get("name"), None); + assert_eq!(back.variables.datasets[1].values.get("name"), Some(&DataValue::Text(String::new()))); + // Nothing is written that the reader leaves out. + assert_eq!(library_notes(&doc), Vec::::new()); +} + +#[test] +fn a_layer_bound_to_a_visibility_variable_is_reported_left_out() { + let mut doc = variables_doc(); + assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.iter().all(|w| w != writer::BINDINGS)); + let layer = doc.layers[0].id; + doc.variables.bind(layer, "show"); + assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::BINDINGS)); + assert_eq!(bound_objects(&read_back(&doc)).len(), 4); +} + +#[test] +fn a_binding_to_an_object_no_longer_in_the_document_is_not_reported() { + let mut doc = variables_doc(); + let gone = doc.alloc_id(); + doc.variables.bind(gone, "show"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(bound_objects(&read_back(&doc)), bound_objects(&variables_doc())); +} + +#[test] +fn a_data_set_shown_that_is_not_written_is_reported_left_out() { + for active in [" Second", "Nope"] { + let mut doc = variables_doc(); + doc.variables.active_dataset = Some(active.into()); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::ACTIVE], "{active:?}"); + assert_eq!(read_back(&doc).variables.active_dataset, None, "{active:?}"); + } +} + +#[test] +fn a_locked_library_without_variables_stays_locked() { + let mut doc = Document::new(200.0, 100.0); + doc.variables.locked = true; + assert!(read_back(&doc).variables.locked); +} + +#[test] +fn names_the_reader_does_not_read_as_they_are_are_left_out() { + use vectorcraft_doc::{DataSet, Variable, VariableKind}; + let mut doc = variables_doc(); + for name in ["x".repeat(256), " padded".into(), String::new(), "show".into()] { + doc.variables.variables.push(Variable { name: name.clone(), kind: VariableKind::Text }); + doc.variables.datasets.push(DataSet { name, values: Default::default() }); + } + doc.variables.datasets.push(DataSet { name: "First".into(), values: Default::default() }); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings); + let back = read_back(&doc); + let expected = variables_doc(); + assert_eq!(back.variables.variables, expected.variables.variables); + let mut datasets = expected.variables.datasets.clone(); + datasets.push(DataSet { name: "show".into(), values: Default::default() }); + assert_eq!(back.variables.datasets, datasets); + assert_eq!(library_notes(&doc), Vec::::new()); +} + +#[test] +fn a_document_without_variables_has_no_library() { + let p = program(&sample()); + assert!(!p.contains("XMLNode")); +} + +#[test] +fn an_unlocked_library_without_a_data_set_is_read_back() { + let mut doc = variables_doc(); + doc.variables.datasets.clear(); + doc.variables.active_dataset = None; + doc.variables.locked = false; + let back = read_back(&doc); + let expected = vectorcraft_doc::Variables { bindings: back.variables.bindings.clone(), ..(*doc.variables).clone() }; + assert_eq!(*back.variables, expected); + assert_eq!(bound_objects(&back), bound_objects(&doc)); +} + +#[test] +fn bindings_and_values_the_reader_cant_take_are_left_out() { + use vectorcraft_doc::{DataValue, VariableKind}; + let mut doc = variables_doc(); + let layer = doc.layers[0].id; + // Type of only white space is written as its outlines. + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), " ", style(18.0, Color::BLACK)); + let t = text_node(&mut doc, t); + let text_id = t.id; + doc.insert(Some(layer), usize::MAX, t).unwrap(); + doc.variables.bind(text_id, "name"); + doc.variables.variables.push(vectorcraft_doc::Variable { name: "a:b".into(), kind: VariableKind::Text }); + let first = &mut doc.variables.datasets[0].values; + first.insert("chart".into(), DataValue::Graph("a,b\n1,2".into())); + first.insert("a:b".into(), DataValue::Text("lost".into())); + first.insert("picture".into(), DataValue::Image("/a:b/pic.png".into())); + let second = &mut doc.variables.datasets[1].values; + second.insert("picture".into(), DataValue::Image("relative/pic.png".into())); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BINDINGS), "{:?}", out.warnings); + assert!(out.warnings.iter().any(|w| w == writer::VALUES), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.variables.objects_of("name").len(), 1); + assert_eq!(back.variables.datasets[0].values.get("chart"), None); + assert_eq!(back.variables.datasets[0].values.get("a:b"), None); + assert_eq!(back.variables.datasets[0].values.get("picture"), None); + assert_eq!(back.variables.datasets[1].values.get("picture"), None); + assert_eq!(bound_objects(&back).len(), 4); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 37b8868ec..2ac1edd63 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -18,6 +18,7 @@ use crate::ps; mod images; mod paints; mod texts; +mod variables; pub(super) use images::PAYLOAD; #[cfg(test)] @@ -26,6 +27,8 @@ pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; #[cfg(test)] pub(super) use texts::TYPE_ATTRIBUTES; +#[cfg(test)] +pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES}; /// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left /// out). Groups and layers need no limit of their own: a document is never deeper than @@ -160,6 +163,13 @@ pub(super) struct Writer<'a> { /// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the /// art being written is in. hidden: u32, + /// The image just written was written as a placed file. + placed: bool, + /// The type just written was written as type, not as its outlines. + typed: bool, + /// The objects bound to variables so far, in the order they are written: each one's id, its XML + /// id and the variables it is bound to ([`Self::bound_variables`]). + bound: Vec<(vectorcraft_doc::NodeId, String, Vec)>, /// The text document: the stories of the type written as text. pub(super) texts: texts::TextDoc, } @@ -181,6 +191,9 @@ impl<'a> Writer<'a> { canvas_origin: origin, payloads: vec![], hidden: 0, + placed: false, + typed: false, + bound: vec![], texts: texts::TextDoc::default(), cms: vectorcraft_color::cms::active(), body: String::new(), @@ -491,23 +504,34 @@ impl<'a> Writer<'a> { } } - /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]) - /// or an opacity mask (`AI9_Mask`, see [`Self::mask`]). + /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), + /// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an + /// empty `AIArtName`) or has an opacity mask (`AI9_Mask`, see [`Self::mask`]). fn dictionary(&mut self, n: &Node) { let name = n.name.as_deref().filter(|s| !s.is_empty()); + let placed = std::mem::take(&mut self.placed); + let typed = std::mem::take(&mut self.typed); + let bound = self.bound_variables(n, placed, typed); let mut mask = n.extras().mask.as_deref(); // Masks nested too deep are left out (an empty mask would hide the object). if mask.is_some() && self.nest >= MAX_NEST { self.warn(DEEP_MASKS); mask = None; } - if name.is_none() && mask.is_none() { + if name.is_none() && mask.is_none() && bound.is_empty() { return; } self.body.push_str("%_/ArtDictionary :\n"); - if let Some(name) = name { - let id = self.take_id(name); + if name.is_some() || !bound.is_empty() { + let id = self.take_id(name.unwrap_or("XMLID")); let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); + if name.is_none() { + // The XML id names the object unless the dictionary gives it another name. + self.body.push_str("%_() /String (AIArtName) ,\n"); + } + if !bound.is_empty() { + self.bound.push((n.id, id, bound)); + } } if let Some(m) = mask { self.mask(m); @@ -931,6 +955,7 @@ impl<'a> Writer<'a> { None }; let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) }; + self.typed = true; if lost { self.warn(texts::TYPE_ATTRIBUTES); } diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index d8bb4b16d..0248e8b9a 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -120,7 +120,11 @@ impl Writer<'_> { let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height)); match &im.link { - Some(link) if self.hidden == 0 => self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf), + Some(link) if self.hidden == 0 => { + // Cut to a region, the placed file is in a clipping group. + self.placed = cut.is_none(); + self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf); + } other => { if other.is_some() { self.warn(LINKS_EMBEDDED); diff --git a/crates/eps/src/ai_editing/writer/variables.rs b/crates/eps/src/ai_editing/writer/variables.rs new file mode 100644 index 000000000..b714b7ecd --- /dev/null +++ b/crates/eps/src/ai_editing/writer/variables.rs @@ -0,0 +1,372 @@ +//! The variable library: the document's variables, data sets and the objects bound to them, as XML +//! nodes of the `/Document` dictionary (see [`crate::ai_editing`] for the form). + +use std::collections::HashSet; +use std::fmt::Write; + +use vectorcraft_doc::{DataValue, Node, NodeId, NodeKind, VariableKind}; + +use super::Writer; +use crate::ps; + +pub(in crate::ai_editing) const BINDINGS: &str = "variable bindings to layers, to type written as outlines, to graphs, and to linked images written as other objects are left out of the editing data for other apps"; +pub(in crate::ai_editing) const VALUES: &str = "data set values of graphs, values that don't fit their variable, linked files that aren't absolute paths or that would read back as other paths, and values of variables whose names have a colon are left out of the editing data for other apps"; +pub(in crate::ai_editing) const ACTIVE: &str = + "the data set shown is left out of the editing data for other apps when it isn't one of the data sets written"; + +pub(in crate::ai_editing) const LIMITS: &str = "variables and data sets whose names are empty, repeated, longer than 255 characters or start or end with white space, variables past 256, data sets past 1,000, bindings past 100,000, bindings to objects whose XML ids are longer than 255 characters, values past 200,000 text lines and values in all, and names and values past 8,388,608 bytes in all are left out of the editing data for other apps"; + +/// The most bytes of a string literal on one line; a longer string is written across lines. +const LITERAL: usize = 64; +/// The reader's caps on a variable library: the variables, bindings and data sets it keeps, the +/// characters of a name and of an object's XML id, and the bytes of the names (a binding's variable name and +/// object id) and values it keeps. +const MAX_VARIABLES: usize = 256; +const MAX_BINDINGS: usize = 100_000; +const MAX_DATASETS: usize = 1_000; +const MAX_ID: usize = 255; +const MAX_BYTES: usize = 8 << 20; +/// The most XML nodes the data sets' values are written with: a value's element, and a text value's +/// `p` element and text for each line. +const MAX_VALUE_NODES: usize = 200_000; + +/// The bytes of names and values written so far, as the reader counts them, the XML nodes of the +/// values, and whether anything was left out past the caps. +#[derive(Default)] +struct Budget { + bytes: usize, + nodes: usize, + past: bool, +} + +impl Budget { + /// Whether `len` more bytes fit in [`MAX_BYTES`] (they are then counted). + fn keep(&mut self, len: usize) -> bool { + self.keep_value(len, 0) + } + + /// Whether a value of `len` bytes written with `nodes` XML nodes fits in [`MAX_BYTES`] and + /// [`MAX_VALUE_NODES`] (it is then counted). + fn keep_value(&mut self, len: usize, nodes: usize) -> bool { + let fits = self.bytes.saturating_add(len) <= MAX_BYTES && self.nodes.saturating_add(nodes) <= MAX_VALUE_NODES; + if fits { + self.bytes += len; + self.nodes += nodes; + } + fits + } +} + +/// The value of an XML node's entry, with the type it is written with. +#[derive(Clone, Copy)] +enum Value<'a> { + /// A `/String`. + Str(&'a str), + /// A `/UnicodeString`. + Unicode(&'a str), + /// A `/Bool`. + Bool(bool), + /// An object's XML id, `/XMLUIDREF`. + Uid(&'a str), +} + +/// `s` as string literals on `%_` lines, at most [`LITERAL`] bytes each, the first line's `%_` +/// included and the last line left open for what follows it. +fn literals(s: &str) -> String { + let mut parts = vec![]; + let mut start = 0; + for (at, c) in s.char_indices() { + if at + c.len_utf8() - start > LITERAL { + parts.push(&s[start..at]); + start = at; + } + } + parts.push(&s[start..]); + parts.iter().map(|p| format!("%_{}", ps::bytes(p.as_bytes()))).collect::>().join("\n") +} + +/// `v` written before an entry's key, from the start of its line. +fn value(v: Value) -> String { + match v { + Value::Str(s) => format!("{} /String", literals(s)), + Value::Unicode(s) => format!("{} /UnicodeString", literals(s)), + Value::Bool(b) => format!("%_{} /Bool", u8::from(b)), + Value::Uid(id) => format!("%_/XMLUIDREF : {} ;", ps::bytes(id.as_bytes())), + } +} + +/// An XML node (`/XMLNode`) up to the `;` that closes it: its name, its value, its type `kind` (1 an +/// element, 2 an attribute, 3 text, 9 the document), its children (an array, each closed as an item) +/// and its attribute nodes (a dictionary, each under its name). +fn node(kind: u8, name: &str, v: Value, attributes: &[(&str, Value)], children: &[String]) -> String { + let mut s = format!( + "%_/XMLNode :\n{} (xmlnode-nodename) ,\n{} (xmlnode-nodevalue) ,\n%_{kind} /Int (xmlnode-nodetype) ,\n%_/Array :\n", + value(Value::Str(name)), + value(v) + ); + for c in children { + s.push_str(c); + s.push_str("%_; ,\n"); + } + s.push_str("%_; (xmlnode-children) ,\n%_/Dictionary :\n"); + for (k, v) in attributes { + s.push_str(&node(2, k, *v, &[], &[])); + let _ = writeln!(s, "%_; {} ,", ps::bytes(k.as_bytes())); + } + s.push_str("%_; (xmlnode-attributes) ,\n"); + s +} + +/// An element `name` with `attributes` and `children`, up to the `;` that closes it. +fn element(name: &str, attributes: &[(&str, Value)], children: &[String]) -> String { + node(1, name, Value::Str(""), attributes, children) +} + +/// A text node (`#text`) holding `v`, up to the `;` that closes it. +fn text(v: Value) -> String { + node(3, "#text", v, &[], &[]) +} + +/// A linked file's path as a data set holds it: a path from the root (`/…`) as a `file://` URL +/// (`file:///` and the path, with `%`, `#`, `?`, white space, control characters and bytes outside ASCII +/// escaped as `%XX`), another absolute path (a drive's, or a network path) as it is. `None` for a +/// path that isn't absolute, for the root alone (`/`), for a path from the root whose first part +/// starts like a drive (`/C:…`, which the reader reads as the drive's path), and for one written as +/// it is with white space at either end. +fn file_value(path: &str) -> Option { + // A drive's path from its root (`C:\…`, `C:/…`); `C:name` is relative to the drive's folder. + let drive = matches!(path.as_bytes(), [l, b':', b'\\' | b'/', ..] if l.is_ascii_alphabetic()); + if path.starts_with('/') && !path.starts_with("//") { + let rest = &path.as_bytes()[1..]; + if rest.is_empty() || matches!(rest, [l, b':', ..] if l.is_ascii_alphabetic()) { + return None; + } + let mut url = String::from("file:///"); + for &b in path.as_bytes() { + if matches!(b, b'%' | b'#' | b'?') || !(0x21..=0x7e).contains(&b) { + let _ = write!(url, "%{b:02X}"); + } else { + url.push(char::from(b)); + } + } + return Some(url); + } + // The reader takes white space off both ends of a path written as it is. + let trimmed = path.trim_matches([' ', '\t', '\n', '\r']) == path; + (trimmed && path.len() > 1 && (path.starts_with('/') || path.starts_with("\\\\") || drive)).then(|| path.to_string()) +} + +/// Whether the reader reads `name` as a variable's or a data set's name as it is: not empty, at most +/// [`MAX_ID`] characters, and without white space at either end. +fn readable_name(name: &str) -> bool { + !name.is_empty() && name.trim() == name && name.chars().count() <= MAX_ID +} + +impl Writer<'_> { + /// The variables `n` is bound to that its XML id is written for: visibility variables, image + /// variables when `n` was written as a placed file (`placed`, see [`Self::placed`]), and text + /// variables when `n` was written as type (`typed`, see [`Self::typed`]). Only objects of the + /// document's own tree are bound, not copies of them (symbol art, live objects' art, opacity + /// masks' art, pattern tiles). + /// The bindings left out are reported by [`Self::variable_library`]. + pub(super) fn bound_variables(&self, n: &Node, placed: bool, typed: bool) -> Vec { + let doc = self.doc; + let Some(names) = doc.variables.bindings.get(&n.id) else { return vec![] }; + if self.nest > 0 || self.hidden > 0 || !doc.node(n.id).is_some_and(|d| std::ptr::eq(d, n)) { + return vec![]; + } + let fits = |name: &&String| match doc.variables.variable(name).map(|v| v.kind) { + Some(VariableKind::Visibility) => true, + Some(VariableKind::Image) => placed && matches!(n.kind, NodeKind::Image(_)), + Some(VariableKind::Text) => typed && matches!(n.kind, NodeKind::Text(_)), + _ => false, + }; + names.iter().filter(fits).cloned().collect() + } + + /// The `#document` entry of the `/Document` dictionary's `/Recorded` part that holds the + /// variable library, on `%_` lines: nothing when the document has no variables or data sets. + pub(in crate::ai_editing) fn variable_library(&mut self) -> String { + let doc = self.doc; + let vars = &doc.variables; + if vars.variables.is_empty() && vars.datasets.is_empty() && !vars.locked { + return String::new(); + } + // Bindings not written: to layers, to type written as outlines, to graphs, and to objects + // not written as themselves. Bindings to objects no longer in the document have nothing to + // write. + let pairs: HashSet<(NodeId, &str)> = self.bound.iter().flat_map(|(n, _, names)| names.iter().map(|name| (*n, name.as_str()))).collect(); + let written = |id: &NodeId, name: &String| pairs.contains(&(*id, name.as_str())); + let kind = |name: &str| vars.variable(name).map(|v| v.kind); + let missing = vars.bindings.iter().any(|(id, names)| { + doc.node(*id).is_some() && names.iter().any(|name| kind(name).is_some_and(|k| k != VariableKind::Unknown) && !written(id, name)) + }); + if missing { + self.warn(BINDINGS); + } + // What the reader keeps: names it doesn't read, and names, bindings and values past its + // caps, are left out. + let mut budget = Budget::default(); + let mut kept: Vec<&vectorcraft_doc::Variable> = vec![]; + for v in &vars.variables { + let new = readable_name(&v.name) && !kept.iter().any(|k| k.name == v.name); + if new && kept.len() < MAX_VARIABLES && budget.keep(v.name.len()) { + kept.push(v); + } else { + budget.past = true; + } + } + let mut declared = vec![]; + let mut bindings = 0usize; + for v in &kept { + let mut attributes = vec![]; + let kind = match v.kind { + VariableKind::Text => Some(("textcontent", "&ns_flows;")), + VariableKind::Visibility => Some(("visibility", "&ns_vars;")), + VariableKind::Image => Some(("fileref", "&ns_vars;")), + VariableKind::Graph => Some(("graphdata", "&ns_vars;")), + VariableKind::Unknown => None, + }; + if let Some((t, category)) = kind { + attributes.push(("trait", Value::Str(t))); + attributes.push(("category", Value::Str(category))); + } + attributes.push(("varName", Value::Unicode(&v.name))); + // A variable bound to several objects is declared once for each. + let objects = self.bound.iter().filter(|(_, _, names)| names.contains(&v.name)).map(|(_, id, _)| id.as_str()); + let mut written = 0; + for id in objects { + if bindings >= MAX_BINDINGS || id.chars().count() > MAX_ID || !budget.keep(v.name.len().saturating_add(id.len())) { + budget.past = true; + continue; + } + bindings += 1; + written += 1; + let mut with = attributes.clone(); + with.push(("docRef", Value::Uid(id))); + declared.push(element("variable", &with, &[])); + } + if written == 0 { + declared.push(element("variable", &attributes, &[])); + } + } + let mut left_out = false; + let mut rows = vec![]; + let mut row_names: Vec<&str> = vec![]; + for ds in &vars.datasets { + let new = readable_name(&ds.name) && !row_names.contains(&ds.name.as_str()); + if !new || rows.len() >= MAX_DATASETS || !budget.keep(ds.name.len()) { + budget.past = true; + continue; + } + let mut values = vec![]; + for (name, v) in &ds.values { + let Some(kind) = kept.iter().find(|k| &k.name == name).map(|k| k.kind) else { + // A value of a variable left out, or of none. + left_out |= vars.variable(name).is_none(); + continue; + }; + // The value's bytes as the reader counts them, and the nodes it is written with. + let (len, nodes) = match (v, kind) { + _ if name.contains(':') => { + left_out = true; + continue; + } + (DataValue::Text(t), VariableKind::Text) => { + let lines = t.bytes().filter(|b| *b == b'\n').count() + 1; + (t.len(), 1 + 2 * lines) + } + (DataValue::Visible(_), VariableKind::Visibility) => (1, 2), + (DataValue::Image(path), VariableKind::Image) if file_value(path).is_some() => (path.len(), 2), + _ => { + left_out = true; + continue; + } + }; + if !budget.keep_value(len, nodes) { + budget.past = true; + continue; + } + let children = match v { + DataValue::Text(t) => t + .split('\n') + .map(|line| { + let words = if line.is_empty() { vec![] } else { vec![text(Value::Unicode(line))] }; + element("p", &[], &words) + }) + .collect(), + DataValue::Visible(show) => vec![text(Value::Bool(*show))], + DataValue::Image(path) => file_value(path).map(|f| vec![text(Value::Unicode(&f))]).unwrap_or_default(), + DataValue::Graph(_) => vec![], + }; + values.push(element(name, &[], &children)); + } + rows.push(element("v:sampleDataSet", &[("dataSetName", Value::Unicode(&ds.name))], &values)); + row_names.push(&ds.name); + } + if left_out { + self.warn(VALUES); + } + if budget.past { + self.warn(LIMITS); + } + let mut attributes = vec![]; + // The data set shown, when a data set of that exact name is written. + if let Some(active) = &vars.active_dataset { + if row_names.contains(&active.as_str()) { + attributes.push(("i:activeDataSet", Value::Unicode(active))); + } else { + self.warn(ACTIVE); + } + } + attributes.push(("xmlns", Value::Str("&ns_custom;"))); + attributes.push(("xmlns:v", Value::Str("&ns_vars;"))); + let data_sets = element("v:sampleDataSets", &attributes, &rows); + let locked = if vars.locked { "variableAndTypes" } else { "none" }; + let set = element( + "variableSet", + &[("locked", Value::Str(locked)), ("varSetName", Value::Str("binding1"))], + &[element("variables", &[], &declared), data_sets], + ); + let sets = element("variableSets", &[("xmlns", Value::Str("&ns_vars;"))], &[set]); + let document = node(9, "#document", Value::Str(""), &[], &[element("metadata", &[], &[sets])]); + format!("{document}%_; (#document) ,\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_from_the_root_are_file_urls() { + assert_eq!(file_value("/Users/a b/100%.png").as_deref(), Some("file:////Users/a%20b/100%25.png")); + assert_eq!(file_value("/tmp/é.png").as_deref(), Some("file:////tmp/%C3%A9.png")); + assert_eq!(file_value("C:\\Art\\pic.png").as_deref(), Some("C:\\Art\\pic.png")); + assert_eq!(file_value("\\\\server\\share\\pic.png").as_deref(), Some("\\\\server\\share\\pic.png")); + assert_eq!(file_value("//server/share/pic.png").as_deref(), Some("//server/share/pic.png")); + assert_eq!(file_value("/a/#1?.png").as_deref(), Some("file:////a/%231%3F.png")); + assert_eq!(file_value("pic.png"), None); + assert_eq!(file_value("C:pic.png"), None); + assert_eq!(file_value("C:\\Art\\pic.png "), None); + assert_eq!(file_value("/tmp/pic.png ").as_deref(), Some("file:////tmp/pic.png%20")); + } + + #[test] + fn paths_the_reader_reads_as_other_paths_are_left_out() { + assert_eq!(file_value("/"), None); + assert_eq!(file_value("/a:b/x.png"), None); + assert_eq!(file_value("/C:/x.png"), None); + assert_eq!(file_value("/1:b/x.png").as_deref(), Some("file:////1:b/x.png")); + assert_eq!(file_value("/ab:c/x.png").as_deref(), Some("file:////ab:c/x.png")); + } + + #[test] + fn long_strings_are_written_across_lines() { + // 140 bytes: two lines of 64 bytes and one of 12. + let s = "é".repeat(70); + let lines = literals(&s); + assert_eq!(lines.lines().count(), 3); + assert!(lines.lines().all(|l| l.starts_with("%_("))); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5b1976b41..fc17be4a0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -91,6 +91,17 @@ private editing data. VectorCraft: gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity and blending mode of single fills and strokes, opacity masks on layers, the placement of pattern swatches that move, turn or scale their pattern, and effects other than geometry effects are left out. + The editing copy also holds the document's variables, data sets, the data set shown and the variables' lock as + the variable library the reader reads, with the objects bound to visibility variables, the type bound to text + variables and the linked images bound to image variables. Bindings to layers, to type written as outlines and to + graphs, graph values, values that don't fit their variable, linked files that aren't absolute paths, the path `/` + alone and paths from the root whose first part starts like a drive (`/C:`), drive and network paths with white + space at either end, values of variables whose names have a colon, and a data set shown that isn't one of the data + sets written are left out, with a + warning, as are variables and data sets whose names are empty, repeated, longer than 255 characters or start or + end with white space, variables past 256, data sets past 1,000, bindings past 100,000 or to objects whose XML ids are + longer than 255 characters, values past 200,000 text lines and values in all, and names and values past + 8,388,608 bytes in all ([variables](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/work-with-variables.html)). - **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0 ([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1aef04833..4d397eee3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1223,6 +1223,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`).