diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index bd9069bd2..d3f0c8cc8 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3601,6 +3601,40 @@ proptest! { } } +/// A pattern's definition in the version 7 form (§3.4): a layer whose `_ &` fills the tile's box, +/// and one whose tile string has CR-separated lines, comments and escapes. +const LEGACY_PATTERN: &str = "%AI3_BeginPattern: (P)\n(P) 0 0 10 10 [\n%AI3_Tile\n(0 O 0 R 0 0 0 0.5 k) @ _ &\n\ + (0 O 1 R 0 0 0 1 \\(Ink\\) 0 X) @ (\r%AI6_BeginPatternLayer\r1 w u\r0 5 m\\r10 5 L\rS\rU\r%AI6_EndPatternLayer\r) &\n] E\n%AI3_EndPattern\n"; + +/// [`NAMED_PROLOG_AI`] with `pattern` in its setup, filling the square. +fn legacy_pattern_ai(pattern: &str) -> String { + NAMED_PROLOG_AI + .replace("%AI5_Begin_NonPrinting", &format!("{pattern}%AI5_Begin_NonPrinting")) + .replace("\n10 10 m\n", "\n(P) 5 5 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n10 10 m\n") + .replace("10 10 L\nS\n", "10 10 L\nB\n") +} + +/// The fixture, as it is, opens with the pattern filling the square. +#[test] +fn the_legacy_pattern_fixture_opens_with_its_pattern() { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", legacy_pattern_ai(LEGACY_PATTERN).as_bytes()).unwrap(); + assert_eq!(l.doc.pattern("P").map(|p| p.art.len()), Some(2), "{:?}", l.warnings); + let square = &l.doc.layers[0].children().unwrap()[0]; + assert!(matches!(square.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Pattern { .. }))); +} + +proptest! { + #![proptest_config(config())] + + /// That `.ai` with its pattern's definition damaged (its strings, `@`, `&`, `_`, the list's + /// brackets and its `E`): read, without the pattern where it can't be read whole, or refused. + #[test] + fn ps_form_ai_legacy_pattern_never_panics(cut in 0usize..500, edits in prop::collection::vec((0usize..500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '\r', '(', ')', '\\', '[', ']', '%', '@', '&', '_', 'E', 'u', 'U', 'q', 'Q', 'm', 'f', 'S', 'k', 'X'])), 0..12)) { + let text = legacy_pattern_ai(&mutate_text(LEGACY_PATTERN, cut, &edits)); + survive("PostScript-form .ai with a damaged version 7 pattern", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + /// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or /// anything else. fn arb_layer_operand() -> impl Strategy { diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 3c0d4cf43..17590b320 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -127,6 +127,9 @@ impl Reader<'_> { /// `type To`: a text object begins. pub(super) fn begin_text(&mut self, vals: &[V], hidden: bool) { self.end_path_quietly(); + if self.pattern.is_some() { + self.unreadable("type in a pattern", false); + } let kind = nums::<1>(vals).map_or(0, |[k]| k as i64); self.text = Some(LegacyText { kind, gs: self.gs.clone(), hidden, ..LegacyText::default() }); } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 2dfee6f2c..93cc0af6b 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -52,9 +52,13 @@ //! lines and `1 (style) XW`: the drawn look is kept, named after the object. //! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's //! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is -//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one: +//! read by a reader of its own and becomes a pattern swatch. In the version 7 form (§3.4) a list +//! of layers follows the tile, `[ (colors) @ (tile) & … ] E`, each layer's tile read from its +//! string with the colors' state (`_ &`: the tile's box filled). A fill or stroke with one: //! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix -//! taking the pattern's space to art space (the other operands are what it is made of). +//! taking the pattern's space to art space (the other operands are what it is made of). With a +//! pattern defined in the version 7 form, the other operands are applied in order (§5.4.1) before +//! the initial matrix. //! //! - Type in the legacy format (version 7 specification §15): `type To` … `TO`, read by //! [`legacy_text`]. @@ -327,6 +331,9 @@ struct Reader<'a> { pattern: Option, /// The tile (y down) of each pattern read, by name. pattern_tiles: HashMap, + /// The patterns read from a definition in the version 7 form, whose fills apply their other + /// operands before the matrix (see [`Reader::pattern_paint`]). + v7_patterns: HashSet, /// The legacy text object being read (`To` … `TO`), and the modal type attributes. text: Option, type_state: legacy_text::TypeState, @@ -431,6 +438,7 @@ impl<'a> Reader<'a> { ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), + v7_patterns: HashSet::new(), text: None, type_state: legacy_text::TypeState::default(), graph: None, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index be4f6214c..ed838b51b 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1,6 +1,7 @@ use super::art::{four, sane, two}; use super::types::{ - FLIP, Kind, MAX_HEX, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED, + 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, Node, PatternDef, Reader, Rect, Tok, V, marker}; @@ -82,10 +83,14 @@ impl<'a> Reader<'a> { return Ok(()); } let m = marker(line); - // A pattern's definition (one inside another's isn't read). - if m == "BeginPattern" && self.pattern.is_none() { + // A pattern's definition. One inside another's isn't read, and the outer tile is then incomplete. + if m == "BeginPattern" { let section = self.section("EndPattern")?; - self.define_pattern(section); + if self.pattern.is_some() { + self.unsupported.insert("patterns inside patterns".into()); + } else { + self.define_pattern(section); + } return Ok(()); } if m == "BeginSymbol" { @@ -322,6 +327,11 @@ impl<'a> Reader<'a> { if self.pattern_tiles.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS { return; } + match legacy_pattern(section) { + Legacy::No => {} + Legacy::Malformed => return, + Legacy::Yes(def) => return self.define_legacy_pattern(&def), + } let mut sub = Reader::new(section); sub.subreader_depth = self.subreader_depth + 1; sub.pattern = Some(PatternHead::default()); @@ -339,6 +349,98 @@ impl<'a> Reader<'a> { && sub.unread.is_empty() && sub.hidden_unread.is_empty() && sub.frames.len() == 1; + self.keep_pattern(sub, whole, false); + } + + /// A definition in the version 7 form ([`legacy_pattern`]), read layer by layer into one group: + /// each layer's colors (`@`) set the state its tile string (`&`) is read with, or the fill of the + /// tile's box (`_ &`). Each layer starts from the state the definition starts with. + fn define_legacy_pattern(&mut self, def: &LegacyPattern) { + let mut sub = Reader::new(b""); + 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.nodes = self.nodes; + sub.template = self.template; + let mut read = sub.open(Kind::Group, false); + for layer in &def.layers { + if read.is_err() { + break; + } + read = sub.pattern_layer(layer, def.tile); + } + self.nodes = sub.nodes; + self.gradients = std::mem::take(&mut sub.gradients); + let whole = read.is_ok() + && sub.unsupported.is_empty() + && sub.unknown.is_empty() + && sub.unread.is_empty() + && sub.hidden_unread.is_empty() + && sub.frames.len() == 1; + self.keep_pattern(sub, whole, true); + } + + /// One layer of a definition in the version 7 form, read into the definition's group. + fn pattern_layer(&mut self, layer: &'a LegacyLayer, tile: Rect) -> Result<(), String> { + self.gs = GState::default(); + self.pending_fill = None; + self.pending_stroke = None; + self.pending_unread_fill = false; + self.pending_unread_stroke = false; + self.instance = None; + self.end_path_quietly(); + // The color part has the overprint and the colors only. + let mut lex = super::Lexer::new(&layer.color); + while let Some(t) = lex.next_token() { + match t.tok { + Tok::Num(_) | Tok::Str(_) | Tok::Hex(_) => {} + Tok::Word(w) if LAYER_COLOR_OPS.contains(&w) => {} + _ => return Err("a pattern layer's colors hold something else".into()), + } + } + self.run_string(&layer.color)?; + match &layer.tile { + Some(art) => self.run_string(art)?, + None => { + let corner = |x: f64, y: f64| [V::Num(x), V::Num(y)]; + self.path_op("m", &corner(tile.x0, tile.y0))?; + for (x, y) in [(tile.x0, tile.y1), (tile.x1, tile.y1), (tile.x1, tile.y0)] { + self.path_op("L", &corner(x, y))?; + } + self.paint("f", false, false)?; + } + } + // A path the layer leaves unpainted paints nothing. + self.end_path_quietly(); + Ok(()) + } + + /// Run this reader on one of a pattern layer's strings, which must end everything it starts. + fn run_string(&mut self, bytes: &'a [u8]) -> Result<(), String> { + let depth = self.frames.len(); + self.lex = super::Lexer::new(bytes); + let read = self.run(); + let open = self.done + || self.frames.len() != depth + || self.stack.iter().any(|v| matches!(v, V::Mark)) + || !self.marks.is_empty() + || self.text.is_some() + || self.raster.is_some() + || self.place.is_some() + || self.mesh.is_some() + || self.gradient_def.is_some(); + self.stack.clear(); + self.marks.clear(); + read?; + if open { + return Err("a pattern layer doesn't end what it starts".into()); + } + Ok(()) + } + + /// Keep the pattern `sub` read, when it read it `whole`, as a pattern swatch; `v7` when it was + /// defined in the version 7 form. + fn keep_pattern(&mut self, mut sub: Reader<'_>, whole: bool, v7: bool) { let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { return; @@ -349,6 +451,11 @@ impl<'a> Reader<'a> { if self.pattern_tiles.insert(def.name.clone(), def.tile).is_some() { self.doc.patterns.retain(|p| p.name != def.name); } + if v7 { + self.v7_patterns.insert(def.name.clone()); + } else { + self.v7_patterns.remove(&def.name); + } self.doc.patterns.push(def); for (key, blob) in sub.doc.images { self.doc.images.entry(key).or_insert(blob); @@ -404,6 +511,117 @@ impl<'a> Reader<'a> { } } +/// The operators a pattern layer's colors (`@`) may hold: overprint, gray, process and custom colors. +const LAYER_COLOR_OPS: &[&[u8]] = &[b"O", b"R", b"g", b"G", b"k", b"K", b"x", b"X"]; + +/// The longest string a pattern layer may have (the format's limit, 64K bytes). +const MAX_LAYER_STRING: usize = 65_535; + +/// Most layers in one pattern's definition. +const MAX_LAYERS: usize = 65_536; + +/// A pattern's definition in the version 7 form: its head, then a list of layers painted in order, +/// the first the lowest. +struct LegacyPattern { + name: String, + /// The tile in the pattern's space (y up). + tile: Rect, + layers: Vec, +} + +/// `(colors) @ (tile) &`, or `(colors) @ _ &` (no tile: the tile's box is filled with the colors' fill). +struct LegacyLayer { + color: Vec, + tile: Option>, +} + +enum Legacy { + /// Another form: the definition's art follows its head as operators. + No, + /// The version 7 form, which can't be read. + Malformed, + Yes(LegacyPattern), +} + +/// The next token that isn't a comment; `Err` at a pattern's definition inside this one. +fn next_operand<'s>(lex: &mut super::Lexer<'s>) -> Result>, ()> { + while let Some(t) = lex.next_token() { + match t.tok { + Tok::Comment(c) if marker(&String::from_utf8_lossy(c)) == "BeginPattern" => return Err(()), + Tok::Comment(_) => {} + _ => return Ok(Some(t)), + } + } + Ok(None) +} + +/// A pattern's definition in the version 7 form: `(name) llx lly urx ury [`, then for each layer +/// `(colors) @` and `(tile) &` or `_ &`, then `] E` (version 7 specification §3.4). It is that form +/// when a `[` that isn't on a `%_` line follows the head. +fn legacy_pattern(section: &[u8]) -> Legacy { + let mut lex = super::Lexer::new(section); + let mut next = || next_operand(&mut lex).ok().flatten().map(|t| (t.tok, t.hidden)); + let name = match next() { + Some((Tok::Str(s), _)) => super::obj::text(&s), + _ => return Legacy::No, + }; + let mut tile = [0.0; 4]; + for v in &mut tile { + match next() { + Some((Tok::Num(n), _)) => *v = n, + _ => return Legacy::No, + } + } + if !matches!(next(), Some((Tok::Word(b"["), false))) { + return Legacy::No; + } + let [x0, y0, x1, y1] = tile; + let mut layers = vec![]; + let string = |t: Option<(Tok, bool)>| match t { + Some((Tok::Str(s) | Tok::Hex(s), _)) if s.len() <= MAX_LAYER_STRING => Some(s), + _ => None, + }; + let word = |t: Option<(Tok, bool)>, w: &[u8]| matches!(t, Some((Tok::Word(x), _)) if x == w); + loop { + // Before the first layer, anything but a string or `] E` is the newer form's art (`[]0 d`). + let first = layers.is_empty(); + let color = match next() { + Some((Tok::Word(b"]"), _)) if !first => break, + Some((Tok::Word(b"]"), _)) => { + return match next() { + Some((Tok::Word(b"E"), _)) => Legacy::Yes(LegacyPattern { name, tile: Rect::new(x0, y0, x1, y1), layers }), + _ => Legacy::No, + }; + } + t @ Some((Tok::Str(_) | Tok::Hex(_), _)) => match string(t) { + Some(s) => s, + None => return Legacy::Malformed, + }, + _ if first => return Legacy::No, + _ => return Legacy::Malformed, + }; + if !word(next(), b"@") { + return Legacy::Malformed; + } + let tile = match next() { + Some((Tok::Word(b"_"), _)) => None, + t => match string(t) { + Some(s) => Some(s), + None => return Legacy::Malformed, + }, + }; + if !word(next(), b"&") || layers.len() >= MAX_LAYERS { + return Legacy::Malformed; + } + layers.push(LegacyLayer { color, tile }); + } + // `] E` ends the definition: nothing but comments may follow it. + if !word(next(), b"E") || !matches!(next_operand(&mut lex), Ok(None)) { + return Legacy::Malformed; + } + Legacy::Yes(LegacyPattern { name, tile: Rect::new(x0, y0, x1, y1), layers }) +} + /// How many custom colors the header may define. pub(super) const MAX_CUSTOM_COLORS: usize = 1000; diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 88f132568..f5ce51384 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -402,7 +402,8 @@ impl<'a> Reader<'a> { /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't - /// read can't be drawn. + /// read can't be drawn. For a pattern defined in the version 7 form, the other operands are + /// applied in order before the initial matrix ([`pattern_controls`]). fn pattern_paint(&mut self, w: &str, vals: &[V]) { // In the setup it paints nothing (and the art's space isn't fixed yet). if self.frames.is_empty() { @@ -413,7 +414,16 @@ impl<'a> Reader<'a> { self.unread_pattern(w); return; }; - let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + let mut matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + if name.as_ref().is_some_and(|n| self.v7_patterns.contains(n)) { + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); + let Some(controls) = pattern_controls(&controls) else { + self.unread_pattern(w); + return; + }; + matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * controls); + } let Some(xf) = pattern_xf(self.space(), matrix, tile) else { self.unread_pattern(w); return; @@ -596,11 +606,14 @@ impl<'a> Reader<'a> { return; } // A style read here: the object, painted as it says. - if let Some(mut appearance) = self - .styles - .get(&style) - .and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space })) - { + if let Some(mut appearance) = self.styles.get(&style).and_then(|st| { + styled( + object, + look, + st, + &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, 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). let k = space.determinant().abs().sqrt(); if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 { @@ -694,10 +707,33 @@ fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option= 1e-12 && 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, and art space → the document. +/// A pattern fill's `x y scaleX scaleY angle reflect axis shear shearAxis` as one transform of the +/// pattern's space, applied before its initial matrix: the shear (by `shear` degrees along the line +/// at `shearAxis` degrees), the reflection about the line at `axis` degrees when `reflect` isn't +/// 0, the counterclockwise rotation by `angle` degrees, the scale, then the move to `x y`. `None` +/// unless there are nine finite numbers. +fn pattern_controls(n: &[f64]) -> Option { + let &[x, y, sx, sy, angle, reflect, axis, shear, shear_axis] = n else { return None }; + if !n.iter().all(|v| v.is_finite()) { + return None; + } + let turn = |deg: f64| Affine::rotate(deg.to_radians()); + let reflection = if reflect != 0.0 { turn(axis) * Affine::scale_non_uniform(1.0, -1.0) * turn(-axis) } else { Affine::IDENTITY }; + let shearing = if shear != 0.0 { + turn(shear_axis) * Affine::new([1.0, 0.0, shear.to_radians().tan(), 1.0, 0.0, 0.0]) * turn(-shear_axis) + } else { + Affine::IDENTITY + }; + let xf = Affine::translate((x, y)) * Affine::scale_non_uniform(sx, sy) * turn(angle) * reflection * shearing; + 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. struct StylePaints<'a> { gradients: &'a HashMap, patterns: &'a HashMap, + v7_patterns: &'a HashSet, space: Affine, } @@ -778,11 +814,15 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p }; AppearanceItem::Fill(FillLayer::new(paint)) } - StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) { + StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{name}")) { Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), None => { let tile = paints.patterns.get(name).copied()?; - let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?; + let mut matrix = matrix.and_then(|m| paint::affine(&m)); + if paints.v7_patterns.contains(name) { + 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 })) } }, diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 043714518..db143d9f6 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -133,10 +133,12 @@ pub(super) enum StyleItem { name: Option, angle: f64, }, - /// A fill with pattern swatch `name`, its space taken to art space by `matrix`. + /// A fill with pattern swatch `name`, its space taken to art space by `matrix`; `controls`, the + /// numbers before the matrix, apply first when the pattern was defined in the version 7 form. Pattern { name: String, matrix: Option<[f64; 6]>, + controls: Vec, }, Stroke { paint: Paint, @@ -199,8 +201,8 @@ struct Filter { gradient: bool, /// The gradient's swatch and angle (`Bg`). gradient_of: Option<(Option, f64)>, - /// `(name) … [matrix] p`. - pattern: Option<(String, Option<[f64; 6]>)>, + /// `(name) … [matrix] p`, and the numbers between the name and the matrix. + pattern: Option<(String, Option<[f64; 6]>, Vec)>, /// `mode opacity … Xy`. xy: Option<(usize, f32)>, /// `… isolate knockout … Xy`. @@ -518,8 +520,10 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { } _ => None, }); + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); if let Some(name) = name { - f.pattern = Some((name, matrix)); + f.pattern = Some((name, matrix, controls)); } } _ => {} @@ -998,7 +1002,7 @@ fn paint_item(f: &Filter) -> Result, ()> { match f.name.as_str() { FILL if !f.paints.1 => Ok(None), FILL => match (&f.pattern, &f.gradient_of, &f.fill) { - (Some((name, matrix)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix })), + (Some((name, matrix, controls)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix, controls: controls.clone() })), (None, Some((name, angle)), _) => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })), (None, None, _) if f.gradient => Ok(Some(StyleItem::Gradient { name: None, angle: 0.0 })), (None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))), diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 13316561d..9ca955eb9 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use kurbo::{Point, Rect}; +use kurbo::{Affine, Point, Rect}; use vectorcraft_color::gradient::GradientKind; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; @@ -525,7 +525,7 @@ fn a_gradient_in_a_patterns_definition_is_placed_from_the_canvas() { /// has that isn't read, where it shows. Its definition costs nothing else. #[test] fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() { - let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\ + let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L frobnicate S\n) &\n] E\n%AI3_EndPattern\n\ %AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n"; let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. }))); @@ -565,6 +565,231 @@ fn a_pattern_painting_with_an_unread_pattern_is_not_kept() { } } +/// A pattern's definition inside another's isn't read, so the outer one isn't kept either. +#[test] +fn a_pattern_with_a_definition_inside_it_isnt_kept() { + let tile = rect_path(0.0, 0.0, 8.0, 8.0).replace('\n', "\n%_"); + let inner = "%AI3_BeginPattern: (In)\n(In) 0 0 1 1\n%_0 0 m\n%_1 1 L\n%_S\nE\n%AI3_EndPattern\n"; + let def = format!("%AI3_BeginPattern: (Out)\n(Out) 0 0 8 8\n{inner}%_{tile}f\nE\n%AI3_EndPattern\n"); + let s = read_ok(&format!("{def}{}", layer(""))); + assert!(s.doc.patterns.is_empty(), "{:?}", s.doc.patterns.iter().map(|p| &p.name).collect::>()); + // Without the definition inside it, it is kept. + let s = read_ok(&format!("{}{}", def.replace(inner, ""), layer(""))); + assert!(s.doc.pattern("Out").is_some()); +} + +/// A definition whose art follows its head as operators is still read when that art starts with a +/// dash array (`[]0 d`) off `%_` lines, which isn't a version 7 layer list. +#[test] +fn a_pattern_whose_art_starts_with_a_dash_array_is_read() { + let def = format!("%AI3_BeginPattern: (Dash)\n(Dash) 0 0 8 8 []0 d\n{}f\nE\n%AI3_EndPattern\n", rect_path(0.0, 0.0, 8.0, 8.0)); + let s = read_ok(&format!("{def}{}", layer(""))); + assert_eq!(s.doc.pattern("Dash").map(|p| p.art.len()), Some(1)); +} + +/// Editing data in the version 7 form: `%AI5_FileFormat 2.0`, the art size around the template +/// box's center (an artboard 200 by 100 from the origin), `setup` (pattern definitions), then `body`. +fn pattern_stream7(setup: &str, body: &str) -> String { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 2.0\n%AI5_ArtSize: 200 100\n%AI3_TemplateBox: 100 50 100 50\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n{setup}%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n" + ) +} + +/// One layer named `name` with the version 7 form's ten operands before `Lb`, holding `art`. +fn pattern_layer7(name: &str, art: &str) -> String { + format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n({name}) Ln\n0 A\n{art}LB\n%AI5_EndLayer--\n") +} + +/// A definition of `name` in the version 7 form: its 20-point tile, then `layers`, the layer list. +fn pattern7(name: &str, layers: &str) -> String { + format!("%AI3_BeginPattern: ({name})\n({name}) 0 0 20 20 [\n%AI3_Tile\n{layers}] E\n%AI3_EndPattern\n") +} + +fn pattern_read7(setup: &str, body: &str) -> Structure { + match read(pattern_stream7(setup, body).as_bytes()) { + Ok(s) => s, + Err(e) => panic!("{e}"), + } +} + +/// A definition in the version 7 form (§3.4): each layer's colors (`@`) paint its tile's art (`&`), +/// the first layer the lowest. The operator lines in the strings end in CR, and each layer starts +/// from the definition's state, so the second layer's stroke doesn't take the first one's width. +#[test] +fn version_7_pattern_layers_paint_in_order_with_their_own_colors() { + let layers = "(0 O 0 R 1 0 0 0 k\r1 0 0 0 K) @\n(\r%AI6_BeginPatternLayer\r0 J 0 j 3 w 4 M []0 d\r2 2 m\r9 2 L\r9 9 L\r2 9 L\rf\r%AI6_EndPatternLayer\r) &\n\ + (0 O 1 R 0 0 0 1 k\r0 0 0 1 K) @\n(\r2 12 m\r18 12 L\rS\r) &\n"; + let fill_with = format!("(Grid) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 10.0, 90.0, 90.0)); + let s = pattern_read7(&pattern7("Grid", layers), &pattern_layer7("Art", &fill_with)); + assert!(s.unread.is_empty() && s.warnings.is_empty(), "{:?} {:?}", s.unread, s.warnings); + let def = s.doc.pattern("Grid").unwrap(); + assert_eq!(def.tile, Rect::new(0.0, -20.0, 20.0, 0.0), "the tile, y down"); + assert_eq!(def.art.len(), 2, "one path for each layer"); + let (square, line) = (&def.art[0], &def.art[1]); + assert_eq!(square.geometric_bounds(), Some(Rect::new(2.0, -9.0, 9.0, -2.0))); + assert_eq!(fill(square).color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0))); + assert!(square.appearance.stroke().is_none()); + assert!(line.appearance.fill().is_none()); + let stroke = line.appearance.stroke().unwrap(); + assert_eq!(stroke.paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + assert_eq!(stroke.width, 1.0, "the first layer's width stays with it"); + assert!(stroke.overprint); + assert!(matches!(fill(&art(&s.doc)[0]), Paint::Pattern { pattern, .. } if pattern == "Grid")); +} + +/// `_ &` fills the tile's box with the layer's fill color, without a stroke (§3.4). +#[test] +fn a_version_7_patterns_underbar_fills_the_tile_box() { + let layers = "(0 O 0 R 0 1 0 0 k 0 0 0 1 K) @ _ &\n(0 0 0 1 k 0 0 0 1 K) @ (2 2 m 18 18 L S) &\n"; + let s = pattern_read7(&pattern7("Back", layers), &pattern_layer7("Art", "")); + let def = s.doc.pattern("Back").unwrap(); + let back = &def.art[0]; + assert_eq!(back.geometric_bounds(), Some(def.tile)); + assert_eq!(fill(back).color(), Some(Color::cmyk(0.0, 1.0, 0.0, 0.0))); + assert!(back.appearance.stroke().is_none()); + assert!(def.art[1].appearance.stroke().is_some()); +} + +/// 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] +fn a_version_7_patterns_strings_are_read_with_their_escapes_undone() { + let layers = "(0 O 0 R 0 0 0 1 \\(Ink \\(A\\)\\) 0 x) @\n(2 2 m\\r9 2 L\\n9 9 L\\0112 9 \\\nL f) &\n"; + let s = pattern_read7(&pattern7("Ink", layers), &pattern_layer7("Art", "")); + let def = s.doc.pattern("Ink").unwrap(); + assert_eq!(def.art.len(), 1); + assert_eq!(def.art[0].geometric_bounds(), Some(Rect::new(2.0, -9.0, 9.0, -2.0))); + assert!(s.doc.swatch("Ink (A)").is_some(), "{:?}", s.doc.swatches.iter().map(|w| &w.name).collect::>()); +} + +/// A definition in the version 7 form that can't be read whole isn't kept, and a fill with it is +/// something the file has that isn't read: an operator it doesn't know, type, a definition inside +/// it, colors with other operators, a layer without its tile or its `&`, a string longer than the +/// format allows, an `E`, a group or an array a layer doesn't end, more than 65,536 layers, a list +/// without its `]` or its `E`, anything after its `E`. +#[test] +fn a_version_7_pattern_it_cant_read_whole_isnt_kept() { + let line = "(0 0 m 8 8 L S) &\n"; + let long = format!("(0 g) @ ({}0 0 m 8 8 L S) &\n", " ".repeat(65_536)); + let open = "%AI3_BeginPattern: (P)\n(P) 0 0 20 20 [\n(0 g) @ (0 0 m 8 8 L S) &\n"; + let defs = [ + pattern7("P", "(0 g) @ (0 0 m 8 8 L frobnicate S) &\n"), + pattern7("P", "(0 g) @ (0 To 1 0 0 1 2 2 0 Tp TP (a) Tx TO) &\n"), + pattern7("P", "(0 g) @ (\r%AI3_BeginPattern: (In)\r(In) 0 0 1 1\rE\r%AI3_EndPattern\r0 0 m 8 8 L S) &\n"), + pattern7("P", &format!("%AI3_BeginPattern: (In)\n(In) 0 0 1 1 [\n] E\n%AI3_EndPattern\n(0 g) @ {line}")), + pattern7("P", &format!("(0 g 0 0 m) @ {line}")), + pattern7("P", "(0 g) @\n"), + pattern7("P", "(0 g) @ _\n"), + pattern7("P", &long), + pattern7("P", "(0 g) @ (0 0 m 8 8 L S E) &\n"), + pattern7("P", "(0 g) @ (u 0 0 m 8 8 L S) &\n"), + pattern7("P", "(0 g) @ (0 0 m 8 8 L S [) &\n"), + pattern7("P", "(0 g) @ ([ 0 0 m 8 8 L S) &\n"), + pattern7("P", &"(0 g) @ _ &\n".repeat(65_537)), + format!("{open}E\n%AI3_EndPattern\n"), + format!("{open}]\n%AI3_EndPattern\n"), + format!("{open}] E\n0 0 m 8 8 L S\n%AI3_EndPattern\n"), + ]; + let fill_with = format!("(P) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)); + for (i, def) in defs.iter().enumerate() { + assert!(pattern_read7(def, &pattern_layer7("Art", "")).doc.patterns.is_empty(), "case {i}"); + assert_unread(&pattern_read7(def, &pattern_layer7("Art", &fill_with)), "pattern fills"); + } + // The same definition, whole, is kept. + let s = pattern_read7(&pattern7("P", &format!("(0 g) @ {line}")), &pattern_layer7("Art", &fill_with)); + assert!(s.doc.pattern("P").is_some() && s.unread.is_empty()); +} + +/// With a pattern defined in the version 7 form, a fill's other operands are applied in order +/// before its initial matrix (§5.4.1): the shear along its axis, the reflection about the line at +/// its angle, the rotation, the scale, then the offset. Identity ones move the tiles by the offset +/// alone. `P` places a stroke's tiles the same way. +#[test] +fn a_pattern_fill_applies_its_offset_scale_rotation_reflection_and_shear() { + let def = pattern7("Grid", "(0 g) @ _ &\n"); + // The operands, and the pattern's space → art space they make (a b c d e f). + let cases = [ + ("10 15 1 1 0 0 0 0 0 [1 0 0 1 0 0]", [1.0, 0.0, 0.0, 1.0, 10.0, 15.0]), + ("25 30 1.5 .75 30 1 45 10 0 [1 0 0 1 0 0]", [-0.75, 0.649519, 1.166793, 0.489528, 25.0, 30.0]), + ("-5 7 2 .5 -45 0 0 -15 45 [1 0 0 1 0 0]", [1.793151, -0.353553, 1.035276, 0.353553, -5.0, 7.0]), + ("3 -4 1.2 .8 20 1 30 12 25 [.8 .1 -.2 1.1 4 -6]", [-0.028557, 0.816531, 1.054026, 0.117492, 7.2, -10.1]), + ]; + // Art space → the document (the artboard is 100 high), and a tile's art (y down from its top + // left) → the pattern's space. + let space = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + let tile = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 20.0]); + for (ops, want) in cases { + for (op, paint) in [("p", "f"), ("P", "S")] { + let s = pattern_read7(&def, &pattern_layer7("Art", &format!("(Grid) {ops} {op}\n{}{paint}\n", rect_path(10.0, 10.0, 90.0, 90.0)))); + let node = &art(&s.doc)[0]; + let p = if op == "p" { fill(node) } else { node.appearance.stroke().unwrap().paint.clone() }; + let Paint::Pattern { xf, .. } = p else { panic!("{ops} {op}: {p:?}") }; + let got = (space.inverse() * xf * tile.inverse()).as_coeffs(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6), "{ops} {op}: {got:?}"); + } + } + // The tiles' corners: with identity operands but the offset, the tile's top left (0, 20) is at + // (10, 35) in art space; with the last ones, its lower right (20, 0) is at (6.628860, 6.230623). + let placed = |ops: &str| { + let s = pattern_read7(&def, &pattern_layer7("Art", &format!("(Grid) {ops} p\n{}f\n", rect_path(10.0, 10.0, 90.0, 90.0)))); + let Paint::Pattern { xf, .. } = fill(&art(&s.doc)[0]) else { panic!() }; + xf + }; + assert_eq!(placed(cases[0].0) * Point::ZERO, Point::new(10.0, 65.0)); + assert!((placed(cases[3].0) * Point::new(20.0, 20.0)).distance(Point::new(6.628860, 93.769377)) < 1e-5); + // Without its nine operands, a fill with the pattern isn't read. + let r = pattern_read7(&def, &pattern_layer7("Art", &format!("(Grid) 0 0 1 1 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 10.0, 90.0, 90.0)))); + assert_unread(&r, "pattern fills"); +} + +/// With a pattern defined in the newer form (art on `%_` lines), the matrix alone places the tiles +/// (#1025): the other operands don't move them, and a fill without all nine is still read. +#[test] +fn a_newer_pattern_fill_is_placed_by_its_matrix_alone() { + let placed = |ops: &str| { + let s = read_ok(&format!("{DOTS}{}", layer(&format!("(Dots) {ops} p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))))); + assert!(s.unread.is_empty() && s.warnings.is_empty(), "{ops}: {:?} {:?}", s.unread, s.warnings); + let Paint::Pattern { xf, .. } = fill(&art(&s.doc)[0]) else { panic!("{ops}: {:?}", fill(&art(&s.doc)[0])) }; + xf + }; + let plain = placed("0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0]"); + assert_eq!(plain * Point::ZERO, Point::new(0.0, 92.0)); + assert_eq!(placed("25 30 1.5 .75 30 1 45 10 0 [1 0 0 1 0 0]"), plain); + assert_eq!(placed("0 0 1 1 0 0 0 0 [1 0 0 1 0 0]"), plain); +} + +/// An art style `Tiled` whose fill is `(name) {ops} p`. +fn tiled_style(name: &str, ops: &str) -> String { + format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(Tiled) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n({name}) {ops} p\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ) +} + +/// A fill set through an art style on a text object places the tiles as a `p` fill with the same +/// operands does: with the other operands before the matrix for a pattern defined in the version 7 +/// form, by the matrix alone for one defined in the newer form. +#[test] +fn a_style_pattern_fill_is_placed_as_a_direct_one() { + let rect = rect_path(10.0, 20.0, 70.0, 80.0); + // The text object's drawn look (without a pattern to take the placement from), then the object. + 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 ops = "25 30 1.5 .75 30 1 45 10 0 [.8 .1 -.2 1.1 4 -6]"; + for (name, def) in [("Grid", pattern7("Grid", "(0 g) @ _ &\n")), ("Dots", DOTS.to_string())] { + let styled = read_ok(&format!("{}{def}{}", tiled_style(name, ops), layer(&format!("{look}{own}1 (Tiled) XW\n")))); + let direct = read_ok(&format!("{def}{}", layer(&format!("({name}) {ops} p\n{rect}f\n")))); + let xf = |s: &Structure| match fill(&art(&s.doc)[0]) { + Paint::Pattern { xf, .. } => xf, + p => panic!("{name}: {p:?} {:?} {:?}", s.unread, s.warnings), + }; + let (got, want) = (xf(&styled).as_coeffs(), xf(&direct).as_coeffs()); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-9), "{name}: {got:?} {want:?}"); + } +} + #[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 9d748f459..d4ae4db83 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -3,6 +3,7 @@ use std::sync::Arc; +use kurbo::Point; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::clipnest::MAX_NEST; use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind}; @@ -123,6 +124,31 @@ fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_lay assert_eq!(tree(d.layers[1].children().unwrap()), "p"); } +/// A `.ai` in PostScript form with a pattern defined in the version 7 form (§3.4): the layer keeps +/// its name, and the pattern is a swatch that fills the path from its offset. +#[test] +fn a_postscript_ai_with_a_version_7_pattern_fills_with_it() { + let pattern = "%AI3_BeginPattern: (Stripes)\n(Stripes) 0 0 10 10 [\n%AI3_Tile\n(0 O 0 R 0 0 0 0.5 k) @ _ &\n\ + (0 O 0 R 0 0 0 1 K) @ (\r1 w\r0 5 m\r10 5 L\rS\r) &\n] E\n%AI3_EndPattern\n"; + let setup = "Adobe_IllustratorA_AI3 /initialize get exec\n"; + let style = "0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n"; + let src = NAMED_PROLOG + .replace(setup, &format!("{setup}{pattern}")) + .replace(style, &format!("{style}(Stripes) 5 5 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")) + .replace("10 10 L\nS\n", "10 10 L\nB\n"); + let r = import(src.as_bytes()).unwrap(); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + assert_eq!(d.layers[0].name.as_deref(), Some("SECTION::Cut")); + assert!(matches!(&d.swatch("Stripes").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Stripes")); + assert_eq!(d.pattern("Stripes").unwrap().art.len(), 2); + let square = &d.layers[0].children().unwrap()[0]; + let Some(Paint::Pattern { pattern, xf }) = square.appearance.fill().map(|f| f.paint.clone()) else { panic!("{:?}", square.appearance) }; + assert_eq!(pattern, "Stripes"); + // The tile's top left, (0, 10) in its space, is at (5, 15) in art space: 85 down the 100-point page. + assert_eq!(xf * Point::ZERO, Point::new(5.0, 85.0)); +} + /// A `.ai` in PostScript form with a graph on each of two layers (version 7 specification §17): its /// data on `%_` lines, then its drawn art in a group, each object followed by `%_… Go`. fn graphs_on_layers() -> String { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 92d6f79d2..6f02703ca 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -104,7 +104,9 @@ private editing data. VectorCraft: without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868. Graphs (`%_Gs` … `%_GS`) come in as their drawn art, as plain groups, paths and type, without their data or settings, with a note. without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868, with its paragraphs' letter - and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`). + and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`). Its patterns in + the version 7 form (a list of `@` and `&` layers, `_ &` for the tile's background) open as pattern swatches, + with a fill's offset, scale, rotation, reflection and shear applied before its matrix. - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy version 7 with layers and groups; not yet tested by users who hand files back). Closing the open-side issues: 10–20 h with users' files.