diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 8283a3ae3..778ecd777 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -82,6 +82,29 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() { assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]); } +#[test] +fn an_ai_file_opens_with_its_artboard_options_raster_settings_and_author() { + let document = "%_/Document :\n%_/Dictionary :\n%_/Dictionary :\n%_150 /Int (dpi.) ,\n%_1 /Bool (mask) ,\n%_; (AI Auto Rasterize) ,\n\ + %_/Array :\n%_/Dictionary :\n%_3 /Int (DisplayMark) ,\n%_1.33 /Real (PAR) ,\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n\ + %_200 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(Board) /UnicodeString (Name) ,\n%_; ,\n%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + // The sample (it holds binary samples) with `insert` before the first `at`. + let before = |data: Vec, at: &[u8], insert: &str| { + let i = data.windows(at.len()).position(|w| w == at).unwrap(); + [&data[..i], insert.as_bytes(), &data[i..]].concat() + }; + let data = before(before(ai::sample_data(), b"%%EndComments", "%%For: (Ann Lee) (Studio)\n"), b"%AI5_BeginLayer", document); + let mut s = Session::new(); + let r = open(&mut s, "art.ai", &ai::ai(&data, ai::page_pdf()), json!({})).unwrap(); + check_sample(&s); + let d = &s.doc().unwrap().doc; + assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("weren't read")), "{r}"); + let b = &d.artboards[0]; + assert_eq!((b.name.as_str(), b.show_center_mark, b.show_cross_hairs, b.show_video_safe_areas), ("Board", true, true, false)); + assert_eq!(b.video_ruler_pixel_aspect_ratio, 1.33); + assert_eq!((d.raster_effects_ppi, d.raster_effects.clipping_mask), (150.0, true)); + assert_eq!(d.metadata.author, "Ann Lee"); +} + #[test] fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() { let live = format!("0 0 1 0 k\n{}f", ai::rect(10.0, 10.0, 14.0, 14.0)); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 54b5a6f13..c7c7ebf9d 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3340,6 +3340,17 @@ fn arb_guide_coord() -> impl Strategy { ] } +/// A value of a `/Document` setting: a number of any type, several numbers, a string or a +/// dictionary. +fn arb_ai_setting() -> impl Strategy { + prop_oneof![ + (arb_num(), prop::sample::select(vec!["/Int", "/Real", "/Bool", "/String", ""])).prop_map(|(n, t)| format!("{n} {t}")), + (arb_num(), arb_num()).prop_map(|(a, b)| format!("{a} {b} /RealPoint\n%_")), + Just("(text) /String".to_string()), + Just("/Dictionary :\n%_1 /Int (x) ,\n%_;\n%_".to_string()), + ] +} + /// The operators, operands and section comments of hostile editing data. fn arb_ai_token() -> impl Strategy { prop_oneof![ @@ -3795,6 +3806,51 @@ proptest! { survive("layer options and ruler guides in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; } + /// Artboard display options and Document Raster Effects Settings of any type and value, and + /// header comments (`%%For`, custom colors and their `%%+` lines, `%%TemplateBox`) with any + /// operands. + #[test] + fn ai_document_settings_never_panic( + boards in prop::collection::vec(prop::collection::vec((prop::sample::select(vec!["DisplayMark", "PAR"]), arb_ai_setting()), 0..4), 0..4), + raster in prop::collection::vec((prop::sample::select(vec!["dpi.", "mask", "alis", "spot", "padd", "colr"]), arb_ai_setting()), 0..8), + header in prop::collection::vec( + ( + prop::sample::select(vec!["%%For:", "%%CMYKCustomColor:", "%%RGBCustomColor:", "%%+", "%%TemplateBox:", "%%DocumentCustomColors:"]), + prop::collection::vec(prop_oneof![arb_num(), "\\(?[ -~]{0,12}\\)?".prop_map(|s| s)], 0..6), + ), + 0..10, + ), + ) { + use vectorcraft_testkit::ai; + let entries = |e: &[(&str, String)]| e.iter().map(|(k, v)| format!("%_{v} ({k}) ,\n")).collect::(); + let array: String = boards + .iter() + .enumerate() + .map(|(i, e)| { + format!( + "%_/Dictionary :\n{}%_{} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + entries(e), + i * 100, + i * 100 + 100 + ) + }) + .collect(); + let document = format!( + "%_/Document :\n%_/Dictionary :\n%_/Dictionary :\n{}%_; (AI Auto Rasterize) ,\n%_/Array :\n{array}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", + entries(&raster) + ); + let comments: String = header.iter().map(|(c, ops)| format!("{c} {}\n", ops.join(" "))).collect(); + let body = format!("{document}{}", ai::layer("L", &format!("{}f\n", ai::rect(0.0, 0.0, 10.0, 10.0)))); + let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", &format!("{comments}%%EndComments"), 1); + survive("document settings", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let eps = ai::eps(data.as_bytes(), ai::page_ps()); + survive("document settings in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + // The same comments in the EPS's own header. + let at = eps.windows(13).position(|w| w == b"%%EndComments").unwrap_or(0); + let outer = [&eps[..at], comments.as_bytes(), &eps[at..]].concat(); + survive("document settings in an EPS's header", || vectorcraft_engine::cmd::fileio::load("x.eps", &outer).ok().map(|l| l.doc))?; + } + /// The testkit's sample damaged, as it is and compressed, in EPS, `.ai` and PDF files. #[test] fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::()), 0..12)) { diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index c32bf2217..98f408578 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -35,7 +35,7 @@ use crate::ps; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; pub(crate) use frames::text_frames; -pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables}; +pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, read_beside, variables}; pub(crate) use read::{join_and, 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 9a0c3b5d2..a18ba51d1 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -17,9 +17,13 @@ //! back to it. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`. //! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`. //! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id. -//! - 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. +//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, with each one's display +//! options, else `%AI3_Cropmarks`, else the art size around the template box's centre +//! (`%AI3_TemplateBox`, `%%TemplateBox` in older files), else (a file in the legacy format +//! another app wrote) the bounding box. +//! - Document settings: Document Raster Effects Settings from the `/Document` dictionary's +//! `AI Auto Rasterize`; the author from the header's `%%For`; swatches for the custom colors of +//! `%%CMYKCustomColor` and `%%RGBCustomColor` that the document has none of. //! - Variables: the variable library, XML nodes of the `/Document` dictionary, isn't read; a note //! gives how many variables and data sets it has ([`VariableLibrary`]). //! - Objects on Path: a group whose objects' dictionaries carry ids, and a record of the @@ -141,6 +145,17 @@ pub fn read(data: &[u8], large_canvas: bool) -> Result { r.finish() } +/// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private +/// 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); + r.large_canvas = large_canvas.then_some(1.0); + r.run()?; + r.program_header(ps); + r.finish() +} + struct Reader<'a> { lex: Lexer<'a>, stack: Vec, @@ -158,7 +173,15 @@ struct Reader<'a> { doc: Document, to_doc: Option, cropmarks: Option, - artboards: Vec<(String, Rect)>, + artboards: Vec, + /// The user the header's `%%For` names. + author: Option, + /// The custom colors the header's `%%CMYKCustomColor` and `%%RGBCustomColor` define. + custom_colors: Vec, + /// The last header comment defined custom colors (CMYK when true), which a `%%+` line continues. + custom_more: Option, + /// The header ended (`%%EndComments`). + header_done: bool, /// The variables and data sets of the `/Document` dictionary's variable library. variables: VariableLibrary, /// The Objects on Path records of the `/Document` dictionary and the ids of the objects. @@ -306,6 +329,10 @@ impl<'a> Reader<'a> { to_doc: None, cropmarks: None, artboards: Vec::new(), + author: None, + custom_colors: Vec::new(), + custom_more: None, + header_done: false, variables: VariableLibrary::default(), on_path: on_path::OnPath::default(), gradients: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index ce5bfa41d..4814b8704 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -49,6 +49,33 @@ impl<'a> Reader<'a> { } return Ok(()); } + // The header's comments (AI7 specification §2.1.1; DSC 3.0). + let more = self.custom_more.take(); + if !self.header_done && self.frames.is_empty() { + let bytes = c.trim_ascii_end(); + if let (Some(cmyk), Some(v)) = (more, bytes.strip_prefix(b"%+")) { + self.custom_color(v, cmyk); + return Ok(()); + } + if let Some(v) = bytes.strip_prefix(b"%CMYKCustomColor:") { + self.custom_color(v, true); + return Ok(()); + } + if let Some(v) = bytes.strip_prefix(b"%RGBCustomColor:") { + self.custom_color(v, false); + return Ok(()); + } + if let Some(v) = bytes.strip_prefix(b"%For:") { + // `(username) (organization)`: the user who created the file. + if self.author.is_none() { + self.author = ps_string(v).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + } + return Ok(()); + } + if bytes == b"%EndComments" { + self.header_done = true; + } + } if self.note_comment(c) { return Ok(()); } @@ -141,7 +168,7 @@ impl<'a> Reader<'a> { self.ruler_units = Some(n); } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { self.art_size = two(v); - } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:") { + } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:").or_else(|| line.strip_prefix("%TemplateBox:")) { self.template_center = two(v); self.template = four(v).or(self.template); } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { @@ -198,6 +225,32 @@ impl<'a> Reader<'a> { true } + /// A custom color of the header, `cyan magenta yellow black (name)` (`cmyk`) or + /// `red green blue (name)`, each component from 0 to 1. One written otherwise isn't kept. + fn custom_color(&mut self, v: &[u8], cmyk: bool) { + self.custom_more = Some(cmyk); + if self.custom_colors.len() >= MAX_CUSTOM_COLORS { + return; + } + let Some(open) = v.iter().position(|&b| b == b'(') else { return }; + let (Some(head), Some(name)) = (v.get(..open), v.get(open..).and_then(ps_string)) else { return }; + // One more than a color has, so that too many are seen. + let parts: Vec> = head + .split(u8::is_ascii_whitespace) + .filter(|w| !w.is_empty()) + .take(5) + .map(|w| std::str::from_utf8(w).ok()?.parse::().ok().filter(|n| (0.0..=1.0).contains(n))) + .collect(); + let color = match parts.as_slice() { + [Some(c), Some(m), Some(y), Some(k)] if cmyk => vectorcraft_color::Color::cmyk(*c, *m, *y, *k), + [Some(r), Some(g), Some(b)] if !cmyk => vectorcraft_color::Color::rgb(*r, *g, *b), + _ => return, + }; + if !name.trim().is_empty() && !self.custom_colors.iter().any(|n| n.name == name) { + self.custom_colors.push(super::Named { name, color, tint: 1.0, spot: true }); + } + } + /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and /// versioned content in an alternate). fn skip_to(&mut self, end: &str) -> Result<(), String> { @@ -320,10 +373,78 @@ impl<'a> Reader<'a> { } } +/// How many custom colors the header may define. +pub(super) const MAX_CUSTOM_COLORS: usize = 1000; + +/// The longest string the header's comments may give, in bytes. +const MAX_STRING: usize = 4096; + +/// The PostScript string `v` starts with (after spaces), its escapes read, when it ends on the line +/// and holds at most [`MAX_STRING`] bytes. +fn ps_string(v: &[u8]) -> Option { + let mut bytes = v.trim_ascii_start().iter().copied().peekable(); + if bytes.next()? != b'(' { + return None; + } + let (mut out, mut depth) = (Vec::new(), 0usize); + while let Some(b) = bytes.next() { + if out.len() > MAX_STRING { + return None; + } + match b { + b'\\' => match bytes.next()? { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(8), + b'f' => out.push(12), + d @ b'0'..=b'7' => { + let mut n = u32::from(d - b'0'); + for _ in 0..2 { + match bytes.peek() { + Some(&o @ b'0'..=b'7') => { + n = n * 8 + u32::from(o - b'0'); + bytes.next(); + } + _ => break, + } + } + out.push((n & 0xff) as u8); + } + other => out.push(other), + }, + b'(' => { + depth += 1; + out.push(b); + } + b')' => { + let Some(d) = depth.checked_sub(1) else { return Some(super::obj::text(&out)) }; + depth = d; + out.push(b); + } + _ => out.push(b), + } + } + None +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn postscript_strings_read_their_escapes_and_end_on_the_line() { + assert_eq!(ps_string(b" (Ann \\(R\\) (x) \\101\\0618) (Org)").as_deref(), Some("Ann (R) (x) A18")); + assert_eq!(ps_string(b"(a\\nb\\\\)").as_deref(), Some("a\nb\\")); + assert_eq!(ps_string(b"(never ends \\)"), None); + assert_eq!(ps_string(b"(ends in a backslash\\"), None); + assert_eq!(ps_string(b"no string"), None); + let long = format!("({})", "x".repeat(MAX_STRING + 1)); + assert_eq!(ps_string(long.as_bytes()), None); + let longest = format!("({})", "x".repeat(MAX_STRING)); + assert_eq!(ps_string(longest.as_bytes()).map(|s| s.len()), Some(MAX_STRING)); + } + #[test] fn deeply_nested_symbol_definitions_stop_at_subreader_limit() { let mut definition = String::from("(Outer) 0 Ae\n"); diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ef41a5f1c..91d8661b9 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -20,6 +20,92 @@ fn ruler_unit(v: u8) -> Option { }) } +/// An artboard of the `/Document`'s `ArtboardArray`: its name, its rect in art space and its +/// display options. +#[derive(Clone, Debug)] +pub(super) struct AiArtboard { + pub name: String, + pub rect: Rect, + pub center_mark: bool, + pub cross_hairs: bool, + pub safe_areas: bool, + /// The video ruler's pixel aspect ratio. + pub par: f64, +} + +impl AiArtboard { + fn new(name: String, rect: Rect) -> Self { + Self { name, rect, center_mark: false, cross_hairs: false, safe_areas: false, par: 1.0 } + } +} + +/// The number of entry `key` in `o` when it is written as one finite number of the type `ty` +/// (`/Int`, `/Real`, `/Bool`; a whole number unless `/Real`), else `None`. +fn typed(o: &Obj, key: &str, ty: &str) -> Option { + match o.get(key)? { + [V::Num(v), V::Name(t)] if t == ty && v.is_finite() && (ty == "Real" || v.fract() == 0.0) => Some(*v), + _ => None, + } +} + +/// The `/Bool` entry `key` of `o`: 0 or 1. +fn boolean(o: &Obj, key: &str) -> Option { + match typed(o, key, "Bool")? { + 0.0 => Some(false), + 1.0 => Some(true), + _ => None, + } +} + +/// An `ArtboardArray` entry's display options: `DisplayMark` (`/Int`, bits: 1 the center mark, +/// 2 cross hairs, 4 video safe areas) and `PAR` (`/Real`, the video ruler's pixel aspect ratio, +/// 0.1 to 10). A value of another type or out of range leaves the option off, or 1. +fn display_options(ab: &Obj, board: &mut AiArtboard) { + if let Some(mark) = typed(ab, "DisplayMark", "Int").filter(|m| (0.0..=f64::from(u32::MAX)).contains(m)) { + let mark = mark as u32; + (board.center_mark, board.cross_hairs, board.safe_areas) = (mark & 1 != 0, mark & 2 != 0, mark & 4 != 0); + } + if let Some(par) = typed(ab, "PAR", "Real").filter(|p| (0.1..=10.0).contains(p)) { + board.par = par; + } +} + +/// The `AI Auto Rasterize` dictionary of the `/Document` (Document Raster Effects Settings) into +/// `doc`: `dpi.` the resolution (`/Int`, 1 to 2400), `mask`, `alis` and `spot` the clipping mask, +/// anti-aliasing and Preserve Spot Colors (`/Bool`), `padd` the room added around the object +/// (`/Real`, points), and `colr` the color model and background (`/Int`: 0 the document's model on +/// white, 4 the document's model, transparent, 6 grayscale, transparent). A value of another type, +/// out of range or not one of these leaves the setting as it is. +fn raster_settings(r: &Obj, doc: &mut Document) { + use vectorcraft_doc::rastersettings::{MAX_ADD_AROUND, RasterColorModel}; + use vectorcraft_doc::setup::Background; + if let Some(ppi) = typed(r, "dpi.", "Int").filter(|p| (1.0..=2400.0).contains(p)) { + doc.raster_effects_ppi = ppi; + } + let s = &mut doc.raster_effects; + if let Some(on) = boolean(r, "mask") { + s.clipping_mask = on; + } + if let Some(on) = boolean(r, "alis") { + s.anti_alias = on; + } + if let Some(on) = boolean(r, "spot") { + s.preserve_spot_colors = on; + } + if let Some(pad) = typed(r, "padd", "Real").filter(|p| (0.0..=MAX_ADD_AROUND).contains(p)) { + s.add_around = pad; + } + let model = match typed(r, "colr", "Int") { + Some(0.0) => Some((RasterColorModel::Document, Background::White)), + Some(4.0) => Some((RasterColorModel::Document, Background::Transparent)), + Some(6.0) => Some((RasterColorModel::Grayscale, Background::Transparent)), + _ => None, + }; + if let Some((model, background)) = model { + (s.color_model, s.background) = (model, background); + } +} + /// The name of the empty group that stands for a text object, followed by the number of its /// story in the text document when the file says. pub(crate) const TEXT_SLOT: &str = "\u{0}text"; @@ -125,7 +211,7 @@ fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { r.document(o); } } - r.artboards + r.artboards.into_iter().map(|b| (b.name, b.rect)).collect() } /// How many variables and data sets the variable library of the editing data has: XML nodes @@ -176,6 +262,34 @@ pub fn variables(data: &[u8]) -> VariableLibrary { }) } +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]) { + 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() { + break; + } + end += line.len(); + if line.trim_ascii_end() == b"%%EndComments" { + break; + } + } + 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); + } + } + } +} + /// 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]> { @@ -302,11 +416,15 @@ impl<'a> Reader<'a> { } } - /// The `/Document` dictionary: the artboards, how many variables and data sets its variable - /// library has, and its Objects on Path records. + /// The `/Document` dictionary: the artboards and their display options, Document Raster Effects + /// Settings, how many variables and data sets its variable library has, and its Objects on + /// Path records. pub(super) fn document(&mut self, o: &Obj) { variable_library(o, 0, &mut self.variables); self.constraint_data(o); + if let Some(r) = find_entry(o, "AI Auto Rasterize", 0) { + raster_settings(r, &mut self.doc); + } 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")); @@ -316,7 +434,9 @@ impl<'a> Reader<'a> { continue; } let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); - self.artboards.push((name, r)); + let mut board = AiArtboard::new(name, r); + display_options(ab, &mut board); + self.artboards.push(board); } } @@ -337,7 +457,7 @@ impl<'a> Reader<'a> { /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. fn first_board(&self) -> Option { - self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) + self.artboards.first().map(|b| b.rect).or(self.cropmarks).or_else(|| self.sized_board()) } /// The page the art is compared with in the document's space, once the art has started. @@ -430,24 +550,24 @@ impl<'a> Reader<'a> { drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); - let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { - artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() + let boards: Vec = if self.artboards.is_empty() { + artboard.or_else(|| self.bbox_board()).map(|r| vec![AiArtboard::new("Artboard 1".to_string(), r)]).unwrap_or_default() } else { - self.artboards.clone() + std::mem::take(&mut self.artboards) }; - let board_rects: Vec = boards.iter().map(|(_, r)| *r).collect(); + let board_rects: Vec = boards.iter().map(|b| b.rect).collect(); doc.artboards = boards .into_iter() .enumerate() - .map(|(i, (name, r))| Artboard { + .map(|(i, b)| Artboard { id: i as u32 + 1, - name, - rect: to_doc.transform_rect_bbox(r), + name: b.name, + rect: to_doc.transform_rect_bbox(b.rect), locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, + show_center_mark: b.center_mark, + show_cross_hairs: b.cross_hairs, + show_video_safe_areas: b.safe_areas, + video_ruler_pixel_aspect_ratio: b.par, color: None, }) .collect(); @@ -490,15 +610,35 @@ impl<'a> Reader<'a> { doc.units = u; } // The file's own swatches in place of the defaults, in its order and colour groups. - if let Some((swatches, groups)) = self.palette_swatches(&doc.patterns) { - (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); - } - // The colour operators' definitions first: a gradient, defined before the art, names a - // global process colour as a stop and would otherwise make it an ink (#1771). - for n in self.spots.iter().chain(&self.stop_spots) { - if doc.swatch(&n.name).is_none() { - doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); + let own_palette = match self.palette_swatches(&doc.patterns) { + Some((swatches, groups)) => { + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + true } + None => false, + }; + // The colour operators' definitions first: a gradient, defined before the art, names a + // global process colour as a stop and would otherwise make it an ink (#1771). The header's + // custom colors follow. The first definition of a name makes its swatch, and takes the place + // of a default swatch of that name when the file has no palette of its own (a reserved name + // in brackets, such as `[None]`, keeps its swatch). + let mut defined = std::collections::HashSet::new(); + let colors = self.spots.iter().chain(&self.stop_spots).map(|n| (n, n.spot)).chain(self.custom_colors.iter().map(|n| (n, true))); + for (n, spot) in colors { + let first = defined.insert(n.name.clone()); + let swatch = Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot }; + if doc.swatch(&n.name).is_none() { + doc.swatches.push(swatch); + } else if first + && !own_palette + && !(n.name.starts_with('[') && n.name.ends_with(']')) + && let Some(default) = doc.swatch_mut(&n.name) + { + *default = swatch; + } + } + if let Some(author) = self.author.take() { + doc.metadata.author = author; } for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { if doc.swatch(&name).is_none() { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 5139ea4eb..e645841cd 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -957,6 +957,155 @@ fn artboards() { assert_eq!(doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0)); } +/// The document data of artboards side by side, 100 points wide, each with the entries `extra`. +fn boards_with(extra: &[&str]) -> String { + let boards: String = extra + .iter() + .enumerate() + .map(|(i, e)| { + let x = i * 100; + format!( + "%_/Dictionary :\n{e}%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + x + 100 + ) + }) + .collect(); + format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{boards}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n") +} + +#[test] +fn artboards_keep_their_display_options() { + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let options = |extra: &[&str]| { + read_ok(&format!("{}{square}", boards_with(extra))) + .doc + .artboards + .iter() + .map(|a| (a.show_center_mark, a.show_cross_hairs, a.show_video_safe_areas, a.video_ruler_pixel_aspect_ratio)) + .collect::>() + }; + assert_eq!( + options(&["%_1 /Int (DisplayMark) ,\n%_1.33 /Real (PAR) ,\n", "%_6 /Int (DisplayMark) ,\n%_2 /Real (PAR) ,\n"]), + [(true, false, false, 1.33), (false, true, true, 2.0)], + "each artboard has its own" + ); + assert_eq!(options(&["%_7 /Int (DisplayMark) ,\n%_1 /Real (PAR) ,\n"]), [(true, true, true, 1.0)]); + // Values of another type or out of range leave the defaults. + for bad in [ + "%_1 /Real (DisplayMark) ,\n%_1 /Int (PAR) ,\n", + "%_-1 /Int (DisplayMark) ,\n%_20 /Real (PAR) ,\n", + "%_1.5 /Int (DisplayMark) ,\n%_0.05 /Real (PAR) ,\n", + "%_1 2 /Int (DisplayMark) ,\n%_(2) /Real (PAR) ,\n", + ] { + assert_eq!(options(&[bad]), [(false, false, false, 1.0)], "{bad}"); + } +} + +/// The document data with the Document Raster Effects Settings `entries`. +fn raster_settings(entries: &str) -> String { + format!("%_/Document :\n%_/Dictionary :\n%_/Dictionary :\n{entries}%_; (AI Auto Rasterize) ,\n%_; /NotRecorded ,\n%_;\n") +} + +#[test] +fn document_raster_effects_settings_come_from_the_document() { + use vectorcraft_doc::{Background, RasterColorModel, RasterEffectsSettings}; + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let settings = |entries: &str| { + let doc = read_ok(&format!("{}{square}", raster_settings(entries))).doc; + (doc.raster_effects_ppi, doc.raster_effects) + }; + let all = "%_6 /Int (colr) ,\n%_0 /Bool (spot) ,\n%_150 /Int (dpi.) ,\n%_1 /Bool (mask) ,\n%_0 /Bool (alis) ,\n%_7 /Real (padd) ,\n"; + assert_eq!( + settings(all), + ( + 150.0, + RasterEffectsSettings { + color_model: RasterColorModel::Grayscale, + background: Background::Transparent, + anti_alias: false, + clipping_mask: true, + add_around: 7.0, + preserve_spot_colors: false, + } + ) + ); + let (_, white) = settings("%_0 /Int (colr) ,\n"); + assert_eq!((white.color_model, white.background), (RasterColorModel::Document, Background::White)); + let (_, document) = settings("%_4 /Int (colr) ,\n"); + assert_eq!((document.color_model, document.background), (RasterColorModel::Document, Background::Transparent)); + // Values of another type, out of range or unknown leave the defaults. + let defaults = (72.0, RasterEffectsSettings::default()); + for bad in [ + "%_150 /Real (dpi.) ,\n%_1 /Int (mask) ,\n%_7 /Int (padd) ,\n%_6 /Real (colr) ,\n", + "%_5000 /Int (dpi.) ,\n%_2 /Bool (alis) ,\n%_-1 /Real (padd) ,\n%_3 /Int (colr) ,\n", + "%_0 /Int (dpi.) ,\n%_(0) /Bool (spot) ,\n%_2000 /Real (padd) ,\n%_1 /Int (colr) ,\n", + "%_150.5 /Int (dpi.) ,\n%_0.5 /Bool (mask) ,\n%_4.5 /Int (colr) ,\n", + ] { + assert_eq!(settings(bad), defaults, "{bad}"); + } +} + +/// Editing data whose header has the comments `header` before `%%EndComments`. +fn stream_with_header(header: &str, body: &str) -> String { + stream(body).replacen("%%EndComments", &format!("{header}%%EndComments"), 1) +} + +#[test] +fn the_header_gives_the_author_and_custom_colors() { + let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let header = "%%For: (Ann \\(QA\\) Lee) (Studio)\n%%DocumentCustomColors: (Ink A)\n%%+ (Ink B)\n%%+ (Ink C)\n\ + %%CMYKCustomColor: 0.1 0.2 0.3 0.4 (Ink A)\n%%+ 0 1 0 0 (Ink B)\n%%RGBCustomColor: 1 0.5 0 (Ink C)\n\ + %%+ 2 0 0 (Too bright)\n%%CMYKCustomColor: 0.1 0.2 0.3 (Too few)\n%%+ 0 0 0 1 (Unended\n"; + let after = "%%CMYKCustomColor: 0 0 0 1 (After the header)\n%%For: (Someone else) ()\n"; + let r = read(stream_with_header(header, &format!("{after}{square}")).as_bytes()).unwrap(); + assert_eq!(r.doc.metadata.author, "Ann (QA) Lee"); + let ink = |name: &str| r.doc.swatches.iter().find(|s| s.name == name).map(|s| (s.paint.color(), s.global, s.spot)); + assert_eq!(ink("Ink A"), Some((Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)), true, true))); + assert_eq!(ink("Ink B"), Some((Some(Color::cmyk(0.0, 1.0, 0.0, 0.0)), true, true))); + assert_eq!(ink("Ink C"), Some((Some(Color::rgb(1.0, 0.5, 0.0)), true, true))); + for name in ["Too bright", "Too few", "Unended", "After the header"] { + assert_eq!(ink(name), None, "{name}"); + } + // A color the art defines keeps its definition. + let used = layer(&format!("0.5 0 0 0 (Ink A) 0 x\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read(stream_with_header(header, &used).as_bytes()).unwrap(); + let a: Vec<_> = r.doc.swatches.iter().filter(|s| s.name == "Ink A").map(|s| s.paint.color()).collect(); + assert_eq!(a, [Some(Color::cmyk(0.5, 0.0, 0.0, 0.0))]); + // A color with the name of a default swatch takes its place in a file without a palette, + // whether the header or the art defines it. + let red = "%%CMYKCustomColor: 0 0.5 0.5 0 (Red)\n%%+ 0 0 0 1 ([None])\n"; + let r = read(stream_with_header(red, &square).as_bytes()).unwrap(); + let reds: Vec<_> = r.doc.swatches.iter().filter(|s| s.name == "Red").map(|s| (s.paint.color(), s.spot)).collect(); + assert_eq!(reds, [(Some(Color::cmyk(0.0, 0.5, 0.5, 0.0)), true)]); + assert!(matches!(r.doc.swatch("[None]").map(|s| &s.paint), Some(Paint::None)), "a reserved name keeps its swatch"); + let art_red = layer(&format!("0 0.5 0.5 0 (Red) 0 x\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))); + let r = read(stream(&art_red).as_bytes()).unwrap(); + let reds: Vec<_> = r.doc.swatches.iter().filter(|s| s.name == "Red").map(|s| (s.paint.color(), s.spot)).collect(); + assert_eq!(reds, [(Some(Color::cmyk(0.0, 0.5, 0.5, 0.0)), true)]); + // Without the comments: no author, the default swatches. + let plain = read_ok(&square).doc; + assert_eq!(plain.metadata.author, ""); + assert!(plain.swatches.iter().all(|s| !s.name.starts_with("Ink"))); +} + +#[test] +fn an_older_files_template_box_places_its_artboard() { + let old = + format!("%!PS-Adobe-3.0\n%AI5_ArtSize: 600 400\n%%TemplateBox: 300.5 199.5 300.5 199.5\n%%EndComments\n{}", layer("10 10 m\n20 20 L\nS\n")); + let r = read(old.as_bytes()).unwrap(); + assert_eq!(r.doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0)); + assert_eq!(r.template, Some([300.5, 199.5, 300.5, 199.5])); +} + +#[test] +fn art_after_the_printers_crop_marks_reads() { + // GUARDS: the crop marks between `%AI3_BeginCrops` and `%AI3_EndCrops` are skipped, and the art + // after them is read. + let crops = format!("%AI3_BeginCrops\n{}S\n%AI3_EndCrops\n", rect_path(-20.0, -20.0, -10.0, -10.0)); + let doc = read_ok(&format!("{crops}{}", layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))))).doc; + assert_eq!(art(&doc).len(), 1); +} + /// A variable library in the document data isn't read: a note gives how many variables and data /// sets it has, counting the elements of the library alone (a data set's element for a variable named /// `variable` isn't a variable). diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 0c26081bf..02f739eb6 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -13,8 +13,11 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im let Some(data) = editing_copy(ps) else { return visible }; // The layer tree is kept from the structure read, for the page to come in on when the layers // can't be used. + // Private data kept apart from the program: the program's own header gives the author and + // custom colors the private data's header may lack. + let read = |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps) } else { ai::read(d, true) }; let (tree, result) = match data.as_deref() { - Ok(d) => match ai::read(d, true) { + Ok(d) => match read(d) { Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), Err(why) => (vec![], Err(why)), }, diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 23ef7d822..7b9446bef 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -87,6 +87,27 @@ fn native_import_keeps_ruler_units_from_editing_data() { assert_eq!(r.document.units, Unit::Millimeters); } +#[test] +fn an_eps_takes_the_author_and_custom_colors_of_its_own_header() { + let art = layer("Art", true, &square(10, 10)); + let outer = |data: Vec| { + let at = data.windows(13).position(|w| w == b"%%EndComments").unwrap(); + let header = b"%%For: (Outer Author) (Org)\n%%CMYKCustomColor: 0 0 1 0 (Outer Ink)\n%%+ 0 1 0 0 (Shared Ink)\n%%+ 0 0.5 0.5 0 (Red)\n"; + [&data[..at], &header[..], &data[at..]].concat() + }; + let r = import(&outer(eps(&page_square(10, 10), &native(&art)))).unwrap(); + assert_eq!(names(&r.document), ["Art"]); + assert_eq!(r.document.metadata.author, "Outer Author"); + let ink = |d: &Document, name: &str| d.swatch(name).map(|s| (s.paint.color(), s.spot)); + assert_eq!(ink(&r.document, "Outer Ink"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)), true))); + assert_eq!(ink(&r.document, "Red"), Some((Some(vectorcraft_color::Color::cmyk(0.0, 0.5, 0.5, 0.0)), true)), "in place of the default"); + // The private data's own header comes first. + let own = native(&art).replacen("%%BoundingBox", "%%For: (Inner Author) ()\n%%RGBCustomColor: 1 0 0 (Shared Ink)\n%%BoundingBox", 1); + let r = import(&outer(eps(&page_square(10, 10), &own))).unwrap(); + assert_eq!(r.document.metadata.author, "Inner Author"); + assert_eq!(ink(&r.document, "Shared Ink"), Some((Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), true))); +} + fn kinds(l: &Node) -> Vec<&'static str> { l.children() .into_iter() diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 342d2f1fc..32d87a1ac 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -99,6 +99,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), 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), 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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); the page's drawing of it (often in pieces) is left out. 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. 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 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). 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), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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); 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. 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 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). 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), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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. 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. 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 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). 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), 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), guides, and every artboard where it is. 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 an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). 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, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's 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 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); the page's drawing of it (often in pieces) is left out. 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 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). 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 24a3dfede..f516e2ecb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1075,6 +1075,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`). PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9). A `.ai` or PDF whose editing data holds a variable library opens with a warning that its variables and data sets weren't read, with how many of each it has, whether its layers are read or it comes in as its PDF part (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). + A `.ai` or EPS opened from its editing data keeps each artboard's center mark, cross hairs, video safe areas and video ruler pixel aspect ratio, and the file's Document Raster Effects Settings; the author comes from the header's `%%For` (the editing data's, else an EPS's own) when the file's File Info gives none, the header's custom colors that no swatch has come in as spot swatches, a named color the file defines takes the place of a default swatch of that name when the file has no palette, and an older file's `%%TemplateBox` places its artboard. A value of another type or out of range keeps its default (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). 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`).