From e55c6d9d127b892e7d8899891bbd92369d671540 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 04:41:21 -0800 Subject: [PATCH] .ai import: gradients and patterns are named up to the newline in their names The Adobe Illustrator File Format Specification 7.0, section 4 (p. 51), says the name shown for a global object is the part of its name in the file before the newline (https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf). The legacy reader kept the whole name, so swatches and patterns came in named "Sky\n1". The reader now applies this rule to the files of every version it reads. - Gradient swatches, gradient fills, pattern definitions, pattern fills, palette cells and art-style pattern fills use the part before the newline. Lookups inside the reader still use the whole name. - When that name is taken, the first free "Sky 2", "Sky 3" is used. Taken names are None and Registration, the palette's own swatch and group names, the default swatches when no palette replaces them, named colors (color operators, gradient stops, version 7 pattern strings, the header's and an EPS program's custom colors), the names of gradients and patterns that have no newline, and the names given to earlier gradients and patterns. Only content the reader reads counts: not the alternates of versioned content, the prolog, skipped sections or what follows the trailer. - A name with nothing before the newline comes in as "Gradient" or "Pattern". Names without a newline do not change. - docs/file-format-parity.md lists the rule. --- crates/engine/tests/import_fuzz.rs | 28 +++ crates/eps/src/import/ai/paint.rs | 213 +++++++++++++++++++- crates/eps/src/import/ai/palette.rs | 8 + crates/eps/src/import/ai/read.rs | 135 ++++++++++++- crates/eps/src/import/ai/read/art.rs | 2 +- crates/eps/src/import/ai/read/directives.rs | 22 +- crates/eps/src/import/ai/read/document.rs | 50 +++-- crates/eps/src/import/ai/read/ops.rs | 27 ++- crates/eps/src/import/ai/tests.rs | 165 +++++++++++++++ crates/eps/src/import/tests_illustrator.rs | 41 ++++ docs/file-format-parity.md | 2 +- 11 files changed, 645 insertions(+), 48 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 2abf7b10d..d00689c9c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3346,6 +3346,34 @@ proptest! { survive("hostile .ai patterns", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// A `.ai` whose gradients and patterns have names with newlines (blank before one, several, + /// repeated, one shown like another's numbered name), used by fills and a palette, damaged or + /// hostile. + #[test] + fn ai_global_names_with_newlines_never_panic( + names in prop::collection::vec(prop::sample::select(vec!["Sky\\n1", "Sky\\n2", "Sky", "Sky 2", "\\n3", "\\n", "Sky\\n\\n1", "\\012x"]), 1..6), + cut in 0usize..3_000, + edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['\\', 'n', '(', ')', ' ', '\n', 'B', 'p', 'E', '%'])), 0..10), + ) { + use vectorcraft_testkit::ai; + let mut setup = String::new(); + let mut art = String::new(); + let mut cells = String::new(); + for name in &names { + setup.push_str(&format!( + "%AI5_BeginGradient: ({name})\n({name}) 0 2 Bd\n[\n%_1 0 50 0 Bs\n%_0 0 50 100 Bs\nBD\n%AI5_EndGradient\n\ + %AI3_BeginPattern: ({name})\n({name}) 0 0 6 6\n%_0 A\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_0 0 0 1 k\n%_f\nE\n%AI3_EndPattern\n" + )); + art.push_str(&format!("Bb\n1 ({name}) 0 0 0 50 1 0 0 1 0 0 Bg\n{}f\n0 BB\n", ai::rect(10.0, 10.0, 40.0, 40.0))); + art.push_str(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", ai::rect(50.0, 50.0, 90.0, 90.0))); + cells.push_str(&format!("Bb\n2 ({name}) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\nPc\n({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\nPc\n")); + } + setup.push_str(&format!("%AI5_BeginPalette\n0 0 Pb\n{cells}PB\n%AI5_EndPalette\n")); + let data = mutate_text(&ai::editing_data(100.0, 100.0, &format!("{setup}{}", ai::layer("L", &art))), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("hostile .ai global names", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose images (binary samples with an alpha channel, the operators that end a raster, /// then a name and an opacity mask) and placed file (an empty relative folder, a preview) are /// damaged or hostile, bytes of the samples included. diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 5788ae094..178238dc1 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -18,15 +18,20 @@ //! `Bm` for each pair in the order of the ramp (`Bc`, around them, are the caps past the first and //! last stops). Without `Xm`, a radial gradient's one `Bm` takes the unit circle onto it. //! +//! - The name shown for a gradient's or a pattern's name in the file is the part before its first +//! newline (`\n`) ([`ShownNames`]). +//! //! "AI7 spec": Adobe Illustrator File Format Specification, version 7.0 (1998), listed by PRONOM at -//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423; §3.5 documents the gradient operators. +//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423; §3.5 documents the gradient operators and §4 +//! the names of global objects. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use kurbo::{Affine, Point, Vec2}; use vectorcraft_color::gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; use vectorcraft_color::{Color, Paint}; +use super::lex::{Lexer, Tok}; use super::obj::V; /// Largest number of stops read in one gradient. @@ -189,6 +194,157 @@ impl GradientDef { } } +/// The names of the named colors in the tokens [`Self::token`] is given: those the color operators +/// (`x`, `Xx`, `Xk` and their stroke forms) and gradient stops (`Bs` between `Bd` and `BD`) use, +/// also in the color and tile strings of patterns in the version 7 form (`(…) @`, `(…) &`). It +/// also keeps the names of the gradients `BD` finishes ([`GradientDef::finish`]). +#[derive(Default)] +pub(super) struct ColorNames { + pub names: HashSet, + pub gradients: Vec, + vals: Vec, + def: Option, + spots: Vec, +} + +impl ColorNames { + pub fn token(&mut self, tok: Tok<'_>) { + self.take(tok, true); + } + + /// [`Self::token`], with the names in the strings of a version 7 pattern's layer when `outer`. + fn take(&mut self, tok: Tok<'_>, outer: bool) { + let w = match tok { + Tok::Num(n) => { + if self.vals.len() < MAX_STOPS { + self.vals.push(V::Num(n)); + } + return; + } + Tok::Str(s) | Tok::Hex(s) => { + if self.vals.len() < MAX_STOPS { + self.vals.push(V::Str(s)); + } + return; + } + Tok::Word(w) => w, + _ => return, + }; + let vals = std::mem::take(&mut self.vals); + match w { + b"Bd" => self.def = GradientDef::begin(&vals), + b"BD" => { + if let Some((name, _)) = self.def.take().and_then(GradientDef::finish) { + self.gradients.push(name); + } + } + b"Bs" => { + if let Some(d) = self.def.as_mut() { + d.stop(&vals, &mut self.spots); + } + } + b"x" | b"X" | b"Xx" | b"XX" | b"Xk" | b"XK" => { + if let Some((_, Some(n))) = std::str::from_utf8(w).ok().and_then(|op| color_op(op, &vals)) { + self.spots.push(n); + } + } + b"@" | b"&" if outer => { + for v in &vals { + if let V::Str(s) = v { + let mut inner = ColorNames::default(); + let mut lex = Lexer::new(s); + while let Some(t) = lex.next_token() { + inner.take(t.tok, false); + } + self.names.extend(inner.names); + } + } + } + _ => {} + } + self.names.extend(self.spots.drain(..).map(|n| n.name)); + } +} + +/// A kind of global object. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Global { + Gradient, + Pattern, +} + +/// The names the gradients and patterns get in the document, by their kind and names in the file. +/// The name shown for a name in the file is the part before its first newline (AI7 spec §4): +/// `(Sky\n1)` is shown as "Sky". Gradients and patterns share their swatches' names. A name with a +/// newline is shown as the part before it unless that name is reserved (a custom color's, or a +/// gradient's or pattern's name without a newline) or an object defined earlier (of either kind) +/// got it; then it is the first free "Sky 2", "Sky 3"… A name shown as nothing (`(\n3)`) is shown +/// as "Gradient" or "Pattern", numbered the same way. A name without a newline is kept. An object +/// defined again under the same name in the file keeps the name it got. +#[derive(Debug, Default)] +pub(super) struct ShownNames { + gradients: HashMap, + patterns: HashMap, + taken: HashSet, + /// The number to try first for each shown name already taken. + next: HashMap, +} + +impl ShownNames { + fn by_file(&self, kind: Global) -> &HashMap { + match kind { + Global::Gradient => &self.gradients, + Global::Pattern => &self.patterns, + } + } + + /// The name the object of `kind` defined as `name` in the file got ([`Self::define`]), or + /// `name` for one not defined. + pub fn get<'n>(&'n self, kind: Global, name: &'n str) -> &'n str { + self.by_file(kind).get(name).map_or(name, String::as_str) + } + + /// Keep `name` from the objects defined after. + pub fn reserve(&mut self, name: &str) { + self.taken.insert(name.to_string()); + } + + /// The name the object of `kind` defined as `name` in the file gets. + pub fn define(&mut self, kind: Global, name: &str) -> String { + if let Some(shown) = self.by_file(kind).get(name) { + return shown.clone(); + } + let shown = match name.split_once('\n') { + None => name.to_string(), + Some((base, _)) => { + let base = match (base.is_empty(), kind) { + (false, _) => base, + (true, Global::Gradient) => "Gradient", + (true, Global::Pattern) => "Pattern", + }; + if self.taken.contains(base) { + // The first free number from the one after the last number this base got. Of + // the numbers `from..=tries`, at least one is free. + let from = self.next.get(base).copied().unwrap_or(2); + let tries = from.saturating_add(self.taken.len()); + let i = (from..tries).find(|i| !self.taken.contains(&format!("{base} {i}"))).unwrap_or(tries); + self.next.insert(base.to_string(), i.saturating_add(1)); + format!("{base} {i}") + } else { + base.to_string() + } + } + }; + self.taken.insert(shown.clone()); + let by_file = match kind { + Global::Gradient => &mut self.gradients, + Global::Pattern => &mut self.patterns, + }; + by_file.insert(name.to_string(), shown.clone()); + shown + } +} + /// A gradient instance on an object, between `Bb` and `BB`. #[derive(Clone, Debug, Default)] pub(super) struct Instance { @@ -267,8 +423,9 @@ impl Instance { self.xm.is_some() || self.bm.is_some() } - /// The paint, through `to_doc` (art space → the document), when the gradient is defined. - pub fn paint(&self, defs: &HashMap, to_doc: Affine) -> Option { + /// The paint, through `to_doc` (art space → the document), when the gradient is defined, linked + /// to the gradient's swatch by the name `shown` gave it. + pub fn paint(&self, defs: &HashMap, shown: &ShownNames, to_doc: Affine) -> Option { let name = self.name.as_ref()?; let gradient = defs.get(name)?.clone(); let radial = gradient.kind == GradientKind::Radial; @@ -319,7 +476,8 @@ impl Instance { let focal = (self.hilight.hypot() > 1e-9).then_some(focal).filter(|p| p.is_finite()); let geom = GradientGeom { start, end, aspect, focal: if radial { focal } else { None } }; let angle = -(end - start).atan2().to_degrees(); - Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) }))) + let swatch = Some(shown.get(Global::Gradient, name).to_string()); + Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch, ..GradientPaint::new(gradient) }))) } } @@ -415,22 +573,59 @@ mod tests { bg.extend(nums(&[100.0, 100.0, 0.0, 50.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); i.bg(&bg); i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0])); - let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; + let shown = ShownNames::default(); + let Some(Paint::Gradient(gp)) = i.paint(&defs, &shown, Affine::IDENTITY) else { panic!() }; + assert_eq!(gp.swatch.as_deref(), Some("G")); let geom = gp.geom.unwrap(); assert_eq!(geom.start, Point::new(100.0, 100.0)); assert_eq!(geom.end, Point::new(150.0, 100.0)); assert!((geom.aspect - 1.0).abs() < 1e-9); - assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none()); + assert!(i.paint(&HashMap::new(), &shown, Affine::IDENTITY).is_none()); // Newer files give the matrix with `Xm`; the `Bm`s after it are bands of a cached ramp. let mut i = Instance::begin(&[]); i.bg(&bg); i.xm(&nums(&[40.0, 0.0, 0.0, -40.0, 10.0, 10.0])); i.bm(&nums(&[5.0, 0.0, 0.0, -40.0, 30.0, 10.0])); - let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; + let Some(Paint::Gradient(gp)) = i.paint(&defs, &shown, Affine::IDENTITY) else { panic!() }; let geom = gp.geom.unwrap(); assert_eq!((geom.start, geom.end), (Point::new(10.0, 10.0), Point::new(50.0, 10.0))); } + /// A name is shown up to its first newline, numbered when a reserved name or an earlier object + /// of either kind has it; a name defined again keeps the name it got, and a blank one gets its + /// kind's stand-in. A name without a newline is kept. + #[test] + fn global_names_are_shown_up_to_their_newline() { + let mut shown = ShownNames::default(); + // A color's name, and the names without a newline the file defines. + for name in ["Ink", "Sky", "Sky 2", "Sea"] { + shown.reserve(name); + } + let got: Vec = [ + (Global::Gradient, "Sky\n1"), + (Global::Gradient, "Sky\n2"), + (Global::Gradient, "Sky"), + (Global::Gradient, "Sky\n1"), + (Global::Pattern, "Sky\n1"), + (Global::Gradient, "Sea\nA\nB"), + (Global::Gradient, "\n3"), + (Global::Pattern, "\n4"), + (Global::Gradient, "Sky 2"), + (Global::Pattern, "Ink\n1"), + (Global::Gradient, "Ink"), + (Global::Pattern, "Sea"), + (Global::Pattern, "Tide\n1"), + (Global::Pattern, ""), + ] + .iter() + .map(|(kind, n)| shown.define(*kind, n)) + .collect(); + assert_eq!(got, ["Sky 3", "Sky 4", "Sky", "Sky 3", "Sky 5", "Sea 2", "Gradient", "Pattern", "Sky 2", "Ink 2", "Ink", "Sea", "Tide", ""]); + assert_eq!(shown.get(Global::Gradient, "Sky\n2"), "Sky 4"); + assert_eq!(shown.get(Global::Pattern, "Sky\n1"), "Sky 5"); + assert_eq!(shown.get(Global::Pattern, "Sky\n2"), "Sky\n2", "not a pattern's name"); + } + #[test] fn gradient_stops_beyond_the_cap_are_ignored() { let mut d = GradientDef::begin(&[V::Str(b"H".to_vec()), V::Num(0.0)]).unwrap(); @@ -481,7 +676,7 @@ mod tests { // From the stop at 20 to the one at 60, then on to the one at 100. i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 260.0])); i.bm(&nums(&[0.0, -80.0, -30.0, 0.0, 10.0, 180.0])); - let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; + let Some(Paint::Gradient(gp)) = i.paint(&defs, &ShownNames::default(), Affine::IDENTITY) else { panic!() }; gp.geom.unwrap() }; for xm in [true, false] { diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs index 9a4ba72eb..e816c5d6c 100644 --- a/crates/eps/src/import/ai/palette.rs +++ b/crates/eps/src/import/ai/palette.rs @@ -63,6 +63,14 @@ impl Palette { pub fn is_empty(&self) -> bool { self.swatches.is_empty() && self.groups.iter().all(|(_, s)| s.is_empty()) } + + /// The names the palette gives its color groups and swatches, except the name a swatch takes + /// from its gradient or pattern. + pub fn own_names(&self) -> impl Iterator { + let named = |e: &Entry| !matches!(&e.cell, Cell::Gradient(n) | Cell::Pattern(n) if *n == e.name); + let swatches = self.swatches.iter().chain(self.groups.iter().flat_map(|(_, s)| s)).filter(move |e| named(e)); + self.groups.iter().map(|(name, _)| name.as_str()).chain(swatches.map(|e| e.name.as_str())) + } } /// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end. diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index d6201ac41..57a40b30f 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -110,7 +110,7 @@ use super::super::ate::CANVAS_CENTRE; use super::super::graphics::HAIRLINE; use super::lex::{Lexer, Tok}; use super::obj::{self, Obj, V}; -use super::paint::{self, GradientDef, Instance, Named}; +use super::paint::{self, Global, GradientDef, Instance, Named, ShownNames}; use crate::ps; mod art; @@ -210,7 +210,7 @@ pub(super) fn remember_xml_id(xml_ids: &mut HashMap>, byt /// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may /// keep the reduced one, which the PDF import reads as it is (#2031). pub fn read(data: &[u8], large_canvas: bool) -> Result { - let mut r = Reader::new(data); + let mut r = Reader::with_names(data); r.large_canvas = large_canvas.then_some(1.0); r.run()?; r.finish() @@ -220,13 +220,129 @@ pub fn read(data: &[u8], large_canvas: bool) -> Result { /// data): the program's header gives the author and the custom colors the editing data's header /// doesn't. pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8]) -> Result { - let mut r = Reader::new(data); + let header = Reader::header_of(ps); + let mut r = Reader::with_names(data); r.large_canvas = large_canvas.then_some(1.0); + // The program's custom colors have swatches of their names, which no gradient or pattern takes. + for n in &header.custom_colors { + r.global_names.reserve(&n.name); + } r.run()?; - r.program_header(ps); + r.program_header(header); r.finish() } +/// The names the gradients and patterns of the editing data `data` get in the document +/// ([`ShownNames`]), with the names its other swatches can have reserved first: Registration, the +/// default swatches of both color modes when the palette the reader keeps is missing or empty, the +/// names that palette gives, the named colors, and the names without a newline of the gradients +/// and patterns. Names come only from what the reader reads: not from the alternates of versioned +/// content, the prolog, resources, sections it skips (such as symbols and the text document) or +/// what follows the trailer. The palette the reader keeps is the last one outside those and the +/// sections it reads on their own. +fn swatch_names(data: &[u8]) -> ShownNames { + /// The sections the reader reads on their own whose definitions and colors it keeps. + const READ_APART: &[&str] = &["BeginPattern", "BeginBrushPattern", "BeginPluginObject", "BeginArtStyles", "BeginArtStyleList"]; + /// Whether the marker `m` ends what began with `begin` and ends with `end`, `depth` of its kind + /// open inside it. + fn closes(begin: &str, end: &str, depth: &mut usize, m: &str) -> bool { + if !begin.is_empty() && m == begin { + *depth += 1; + } else if m == end { + match depth.checked_sub(1) { + Some(d) => *depth = d, + None => return true, + } + } + false + } + let mut names = ShownNames::default(); + names.reserve(vectorcraft_color::swatch::REGISTRATION); + let mut colors = paint::ColorNames::default(); + let mut lex = Lexer::new(data); + // What the reader skips wherever it is (the marker of one of its kind nested in it, its end + // marker, how many are open inside it, and where reading goes on when it doesn't end), the + // top-level section being passed over (the first three, and whether the reader reads it on its + // own), and where the palette being found starts. + let mut skip: Option<(&str, &str, usize, Option)> = None; + let mut section: Option<(&str, &str, usize, bool)> = None; + let (mut palette_at, mut palette) = (None, None); + // A pattern's name is the first string of its definition. + let mut head = false; + loop { + let before = lex.offset(); + let Some(t) = lex.next_token() else { + // Versioned content that doesn't end is read whole, as the reader reads it. + match skip.take() { + Some((.., Some(at))) => { + lex.seek(at); + continue; + } + _ => break, + } + }; + match &t.tok { + Tok::Comment(c) => { + let line = String::from_utf8_lossy(c); + let m = marker(&line); + if let Some((begin, end, depth, _)) = skip.as_mut() { + if closes(begin, end, depth, m) { + skip = None; + } + } else if let Some(at) = palette_at { + if m == "EndPalette" { + palette = Some(lex.slice(at, before)); + palette_at = None; + } + } else if m.starts_with("Alternate_Content") && section.is_none_or(|s| s.3) { + skip = Some(("Begin_Content_if_version_gt", "End_Versioned_Content", 0, Some(lex.offset()))); + } else if m == "%BeginProlog" { + skip = Some(("", "%EndProlog", 0, None)); + } else if m == "%BeginResource" { + skip = Some(("", "%EndResource", 0, None)); + } else if let Some((begin, end, depth, _)) = section.as_mut() { + if closes(begin, end, depth, m) { + section = None; + } + } else if m == "BeginPalette" { + palette_at = Some(lex.offset()); + } else if let Some((begin, end)) = types::SKIPPED.iter().find(|(b, _)| *b == m) { + section = Some((begin, end, 0, READ_APART.contains(begin))); + head = m == "BeginPattern"; + } else if matches!(m, "%PageTrailer" | "%Trailer") { + break; + } + continue; + } + // A palette's names are those of the palette kept, as the palette reads them. + _ if skip.is_some() || palette_at.is_some() || section.is_some_and(|s| !s.3) => continue, + Tok::Str(s) | Tok::Hex(s) if std::mem::take(&mut head) => { + let text = obj::text(s); + if !text.contains('\n') { + names.reserve(&text); + } + } + _ => {} + } + colors.token(t.tok); + } + colors.gradients.iter().filter(|n| !n.contains('\n')).for_each(|n| names.reserve(n)); + let palette = palette.map(super::palette::palette).unwrap_or_default(); + if palette.is_empty() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let (swatches, groups) = vectorcraft_color::default_swatches(mode.model()); + for g in &groups { + names.reserve(&g.name); + g.swatches.iter().for_each(|s| names.reserve(&s.name)); + } + swatches.iter().for_each(|s| names.reserve(&s.name)); + } + } + palette.own_names().for_each(|n| names.reserve(n)); + colors.names.iter().for_each(|n| names.reserve(n)); + names +} + struct Reader<'a> { lex: Lexer<'a>, stack: Vec, @@ -258,6 +374,8 @@ struct Reader<'a> { /// The Objects on Path records of the `/Document` dictionary and the ids of the objects. on_path: on_path::OnPath, gradients: HashMap, + /// The names the gradients and patterns get in the document, by their names in the file. + global_names: ShownNames, gradient_def: Option, instance: Option, pending_fill: Option, @@ -428,6 +546,14 @@ pub(crate) fn is_look_slot(name: Option<&str>) -> bool { } impl<'a> Reader<'a> { + /// A reader of the editing data `data` as a whole, with the names its gradients and patterns + /// get in the document found first ([`swatch_names`]). + fn with_names(data: &'a [u8]) -> Self { + let mut r = Self::new(data); + r.global_names = swatch_names(data); + r + } + fn new(data: &'a [u8]) -> Self { let mut doc = Document::new(1.0, 1.0); doc.layers.clear(); @@ -452,6 +578,7 @@ impl<'a> Reader<'a> { variables: VariableRead::default(), on_path: on_path::OnPath::default(), gradients: HashMap::new(), + global_names: ShownNames::default(), gradient_def: None, instance: None, pending_fill: None, diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 7db7fe85e..d9176869c 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -361,7 +361,7 @@ impl<'a> Reader<'a> { let (cx, cy) = ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0); to_doc *= Affine::new([1.0, 0.0, 0.0, -1.0, CANVAS_CENTRE - cx, CANVAS_CENTRE + cy]); } - match i.paint(&self.gradients, to_doc) { + match i.paint(&self.gradients, &self.global_names, to_doc) { Some(p) if i.stroke => self.pending_stroke = Some(p), Some(p) => self.pending_fill = Some(p), None => { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 693894daf..4361db5bf 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -4,7 +4,7 @@ use super::types::{ FLIP, GState, Kind, MAX_HEX, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED, }; -use super::{Arc, Lexer, Node, PatternDef, Reader, Rect, Tok, V, marker, obj}; +use super::{Arc, Global, Lexer, Node, PatternDef, Reader, Rect, Tok, V, marker, obj}; impl<'a> Reader<'a> { pub(super) fn push(&mut self, v: V) -> Result<(), String> { @@ -310,6 +310,8 @@ impl<'a> Reader<'a> { _ => return, }; if !name.trim().is_empty() && !self.custom_colors.iter().any(|n| n.name == name) { + // A gradient or pattern defined after it does not take its swatch's name. + self.global_names.reserve(&name); self.custom_colors.push(super::Named { name, color, tint: 1.0, spot: true }); } } @@ -362,13 +364,15 @@ impl<'a> Reader<'a> { let mut sub = Reader::new(section); sub.subreader_depth = self.subreader_depth + 1; sub.pattern = Some(PatternHead::default()); - // The gradients defined so far, lent (and those it defines kept). + // The gradients defined so far and the names given, lent (and those it defines kept). sub.gradients = std::mem::take(&mut self.gradients); + sub.global_names = std::mem::take(&mut self.global_names); sub.nodes = self.nodes; sub.template = self.template; let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); + self.global_names = std::mem::take(&mut sub.global_names); // Unread art in the definition, hidden or not, leaves the tile incomplete. let whole = read.is_ok() && sub.unsupported.is_empty() @@ -387,6 +391,7 @@ impl<'a> Reader<'a> { sub.subreader_depth = self.subreader_depth + 1; sub.pattern = Some(PatternHead { taken: true, name: def.name.clone(), tile: Some(def.tile) }); sub.gradients = std::mem::take(&mut self.gradients); + sub.global_names = std::mem::take(&mut self.global_names); sub.nodes = self.nodes; sub.template = self.template; let mut read = sub.open(Kind::Group, false); @@ -398,6 +403,7 @@ impl<'a> Reader<'a> { } self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); + self.global_names = std::mem::take(&mut sub.global_names); let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() @@ -473,15 +479,17 @@ impl<'a> Reader<'a> { return; }; let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); - let mut def = PatternDef::new(&head.name, art); + // Fills and the tables look the pattern up by its name in the file; the document has it by + // the name it shows. + let mut def = PatternDef::new(&self.global_names.define(Global::Pattern, &head.name), art); def.tile = FLIP.transform_rect_bbox(tile); - if self.pattern_tiles.insert(def.name.clone(), def.tile).is_some() { + if self.pattern_tiles.insert(head.name.clone(), def.tile).is_some() { self.doc.patterns.retain(|p| p.name != def.name); } if v7 { - self.v7_patterns.insert(def.name.clone()); + self.v7_patterns.insert(head.name); } else { - self.v7_patterns.remove(&def.name); + self.v7_patterns.remove(&head.name); } self.doc.patterns.push(def); for (key, blob) in sub.doc.images { @@ -570,12 +578,14 @@ impl<'a> Reader<'a> { // The gradients and patterns defined so far, lent. sub.gradients = std::mem::take(&mut self.gradients); sub.pattern_tiles = std::mem::take(&mut self.pattern_tiles); + sub.global_names = std::mem::take(&mut self.global_names); sub.nodes = self.nodes; sub.template = self.template; let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); self.pattern_tiles = std::mem::take(&mut sub.pattern_tiles); + self.global_names = std::mem::take(&mut sub.global_names); // Images aren't kept: the document's images don't count a brush's art among their users. Art // read with a note (something left out of it) isn't kept either. let whole = read.is_ok() diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index f283979c3..3fdf3491d 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -4,7 +4,7 @@ use super::containers::drop_commented_copies; use super::ops::appearance_from_art_style; use super::types::{FLIP, Kind, MAX_DEPTH}; use super::{ - Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, HashMap, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, + Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Global, HashMap, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj, }; use vectorcraft_doc::{DataSet, DataValue, GraphicStyle, Guide, ImageBlob, PatternDef, TextWrap, Variable, VariableKind, Variables}; @@ -231,7 +231,7 @@ fn repoint(n: &mut Node, renamed: &std::collections::BTreeMap) { /// editing data defines, which are named apart from `patterns` ([`beside`]); `None` when the /// palette is empty or reading stops before it. pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option { - let mut r = Reader::new(data); + let mut r = Reader::with_names(data); // The palette, the gradients and the patterns it names come before the art, so an error in the // art leaves them read. let _ = r.run(); @@ -281,7 +281,7 @@ pub struct Brushes { pub fn brushes(data: &[u8], patterns: &[PatternDef]) -> Option { // Brush objects are plug-in objects: data without one isn't read again. memchr::memmem::find(data, b"BeginPluginObject")?; - let mut r = Reader::new(data); + let mut r = Reader::with_names(data); // The brushes come before the art, so an error in the art leaves them read. let _ = r.run(); let (mut lib, unread) = r.brush_library()?; @@ -406,10 +406,23 @@ pub fn variables(data: &[u8]) -> VariableRead { } impl<'a> Reader<'a> { - /// Take the author and custom colors that the header of the program `ps` gives (its comment - /// lines up to `%%EndComments`), where the editing data's own header gives none: the program of - /// an EPS whose editing data is kept apart from it. - pub(super) fn program_header(&mut self, ps: &[u8]) { + /// Take the author and custom colors that the header of a program gives (`outer`, from + /// [`Self::header_of`]), where the editing data's own header gives none: the program of an EPS + /// whose editing data is kept apart from it. + pub(super) fn program_header(&mut self, outer: Reader<'_>) { + if self.author.is_none() { + self.author = outer.author; + } + for n in outer.custom_colors { + if self.custom_colors.len() < super::directives::MAX_CUSTOM_COLORS && !self.custom_colors.iter().any(|c| c.name == n.name) { + self.custom_colors.push(n); + } + } + } + + /// A reader that has read the header of the program `ps` (its comment lines up to + /// `%%EndComments`). + pub(super) fn header_of(ps: &[u8]) -> Reader<'_> { let mut end = 0; for line in ps.split_inclusive(|&b| b == b'\n' || b == b'\r') { if !line.starts_with(b"%") && !line.trim_ascii().is_empty() { @@ -422,14 +435,7 @@ impl<'a> Reader<'a> { } let mut outer = Reader::new(ps.get(..end).unwrap_or_default()); let _ = outer.run(); - if self.author.is_none() { - self.author = outer.author; - } - for n in outer.custom_colors { - if self.custom_colors.len() < super::directives::MAX_CUSTOM_COLORS && !self.custom_colors.iter().any(|c| c.name == n.name) { - self.custom_colors.push(n); - } - } + outer } } @@ -665,18 +671,26 @@ impl<'a> Reader<'a> { return None; } let palette = std::mem::take(&mut self.palette); - let swatch = |e: crate::import::ai::palette::Entry| -> Option { + let swatch = |mut e: crate::import::ai::palette::Entry| -> Option { use crate::import::ai::palette::Cell; let (paint, global, spot) = match e.cell { Cell::Color { color, global, spot } => (Paint::solid(color), global, spot), + // A swatch named by its gradient or pattern takes the name that got in the document. Cell::Gradient(g) => { let mut gp = vectorcraft_color::gradient::GradientPaint::new(self.gradients.get(&g)?.clone()); + if e.name == g { + e.name = self.global_names.get(Global::Gradient, &g).to_string(); + } gp.swatch = Some(e.name.clone()); (Paint::Gradient(Box::new(gp)), false, false) } Cell::Pattern(p) => { - patterns.iter().find(|d| d.name == p)?; - (Paint::Pattern { pattern: p, xf: Affine::IDENTITY }, false, false) + let pattern = self.global_names.get(Global::Pattern, &p).to_string(); + patterns.iter().find(|d| d.name == pattern)?; + if e.name == p { + e.name.clone_from(&pattern); + } + (Paint::Pattern { pattern, xf: Affine::IDENTITY }, false, false) } }; Some(Swatch { name: e.name, paint, global, spot }) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9844d7b3a..07bdb9564 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -5,8 +5,8 @@ use super::types::{ line_width, }; use super::{ - Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Gradient, GradientDef, Graph, HashMap, HashSet, Instance, - Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, StrokeLayer, V, obj, paint, + Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap, HashSet, Instance, + Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint, }; impl<'a> Reader<'a> { @@ -157,6 +157,7 @@ impl<'a> Reader<'a> { "Bs" => d.stop(&vals, &mut self.stop_spots), "BD" => { if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) { + self.global_names.define(Global::Gradient, &name); self.gradients.insert(name, g); } } @@ -478,7 +479,7 @@ impl<'a> Reader<'a> { self.unread_pattern(w); return; }; - let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; + let p = Paint::Pattern { pattern: name.as_deref().map(|n| self.global_names.get(Global::Pattern, n).to_string()).unwrap_or_default(), xf }; if w == "p" { self.pending_unread_fill = false; self.gs.fill = p; @@ -707,7 +708,13 @@ impl<'a> Reader<'a> { object, look, st, - &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, v7_patterns: &self.v7_patterns, space }, + &StylePaints { + gradients: &self.gradients, + patterns: &self.pattern_tiles, + names: &self.global_names, + v7_patterns: &self.v7_patterns, + space, + }, ) }) { // Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up). @@ -841,11 +848,12 @@ fn pattern_controls(n: &[f64]) -> Option { xf.as_coeffs().iter().all(|v| v.is_finite()).then_some(xf) } -/// What a style's fills paint with: the file's gradients and pattern tiles, the patterns defined in -/// the version 7 form, and art space → the document. +/// What a style's fills paint with: the file's gradients and pattern tiles with the names they get +/// in the document, the patterns defined in the version 7 form, and art space → the document. struct StylePaints<'a> { gradients: &'a HashMap, patterns: &'a HashMap, + names: &'a ShownNames, v7_patterns: &'a HashSet, space: Affine, } @@ -915,7 +923,8 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p let mut layer = match item { StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), StyleItem::Gradient { name, angle } => { - let paint = match (name.as_ref().and_then(|n| placed.get(n)), gradients, own_gradient) { + // The drawn look's gradients are keyed by the names they got in the document. + let paint = match (name.as_ref().and_then(|n| placed.get(paints.names.get(Global::Gradient, n))), gradients, own_gradient) { // Other objects than type: the object's own, as they always were. (_, 1, Some(own)) if !slot => own.paint.clone(), (Some(p), ..) => p.clone(), @@ -930,7 +939,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p }; AppearanceItem::Fill(FillLayer::new(paint)) } - StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{name}")) { + StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{}", paints.names.get(Global::Pattern, name))) { Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), None => { let tile = paints.patterns.get(name).copied()?; @@ -939,7 +948,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); } let xf = pattern_xf(paints.space, matrix, tile)?; - AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf })) + AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: paints.names.get(Global::Pattern, name).to_string(), xf })) } }, StyleItem::Stroke { .. } => stroke_layer(item)?, diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 16a018676..5f937fa88 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -605,6 +605,36 @@ fn a_gradient_stop_naming_a_process_colour_leaves_its_swatch_process() { assert!(near(sw.paint.color()), "{sw:?}"); } +/// A gradient whose name in the file is shown as the name of a color a later gradient's stop names +/// is numbered: the color keeps its swatch. +#[test] +fn a_gradient_shown_as_a_stops_color_name_is_numbered() { + let first = "%AI5_BeginGradient: (Test Cyan\\n1)\n(Test Cyan\\n1) 0 2 Bd\n[\n%_1 0 50 0 Bs\n%_0 0 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let brand = "%AI5_BeginGradient: (Brand)\n(Brand) 0 2 Bd\n[\n\ + %_0.93 0.1 0 0 (Test Cyan) 0 0 5 1 6 50 0 Bs\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let art_ = format!("Bb\n1 (Test Cyan\\n1) 0 0 0 10 1 0 0 1 0 0 Bg\n{}f\n0 BB\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let doc = read_ok(&format!("{first}{brand}{}", layer(&art_))).doc; + let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!("{:?}", fill(&art(&doc)[0])) }; + assert_eq!(g.swatch.as_deref(), Some("Test Cyan 2")); + let sw = doc.swatch("Test Cyan").unwrap(); + assert!(matches!(sw.paint, Paint::Solid { .. }) && sw.global, "{sw:?}"); +} + +/// A gradient whose name has no newline keeps it when a gradient shown as that name is defined +/// first: the one shown up to its newline is numbered. +#[test] +fn a_gradient_name_without_a_newline_is_kept_whichever_comes_first() { + let gradient = |name: &str| format!("%AI5_BeginGradient: ({name})\n({name}) 0 2 Bd\n[\n%_1 0 50 0 Bs\n%_0 0 50 100 Bs\nBD\n%AI5_EndGradient\n"); + let filled = |name: &str| format!("Bb\n1 ({name}) 0 0 0 10 1 0 0 1 0 0 Bg\n{}f\n0 BB\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let doc = read_ok(&format!("{}{}{}", gradient("Sky\\n1"), gradient("Sky"), layer(&format!("{}{}", filled("Sky\\n1"), filled("Sky"))))).doc; + let links: Vec<_> = art(&doc).iter().map(|n| if let Paint::Gradient(g) = fill(n) { g.swatch.clone() } else { None }).collect(); + assert_eq!(links, [Some("Sky 2".to_string()), Some("Sky".to_string())]); + // A gradient without stops isn't kept, and its name stays free. + let empty = "%AI5_BeginGradient: (Sky)\n(Sky) 0 0 Bd\n[\nBD\n%AI5_EndGradient\n"; + let doc = read_ok(&format!("{empty}{}{}", gradient("Sky\\n1"), layer(&filled("Sky\\n1")))).doc; + assert!(matches!(fill(&art(&doc)[0]), Paint::Gradient(g) if g.swatch.as_deref() == Some("Sky"))); +} + /// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up), /// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped /// stroke of no length), `E`. @@ -688,6 +718,116 @@ fn patterns_are_pattern_swatches_and_fill_with_them() { assert!(s.warnings.is_empty(), "{:?}", s.warnings); } +/// A pattern's name in the file is shown up to its newline (AI7 spec §4): two patterns named +/// `Dots` apart from what follows the newline are "Dots" and "Dots 2", each fill paints with its +/// own, and one defined again under its name in the file replaces the first. +#[test] +fn pattern_names_are_shown_up_to_their_newline() { + let def = |name: &str, tile: u32| DOTS.replace("(Dots)", &format!("({name})")).replace("0 0 8 8\n", &format!("0 0 {tile} {tile}\n")); + let filled = |name: &str| format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0)); + let body = format!( + "{}{}{}{}", + def("Dots\\n1", 8), + def("Dots\\n2", 8), + def("Dots\\n2", 9), + layer(&format!("{}{}", filled("Dots\\n2"), filled("Dots\\n1"))) + ); + let s = read_ok(&body); + let doc = &s.doc; + let tiles: Vec<_> = doc.patterns.iter().map(|p| (p.name.as_str(), p.tile.width())).collect(); + assert_eq!(tiles, [("Dots", 8.0), ("Dots 2", 9.0)]); + for name in ["Dots", "Dots 2"] { + assert!(matches!(&doc.swatch(name).unwrap().paint, Paint::Pattern { pattern, .. } if pattern == name), "{name}"); + } + let fills: Vec<_> = art(doc).iter().map(|n| if let Paint::Pattern { pattern, .. } = fill(n) { pattern } else { String::new() }).collect(); + assert_eq!(fills, ["Dots 2", "Dots"]); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); + // Without a palette (or with an empty one) the document keeps its default swatches, and a named + // color the art uses has its swatch: a pattern takes none of their names. + let empty = "%AI5_BeginPalette\n0 0 Pb\nPB\n%AI5_EndPalette\n"; + let ink = format!("0 1 1 0 (Ink) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0)); + for palette in ["", empty] { + let body = + format!("{}{}{palette}{}", def("Black\\n1", 8), def("Ink\\n1", 8), layer(&format!("{}{}{ink}", filled("Black\\n1"), filled("Ink\\n1")))); + let doc = read_ok(&body).doc; + for (solid, pattern) in [("Black", "Black 2"), ("Ink", "Ink 2")] { + assert!(matches!(&doc.swatch(pattern).unwrap().paint, Paint::Pattern { pattern: p, .. } if p == pattern), "{palette:?}"); + assert!(matches!(doc.swatch(solid).unwrap().paint, Paint::Solid { .. }), "{palette:?}"); + } + let fills: Vec<_> = art(&doc).iter().map(|n| fill(n)).collect(); + assert!(matches!(&fills[..2], [Paint::Pattern { pattern: a, .. }, Paint::Pattern { pattern: b, .. }] if a == "Black 2" && b == "Ink 2")); + } + // Nor the name of a custom color the program of an EPS gives, or None's. + let ps = b"%!PS-Adobe-3.0\n%%CMYKCustomColor: 0 0 1 0 (Outer Ink)\n%%EndComments\n"; + let data = stream(&format!("{}{}{}", def("Outer Ink\\n1", 8), def("[None]\\n1", 8), layer(&filled("Outer Ink\\n1")))); + let doc = super::read_beside(data.as_bytes(), true, ps).unwrap().doc; + assert_eq!(doc.patterns.iter().map(|p| p.name.as_str()).collect::>(), ["Outer Ink 2", "[None] 2"]); + assert!(matches!(doc.swatch("Outer Ink").unwrap().paint, Paint::Solid { .. })); + assert!(matches!(fill(&art(&doc)[0]), Paint::Pattern { pattern, .. } if pattern == "Outer Ink 2")); +} + +/// A pattern shown as the name of one of the document's other swatches is numbered: a swatch or a +/// color group of the palette the reader keeps (under any version's prefix, a swatch it names by +/// its values too), Registration, and a custom color of the editing data's own header. A pattern +/// whose name has no newline keeps it, whichever is defined first. The default swatches go when a +/// palette replaces them, and their names are free then; a palette only in versioned content that +/// isn't read replaces nothing. +#[test] +fn pattern_names_are_numbered_around_the_documents_swatches() { + let def = |name: &str| DOTS.replace("(Dots)", &format!("({name})")); + let palette = "%AI12_BeginPalette\n0 0 Pb\n0 0 0 0.5 k\n(Mist)\nPc\n1 (Greys) 1 Pg\n0 0 0 0.2 k\nPc\nPB\n%AI12_EndPalette\n"; + let header = "%%CMYKCustomColor: 0 0 1 0 (Head Ink)\n"; + let doc = |defs: &[&str], palette: &str| { + let body = format!("{}{palette}{}", defs.iter().map(|n| def(n)).collect::(), layer("")); + read(stream_with_header(header, &body).as_bytes()).unwrap().doc + }; + let names = |d: &Document| d.patterns.iter().map(|p| p.name.clone()).collect::>(); + let d = doc(&["Mist\\n1", "Greys\\n1", "[Registration]\\n1", "Head Ink\\n1", "Red\\n1", "Sky\\n1", "Sky"], palette); + let generated = d.swatch_groups[0].swatches[0].name.clone(); + let all: Vec<&str> = d.swatches_iter().map(|s| s.name.as_str()).collect(); + assert!(d.swatch("Mist").is_some() && d.swatch("Head Ink").is_some(), "{all:?}"); + assert!(matches!(&d.swatch("Red").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Red"), "{all:?}"); + assert_eq!(names(&d), ["Mist 2", "Greys 2", "[Registration] 2", "Head Ink 2", "Red", "Sky 2", "Sky"]); + let d = doc(&[&format!("{generated}\\n1")], palette); + assert_eq!(names(&d), [format!("{generated} 2")]); + // Without a palette the default swatches stay, and so they do when the only palette is in + // versioned content that isn't read. + let versioned = format!("%AI17_Begin_Content_if_version_gt:17 1\n%AI17_Alternate_Content\n{palette}%AI17_End_Versioned_Content\n"); + for palette in ["", &versioned] { + let d = doc(&["Red\\n1"], palette); + assert!(matches!(d.swatch("Red").unwrap().paint, Paint::Solid { .. }), "{palette:?}"); + assert_eq!(names(&d), ["Red 2"], "{palette:?}"); + } + // A pattern and a color in versioned content that isn't read take no name. + let unread = format!( + "%AI17_Begin_Content_if_version_gt:17 1\n%AI17_Alternate_Content\n{}{}%AI17_End_Versioned_Content\n", + def("Sky"), + layer("0 1 1 0 (Sea) 0 x\n") + ); + let d = doc(&["Sky\\n1", "Sea\\n1"], &unread); + assert_eq!(names(&d), ["Sky", "Sea"]); + // Nor does a color of a palette that a later one replaces, or of a palette's alternate. + let replaced = "%AI5_BeginPalette\n0 0 Pb\n0 1 1 0 (Sky) 0 x\n(Sky)\nPc\nPB\n%AI5_EndPalette\n\ + %AI5_BeginPalette\n0 0 Pb\n%AI17_Begin_Content_if_version_gt:17 1\n0 0 0 1 k\n(Ink)\nPc\n\ + %AI17_Alternate_Content\n0 1 0 0 (Sea) 0 x\n(Sea)\nPc\n%AI17_End_Versioned_Content\nPB\n%AI5_EndPalette\n"; + let d = doc(&["Sky\\n1", "Sea\\n1"], replaced); + assert_eq!(names(&d), ["Sky", "Sea"]); + // Versioned content that doesn't end is read whole, its colors too. + let unended = format!("%AI17_Begin_Content_if_version_gt:17 1\n%AI17_Alternate_Content\n{}", layer("0 1 1 0 (Sea) 0 x\n")); + let d = doc(&["Sea\\n1"], &unended); + assert_eq!(names(&d), ["Sea 2"]); +} + +/// A palette's pattern swatch named by its pattern takes the name the pattern is shown with. +#[test] +fn a_palette_pattern_swatch_takes_its_patterns_shown_name() { + let palette = "%AI5_BeginPalette\n0 0 Pb\n(Dots\\n1) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\nPc\nPB\n%AI5_EndPalette\n"; + let doc = read_ok(&format!("{}{palette}{}", DOTS.replace("(Dots)", "(Dots\\n1)"), layer(""))).doc; + let names: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["[None]", "Dots"]); + assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots")); +} + /// Where a definition draws its tile doesn't move the fill: the tile's lower left is the pattern's /// origin, so the same dots drawn at 30..38 × 40..48 tile as they do at 0..8 × 0..8. #[test] @@ -855,6 +995,17 @@ fn a_version_7_patterns_underbar_fills_the_tile_box() { assert!(def.art[1].appearance.stroke().is_some()); } +/// A version 7 pattern shown as the name of a custom color its own layer strings use is numbered: +/// the color keeps its swatch. +#[test] +fn a_version_7_pattern_shown_as_its_colors_name_is_numbered() { + let layers = "(0 O 0 R 0 0 0 1 \\(Ink\\) 0 x) @ (2 2 m 9 2 L 9 9 L f) &\n"; + let s = pattern_read7(&pattern7("Ink\\n1", layers), &pattern_layer7("Art", "")); + assert_eq!(s.doc.patterns.iter().map(|p| p.name.as_str()).collect::>(), ["Ink 2"]); + assert!(matches!(&s.doc.swatch("Ink 2").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Ink 2")); + assert!(matches!(s.doc.swatch("Ink").unwrap().paint, Paint::Solid { .. })); +} + /// A layer's strings are read with their escapes undone: `\r`, `\n`, an octal tab, a backslash at /// a line's end, and escaped parentheses around a custom color's name. #[test] @@ -995,6 +1146,20 @@ fn a_style_pattern_fill_is_placed_as_a_direct_one() { } } +/// An art style's pattern fill paints with the pattern under the name it is shown with. +#[test] +fn a_style_pattern_fill_paints_with_the_shown_name() { + let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n"; + let own = "%_/AI11Text :\n%_0 /FreeUndo ,\n%_0 /FrameIndex ,\n%_0 /StoryIndex ,\n%_;\n"; + let defs = format!("{}{}", pattern7("Grid", "(0 g) @ _ &\n"), pattern7("Grid\\n1", "(0 g) @ _ &\n")); + let s = read_ok(&format!("{}{defs}{}", tiled_style("Grid\\n1", "0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0]"), layer(&format!("{look}{own}1 (Tiled) XW\n")))); + let pattern = |n: &Node| match fill(n) { + Paint::Pattern { pattern, .. } => pattern, + p => panic!("{p:?} {:?} {:?}", s.unread, s.warnings), + }; + assert_eq!(pattern(&art(&s.doc)[0]), "Grid 2"); +} + #[test] fn compound_paths_and_clipping() { let art_ = format!( diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index d921bccdb..065caa066 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -446,6 +446,47 @@ fn a_version_7_palette_keeps_its_swatches_without_names() { assert_eq!(d.swatch("Gray K=0").unwrap().paint.color(), Some(Color::gray(0.0)), "1 g is white"); } +/// A gradient's name in the file is shown up to its newline (the AI7 spec's §4): two gradients +/// named `Sky` apart from what follows the newline come in as swatches "Sky" and "Sky 2", and each +/// object keeps its own gradient, linked to its own swatch. A palette swatch that has the name +/// "Sky" of its own keeps it, and the gradients take the next free names. +#[test] +fn gradient_names_are_shown_up_to_their_newline() { + let gradient = |name: &str, gray: f64| { + format!("%AI5_BeginGradient: ({name})\n({name}) 0 2 Bd\n[\n%_{gray} 0 50 0 Bs\n%_0 0 50 100 Bs\nBD\n%AI5_EndGradient\n") + }; + let cell = |name: &str| format!("Bb\n2 ({name}) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\nPc\n"); + let fill = |name: &str, x: u32| format!("Bb\n1 ({name}) 0 0 0 100 1 0 0 1 0 0 Bg\n{}\n0 BB\n", tri(x)); + let body = format!( + "%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n(Art) Ln\n0 A\n0 Xw\n{}{}LB\n%AI5_EndLayer--\n", + fill("Sky\\n2", 10), + fill("Sky\\n1", 30) + ); + let first_stop = |p: &Paint| match p { + Paint::Gradient(g) => (g.swatch.clone(), g.gradient.stops.first().map(|s| s.color)), + _ => panic!("{p:?}"), + }; + for (own, first, second) in [("", "Sky", "Sky 2"), ("0 1 1 0 (Sky) 0 x\nPc\n", "Sky 2", "Sky 3")] { + let setup = format!( + "{}{}%AI5_BeginPalette\n0 0 Pb\nPn\nPc\n{own}{}{}PB\n%AI5_EndPalette\n", + gradient("Sky\\n1", 1.0), + gradient("Sky\\n2", 0.5), + cell("Sky\\n1"), + cell("Sky\\n2") + ); + let r = import(&stream7(&setup, &body)).unwrap(); + let d = &r.document; + let names: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect(); + let expected: Vec<&str> = ["[None]"].into_iter().chain((!own.is_empty()).then_some("Sky")).chain([first, second]).collect(); + assert_eq!(names, expected, "{:?}", r.warnings); + assert_eq!(first_stop(&d.swatch(first).unwrap().paint), (Some(first.into()), Some(Color::gray(0.0)))); + assert_eq!(first_stop(&d.swatch(second).unwrap().paint), (Some(second.into()), Some(Color::gray(0.5)))); + let art = d.layers[0].children().unwrap(); + let fills: Vec<_> = art.iter().map(|n| first_stop(&n.appearance.fill().unwrap().paint)).collect(); + assert_eq!(fills, [(Some(second.into()), Some(Color::gray(0.5))), (Some(first.into()), Some(Color::gray(0.0)))]); + } +} + /// A version 8 file's paths keep their notes and Show Center. #[test] fn a_legacy_files_paths_keep_their_notes_and_show_center() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ef231b799..88b22a92e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -143,7 +143,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a brush or dashes aligned to corners comes in as its drawn look (a group named after it). The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a brush or dashes aligned to corners comes in as its drawn look (a group named after it). The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format