diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index f2b6802bb..505e45eb1 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -184,6 +184,34 @@ 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. +#[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 (2 variables, 3 data sets): the Variables panel doesn't list them"; + let library = ai::variable_library(&["Name", "Number"], &["One", "Two", "Three"]); + 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 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. + 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. + 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}"); +} + /// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has. const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET"; diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4277cefa7..3561195fa 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2430,6 +2430,19 @@ proptest! { } survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + + /// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning + /// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't + /// handle, and then the layers aren't read). + #[test] + fn ai_variable_library_never_panics(cut in 0usize..12_000, edits in prop::collection::vec((0usize..12_000, prop::sample::select(vec!['0', '9', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'Z'])), 0..12)) { + use vectorcraft_testkit::ai; + let library = ai::variable_library(&["Name", "Number"], &["One", "Two"]); + let data = ai::editing_data(100.0, 100.0, &format!("{library}{}", ai::layer("L", "0 g\n10 10 m\n20 20 L\nS\n"))); + let text = mutate_text(&data, cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + 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` in PostScript form as other apps write it: the procsets that define its operators named, diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index 59ca3fabf..901c37085 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -31,7 +31,7 @@ use super::lex::find; use crate::ps; pub(crate) use frames::text_frames; -pub use read::{Structure, artboards, read}; +pub use read::{Structure, artboards, read, variables}; pub(crate) use read::{slot_frame, slot_of}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 790fee3a2..5f7421207 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -13,6 +13,8 @@ //! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else //! the art size around the template box's centre, else (a file in the legacy format another //! app wrote) the bounding box. +//! - 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`]). //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and //! an alpha channel that hides part of such an image becomes its opacity mask; other samples @@ -184,6 +186,102 @@ fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { r.artboards } +/// 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, +} + +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; + let s = |n: usize| if n == 1 { "" } else { "s" }; + (variables + data_sets > 0).then(|| { + format!( + "the file's variables and data sets weren't read ({variables} variable{}, {data_sets} data set{}): the Variables panel doesn't list them", + s(variables), + s(data_sets) + ) + }) + } +} + +/// The variable library of the editing data `data` ([`VariableLibrary`]), for a file whose layers +/// [`read`] can't read. Data without a `variableSets` element isn't read. +pub fn variables(data: &[u8]) -> VariableLibrary { + if super::find(data, b"variableSets").is_none() { + return VariableLibrary::default(); + } + let mut r = Reader::new(data); + let _ = r.run(); + // 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; + } + // Reading can stop before the library: read its node on its own. + variable_sets_node(data).map_or(r.variables, |node| { + let mut alone = b"%_/Document :\n%_/Dictionary :\n%_/Array :\n".to_vec(); + alone.extend_from_slice(node); + alone.extend_from_slice(b"\n%_; (xmlnode-children) ,\n%_; /Recorded ,\n%_;\n"); + let mut r = Reader::new(&alone); + let _ = r.run(); + r.variables + }) +} + +/// The `variableSets` XML node of the editing data `data`, from its `/XMLNode :` line to the line +/// that closes it: found from its name back, counting the dictionaries and arrays it holds. +fn variable_sets_node(data: &[u8]) -> Option<&[u8]> { + const NAME: &[u8] = b"variableSets) /String (xmlnode-nodename) ,"; + let at = data.windows(NAME.len()).position(|w| w == NAME)?; + let opens = |text: &[u8]| text.starts_with(b"/") && text.windows(2).any(|w| w == b" :"); + let closes = |text: &[u8]| text.starts_with(b";") || text.starts_with(b"/Def ;"); + let mut depth = 0usize; + let mut line_end = data.get(..at)?.iter().rposition(|&b| b == b'\n' || b == b'\r')?; + let start = loop { + let line_start = data.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1); + if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { + if closes(text) { + depth += 1; + } else if opens(text) { + match depth.checked_sub(1) { + Some(d) => depth = d, + None => break line_start, + } + } + } + line_end = line_start.checked_sub(1)?; + }; + let mut line_start = start; + loop { + let rest = data.get(line_start..)?; + let line_end = line_start + rest.iter().position(|&b| b == b'\n' || b == b'\r').unwrap_or(rest.len()); + if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") { + if closes(text) { + depth = depth.checked_sub(1)?; + if depth == 0 { + return data.get(start..line_end); + } + } else if opens(text) { + depth += 1; + } + } + if line_end >= data.len() { + return None; + } + line_start = line_end + 1; + } +} + #[derive(Clone, Debug)] struct GState { fill: Paint, @@ -347,6 +445,8 @@ struct Reader<'a> { to_doc: Option, cropmarks: Option, artboards: Vec<(String, Rect)>, + /// The variables and data sets of the `/Document` dictionary's variable library. + variables: VariableLibrary, gradients: HashMap, gradient_def: Option, instance: Option, @@ -508,6 +608,7 @@ impl<'a> Reader<'a> { to_doc: None, cropmarks: None, artboards: Vec::new(), + variables: VariableLibrary::default(), gradients: HashMap::new(), gradient_def: None, instance: None, @@ -1501,8 +1602,10 @@ impl<'a> Reader<'a> { } } - /// The `/Document` dictionary: the artboards. + /// The `/Document` dictionary: the artboards, and how many variables and data sets its variable + /// library has. fn document(&mut self, o: &Obj) { + variable_library(o, 0, &mut self.variables); let Some(list) = find_entry(o, "ArtboardArray", 0) else { return }; for (i, ab) in list.items().enumerate().take(1000) { let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2")); @@ -2182,6 +2285,7 @@ impl<'a> Reader<'a> { self.drawn_looks )); } + warnings.extend(self.variables.note()); Ok(Structure { doc, to_doc, @@ -2317,6 +2421,35 @@ fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> { 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) { + if depth > MAX_DEPTH { + return; + } + 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; + } + for c in o.items() { + variable_library(c, depth + 1, found); + } +} + +/// The name of the XML node `o` (its `xmlnode-nodename`) without a namespace prefix (`v:`). +fn element_name(o: &Obj) -> Option { + let name = o.text("xmlnode-nodename")?; + Some(name.rsplit(':').next().unwrap_or(&name).to_string()) +} + +/// The child nodes named `name` (see [`element_name`]) of the XML node `o`. +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/tests.rs b/crates/eps/src/import/ai/tests.rs index d8bd3d794..ffb25c3f6 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -538,6 +538,36 @@ fn artboards() { assert_eq!(doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0)); } +/// 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; + 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::>(); + assert_eq!( + notes(&variable_library(&["variable", "Team"], &["Home", "Away", "Third"])), + ["the file's variables and data sets weren't read (2 variables, 3 data sets): the Variables panel doesn't list them"] + ); + assert_eq!( + notes(&variable_library(&["Team"], &["Home"])), + ["the file's variables and data sets weren't read (1 variable, 1 data set): the Variables panel doesn't list them"] + ); + 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"))); + 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"]))); + 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()); +} + #[test] fn a_drawn_look_stands_for_its_object() { // The look (a group, with a part on `%_` lines for newer readers), then the object on `%_` diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index ac50bc521..0d2b9e111 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -1384,7 +1384,9 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl Err(why) => { let mut visible = visible; place_pages(&ai::artboards(&data), &mut visible, unit); - (visible, unread(warnings, &why), false) + let mut all = unread(warnings, &why); + all.extend(ai::variables(&data).note()); + (visible, all, false) } } } diff --git a/crates/testkit/src/ai.rs b/crates/testkit/src/ai.rs index a999a13eb..b7af4b5a5 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. +//! app wrote, except the key names that [`pdf`] uses and the XML node keys of [`variable_library`]. /// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop /// marks are the page), `body` (setup and layers), the trailer. @@ -32,6 +32,43 @@ 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() + ) + } + 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(); + 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) + }) + .collect(); + let set = element( + "variableSet", + &[("varSetName", "binding1"), ("locked", "none")], + &[element("variables", &[], &declared), 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]) + ) +} + /// A document of 200 × 100 pt with a bit of everything the reader reads. A layer `Art` that shows: /// a group `Pair` (a red square `Red` and a hidden one), a compound path `Ring`, a clipping group /// `Window` (a grey square seen through a smaller one), what [`page_ps`] and [`page_pdf`] draw. A diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 162d36c61..126c8ec44 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -705,6 +705,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In SVG export without Fewer `` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned ``, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/text_layout.rs`). 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 (`crates/eps/src/import/tests_native.rs`). + 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 PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).