diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 861b69831..f72240edf 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3919,6 +3919,19 @@ proptest! { survive("mutated .ai Art brushed path", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// [`styled_definitions_text`] (objects with an art style in a pattern's art and in a brush's + /// art), damaged or hostile. + #[test] + fn ai_styled_definitions_never_panic(cut in 0usize..2_400, edits in prop::collection::vec((0usize..2_400, prop::sample::select(vec!['0', '1', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'X', 'W', 'E'])), 0..12)) { + let text = mutate_text(&styled_definitions_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai styled definitions", || { + let d = vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0; + let _ = vectorcraft_brush::library(&d); + Some(d) + })?; + } + /// Brush objects of every kind with any parameters, the brush manager's order with any names, /// and brush art sections nested or cut short, their art alone or in a group after a frame of /// any corners, painted or not, open or closed, the group ended or not: read with the layers, @@ -4206,6 +4219,44 @@ fn the_art_brushed_fuzz_data_opens_live() { assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("R"), "{warnings:?} {n:?}"); } +/// Editing data with an art style (a blue fill and a drop shadow), a pattern, a pattern in the +/// version 7 form (its tile string) and a Scatter brush's art each holding a square with its drawn +/// look and that style, and a path filled with the first pattern. +fn styled_definitions_text() -> String { + use vectorcraft_testkit::ai; + let style = "%AI9_BeginArtStyles\n/KnownStyle :\n(S) /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\n0 0 0 0 0 0 1 Xa\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\ + /BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n\ + 0 /Int (csrc) ,\n/FillStyle : 0 O\n0 0 0 1 0 0 0 Xa\n0 1 Xd\n/Def ;\n (sclr) ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n%AI9_EndArtStyles\n"; + let square = "0 Ae\nu\n1 0 0 0 0 0 0 Xa\n0 0 m\n10 0 L\n10 10 L\n0 10 L\nf\nU\n%_0 0 m\n%_10 0 L\n%_10 10 L\n%_0 10 L\n%_f\n1 (S) XW\n"; + let pattern = format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{square}E\n%AI3_EndPattern\n"); + let tile = square.replace('\n', "\r"); + let legacy = format!("%AI3_BeginPattern: (Q)\n(Q) 0 0 20 20 [\n%AI3_Tile\n(0 O 0 R 0 0 0 1 k 0 0 0 1 K) @\n(\r{tile}) &\n] E\n%AI3_EndPattern\n"); + let art = format!("%AI8_BeginBrushPattern\n(D)\n0 A\n{square}%AI8_EndBrushPattern\n"); + let object = "%AI8_BeginPluginObject\n(Adobe Scatter Brush Tool)\n(B)\n(2 /D/ 5 1 0 0 / 0 0.5 1.5 1) -\n( 1.2 2 0 -0.25 0.25 2 -45 30 1 1 0) .\n%AI8_EndPluginObject\n"; + let path = format!("(P) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", ai::rect(20.0, 20.0, 80.0, 80.0)); + ai::editing_data(100.0, 100.0, &format!("{style}{pattern}{legacy}{art}{object}{}", ai::layer("L", &path))) +} + +/// The data [`ai_styled_definitions_never_panic`] damages, as it is, opens with the patterns' and +/// the brush's squares live. +#[test] +fn the_styled_definitions_fuzz_data_opens_live() { + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&styled_definitions_text())].concat(); + // Trusted: the empty page it is read beside isn't compared with its art. + let (doc, warnings, _) = vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0); + let live = |n: &vectorcraft_doc::Node| matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }) && n.appearance.effects.len() == 1; + for name in ["P", "Q"] { + let tile = doc.pattern(name).map(|p| p.art.clone()).unwrap_or_else(|| panic!("{name}: {warnings:?}")); + assert!(tile.len() == 1 && live(&tile[0]), "{name}: {warnings:?} {tile:?}"); + } + let lib = vectorcraft_brush::library(&doc); + let brush = lib.iter().find(|b| b.name == "B").unwrap_or_else(|| panic!("{warnings:?}")); + assert!(matches!(brush.art().as_slice(), [n] if live(n)), "{warnings:?} {brush:?}"); +} + /// The ids of the objects of an Objects on Path group in the fuzz cases. const ON_PATH_IDS: [&str; 5] = ["f-0001", "f-0002", "f-0003", "f-0004", "f-0005"]; diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 45f60916b..dfc55aa7a 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -121,7 +121,11 @@ impl<'a> Reader<'a> { } if m == "BeginArtStyles" { let section = self.section("EndArtStyles")?; - self.styles = crate::import::ai::style::art_styles(section); + // GUARDS: a definition's art takes the file's art styles and hands them back, so a + // section inside it would replace the file's own. + if self.subreader_depth == 0 { + self.styles = crate::import::ai::style::art_styles(section); + } return Ok(()); } if m == "BeginArtStyleList" { @@ -376,9 +380,11 @@ impl<'a> Reader<'a> { let mut sub = Reader::new(section); sub.subreader_depth = self.subreader_depth + 1; sub.pattern = Some(PatternHead::default()); - // The gradients defined so far and the names given, lent (and those it defines kept). + // The gradients and art styles defined so far and the names given, lent (and the gradients + // it defines kept). sub.gradients = std::mem::take(&mut self.gradients); sub.global_names = std::mem::take(&mut self.global_names); + sub.styles = std::mem::take(&mut self.styles); sub.nodes = self.nodes; sub.template = self.template; self.lend_budgets(&mut sub); @@ -387,6 +393,7 @@ impl<'a> Reader<'a> { self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); self.global_names = std::mem::take(&mut sub.global_names); + self.styles = std::mem::take(&mut sub.styles); // Unread art in the definition, hidden or not, leaves the tile incomplete. let whole = read.is_ok() && sub.unsupported.is_empty() @@ -406,6 +413,7 @@ impl<'a> Reader<'a> { sub.pattern = Some(PatternHead { taken: true, name: def.name.clone(), tile: Some(def.tile) }); sub.gradients = std::mem::take(&mut self.gradients); sub.global_names = std::mem::take(&mut self.global_names); + sub.styles = std::mem::take(&mut self.styles); sub.nodes = self.nodes; sub.template = self.template; self.lend_budgets(&mut sub); @@ -420,6 +428,7 @@ impl<'a> Reader<'a> { self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); self.global_names = std::mem::take(&mut sub.global_names); + self.styles = std::mem::take(&mut sub.styles); let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() @@ -495,6 +504,20 @@ impl<'a> Reader<'a> { return; }; let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + // The objects of the art linked to graphic styles, by their ids in the document. + if !sub.style_links.is_empty() { + let mut ids = HashMap::new(); + for (read, kept) in frame.children.iter().zip(&art) { + pair_ids(read, kept, &mut ids, 0); + } + for (id, style) in &sub.style_links { + if let Some(new) = ids.get(id) + && self.style_links.len() < MAX_NODES + { + self.style_links.push((*new, style.clone())); + } + } + } // Fills and the tables look the pattern up by its name in the file; the document has it by // the name it shows. let mut def = PatternDef::new(&self.global_names.define(Global::Pattern, &head.name), art); @@ -730,8 +753,10 @@ impl<'a> Reader<'a> { // In a space of its own (y down), with no head to take. sub.pattern = Some(PatternHead { taken: true, ..PatternHead::default() }); sub.subreader_depth = self.subreader_depth + 1; - // The gradients and patterns defined so far, and which patterns are in the version 7 form, lent. + // The gradients, art styles and patterns defined so far, and which patterns are in the + // version 7 form, lent. sub.gradients = std::mem::take(&mut self.gradients); + sub.styles = std::mem::take(&mut self.styles); sub.pattern_tiles = std::mem::take(&mut self.pattern_tiles); sub.v7_patterns = std::mem::take(&mut self.v7_patterns); sub.global_names = std::mem::take(&mut self.global_names); @@ -742,6 +767,7 @@ impl<'a> Reader<'a> { self.take_back_budgets(&mut sub); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); + self.styles = std::mem::take(&mut sub.styles); self.pattern_tiles = std::mem::take(&mut sub.pattern_tiles); self.v7_patterns = std::mem::take(&mut sub.v7_patterns); self.global_names = std::mem::take(&mut sub.global_names); diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index f416cc496..55cca0a86 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1129,6 +1129,9 @@ impl<'a> Reader<'a> { for symbol in &mut doc.symbols { link_styles(std::slice::from_mut(&mut symbol.art), &links, &styles, 0); } + for pattern in &mut doc.patterns { + link_styles(&mut pattern.art, &links, &styles, 0); + } } doc.graphic_styles = styles.into(); } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 93e09a183..5765fa6bf 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5797,6 +5797,74 @@ fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// `node` is the red square of [`styled_square`] read as itself with [`ART_STYLES`]' `Blue`: a blue +/// fill and a drop shadow, not the look it draws. +fn assert_blue_square(node: &Node) { + assert!(matches!(node.kind, NodeKind::Path { .. }), "the object, not its look: {node:#?}"); + assert_eq!(fill(node).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + let effects: Vec<_> = node.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(effects, ["stylize.dropShadow"]); +} + +/// An object in a pattern's art with an art style the file defines comes in as itself, as in a +/// symbol's art, whichever section comes first. +#[test] +fn an_object_in_a_patterns_art_with_a_style_this_reads_comes_in_as_itself() { + let pattern = format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{}E\n%AI3_EndPattern\n", styled_square("Blue")); + for body in [format!("{ART_STYLES}{pattern}{}", layer("")), format!("{pattern}{ART_STYLES}{}", layer(""))] { + let r = read_ok(&body); + let def = r.doc.pattern("P").unwrap_or_else(|| panic!("{:?} {:?}", r.unread, r.warnings)); + let [square] = def.art.as_slice() else { panic!("{:#?}", def.art) }; + assert_blue_square(square); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + } +} + +/// The same in a pattern defined in the version 7 form: a layer's tile string holding the object. +#[test] +fn an_object_in_a_version_7_patterns_tile_with_a_style_this_reads_comes_in_as_itself() { + let tile = styled_square("Blue").replace('\n', "\r"); + let layers = format!("(0 O 0 R 0 0 0 1 k 0 0 0 1 K) @\n(\r{tile}) &\n"); + let s = pattern_read7(&format!("{ART_STYLES}{}", pattern7("Grid", &layers)), &pattern_layer7("Art", "")); + let def = s.doc.pattern("Grid").unwrap_or_else(|| panic!("{:?} {:?}", s.unread, s.warnings)); + let [square] = def.art.as_slice() else { panic!("{:#?}", def.art) }; + assert_blue_square(square); +} + +/// An object in a brush's art with an art style the file defines comes in as itself, and the brush +/// that uses the art is kept. +#[test] +fn an_object_in_a_brushs_art_with_a_style_this_reads_comes_in_as_itself() { + let section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{}%AI8_EndBrushPattern\n", styled_square("Blue")); + let r = read_ok(&format!("{section}{ART_STYLES}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + let spray = lib.iter().find(|b| b.name == "Spray").unwrap_or_else(|| panic!("{:?}", r.warnings)); + assert!(matches!(spray.kind, vectorcraft_brush::BrushKind::Scatter(_)), "{:?}", spray.kind); + let art = spray.art(); + let [square] = art.as_slice() else { panic!("{art:#?}") }; + assert_blue_square(square); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + +/// An art styles section inside a symbol's, a pattern's or a brush's art is skipped: the file's own +/// art styles still style the layers' objects read after the definition. +#[test] +fn art_styles_inside_a_definition_leave_the_files_art_styles_alone() { + let nested = format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n", fill_style("Other", "0 0 0 0 1 0 0 Xa\n")); + let square = styled_square("Blue"); + let definitions = [ + symbols(&symbol("Tag", &format!("{nested}{square}"), 1)), + format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{nested}{square}E\n%AI3_EndPattern\n"), + format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{nested}{square}%AI8_EndBrushPattern\n"), + ]; + for definition in definitions { + let r = read_ok(&format!("{ART_STYLES}{definition}{}", layer(&square))); + let a = art(&r.doc); + let [object] = a.as_slice() else { panic!("{definition}: {a:#?}") }; + assert_blue_square(object); + } +} + /// An art style named `name` (its bytes as written) that fills with colour `xa` (`c m y k r g b Xa`). fn fill_style(name: &str, xa: &str) -> String { fill_style_with(name, xa, "") @@ -5964,6 +6032,31 @@ fn an_object_in_a_symbols_art_with_a_listed_style_is_linked() { assert_eq!(art.extras().graphic_style, Some(1), "{art:#?}"); } +#[test] +fn an_object_in_a_patterns_art_with_a_listed_style_is_linked() { + let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]); + let pattern = format!("%AI3_BeginPattern: (P)\n(P) 0 0 10 10\n0 A\n0 Xw\n{}E\n%AI3_EndPattern\n", styled_square("Blue")); + let r = read_ok(&format!("{defs}{pattern}{}", layer(""))); + assert_eq!(style_names(&r.doc), [("Blue".to_string(), 1)]); + let def = r.doc.pattern("P").unwrap_or_else(|| panic!("{:?}", r.warnings)); + let [square] = def.art.as_slice() else { panic!("{:#?}", def.art) }; + assert_eq!(fill(square).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}"); +} + +#[test] +fn an_object_in_a_version_7_patterns_tile_with_a_listed_style_is_linked() { + let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]); + let tile = styled_square("Blue").replace('\n', "\r"); + let layers = format!("(0 O 0 R 0 0 0 1 k 0 0 0 1 K) @\n(\r{tile}) &\n"); + let s = pattern_read7(&format!("{defs}{}", pattern7("Grid", &layers)), &pattern_layer7("Art", "")); + assert_eq!(style_names(&s.doc), [("Blue".to_string(), 1)]); + let def = s.doc.pattern("Grid").unwrap_or_else(|| panic!("{:?}", s.warnings)); + let [square] = def.art.as_slice() else { panic!("{:#?}", def.art) }; + assert_eq!(fill(square).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}"); +} + #[test] fn without_a_style_list_the_default_graphic_styles_stay() { let defaults = style_names(&Document::new(1.0, 1.0)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ebf72a690..108d2b9bd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -257,6 +257,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. +An object in a pattern's art or in a brush's art takes the file's art styles in the same way as an object on a layer: with a style VectorCraft reads, it comes in as itself with its effects live, not as the look it draws. An object in a pattern's art with a style in the file's style list keeps that graphic style's id. An art styles section inside a symbol's, a pattern's or a brush's art is skipped. + ## Format by format | Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index decb0523b..447221238 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1300,6 +1300,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). + In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).