From e88d1b6380d4efabdb681c9572b63550ee5ab5c7 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:34:10 -0500 Subject: [PATCH 1/3] EPS/.ai editing data: paths keep art styles of several fills and strokes live Only type kept an art style that stacks several fills or strokes, or gives one its own opacity or blend mode, as a live appearance; paths came in as their drawn look. A path or compound path now takes such a style (solid colours and gradients, strokes without a brush, no effects on the items, object effects any object takes that don't move the outline) when every gradient is its own swatch painted by the drawn look, which gives each its placement. Anything else keeps the drawn look. --- crates/eps/src/import/ai/read/ops.rs | 17 ++++++++- crates/eps/src/import/ai/style.rs | 16 +++++++- crates/eps/src/import/ai/tests.rs | 56 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index dc3089024..30637f181 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -985,10 +985,12 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p use crate::import::ai::style::StyleItem; // A text object's slot (an empty group standing for it) takes the style as the type will. let slot = slot_of(object.name.as_deref()).is_some(); + // Several fills and strokes, on a path or a compound path. + let stacked = !slot && !st.simple && st.stacked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); // A symbol instance draws its symbol's art: it keeps its drawn look. if !st.plain || matches!(object.kind, NodeKind::SymbolInstance { .. }) - || (!slot && !st.simple) + || (!slot && !st.simple && !stacked) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) || (st.pathfinder && (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. }))) @@ -1003,6 +1005,19 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p // drawn look has each where the app placed it. let mut placed = HashMap::new(); placed_paints(look, &mut placed, 0); + // A stack's gradients are each its own swatch, placed where the drawn look has it: one fitted + // to the object's bounds instead could lie elsewhere, so such a stack keeps its drawn look. + if stacked { + let mut seen = HashSet::new(); + let placed_apart = st.items.iter().all(|i| match i { + StyleItem::Gradient { name: Some(n), .. } => seen.insert(n) && placed.contains_key(paints.names.get(Global::Gradient, n)), + StyleItem::Gradient { name: None, .. } => false, + _ => true, + }); + if !placed_apart { + return None; + } + } let mut items = vec![]; for (n, item) in st.items.iter().enumerate() { let mut layer = match item { diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 0e1a52a6f..31c6f9808 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -40,7 +40,8 @@ //! with a pattern or gradient too; strokes with a Calligraphic brush too), each with its own //! transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and -//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the +//! effects (a path's stack of several fills and strokes when its drawn look places each of its +//! gradients); others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the //! object's chain) are read on a group with no fills or strokes of its own, which keeps its members. use std::collections::HashMap; @@ -141,8 +142,13 @@ pub(super) struct ArtStyle { /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke) /// and some Distort & Transform effects ([`any_object_takes`]) on the object /// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look - /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. + /// can't be retyped), paths and compound paths [`Self::stacked`] ones; other objects keep their drawn look, which is the app's own rendering. pub simple: bool, + /// It is several fills and strokes in solid colours or gradients (strokes without a brush), + /// each with its own visibility, opacity and blend mode but no effects of its own, under only + /// the effects any object takes that don't move the outline: what a path or compound path is + /// read with when each gradient is placed as its drawn look has it (`read::ops`' `styled`). + pub stacked: bool, /// Its fills and strokes, bottom first. pub items: Vec, /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. @@ -1334,6 +1340,12 @@ fn style(filters: &[Filter]) -> ArtStyle { && (!st.moves_outline() || st.effects.len() == 1) // Pathfinder effects on a group with no fills or strokes of its own (`styled` checks the group). && (!st.pathfinder || st.items.is_empty()); + st.stacked = st.plain + && !st.pathfinder + && !st.moves_outline() + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Gradient { .. } | StyleItem::Stroke { brush: None, .. })) + && st.looks.iter().all(|l| l.effects.is_empty()) + && st.effects.iter().all(any_object_takes); st } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c883719e3..f6b9bbd99 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4911,6 +4911,62 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// An art style named `Glow`: a path filled with gradient swatch `under`, then gradient swatch +/// `over` at 50 % Screen above it. +fn two_gradient_style(under: &str, over: &str) -> String { + let fill = |name: &str| { + format!( + "/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 ({name}) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n" + ) + }; + format!( + "/KnownStyle :\n(Glow) /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\ + /CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n{}\ + /BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /BlendStyle : 2 0.5 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n\ + {}/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n", + fill(over), + fill(under) + ) +} + +#[test] +fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_them() { + // GUARDS: a path whose style stacks fills with their own opacity and blend mode came in as its drawn look. + let defs = "%AI5_BeginGradient: (A)\n(A) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n\ + %AI5_BeginGradient: (B)\n(B) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let rect = rect_path(0.0, 0.0, 100.0, 50.0); + let hidden_rect = rect.replace('\n', "\n%_"); + // The drawn look: the path filled with each gradient where the app placed it (its transparency + // left out: only where the gradients lie is read from it). + let look = |name: &str, x0: f64| format!("{rect}Bb\n1 ({name}) {x0} 25 0 50 1 0 0 1 0 0 Bg\nf\n0 BB\n"); + let square = |under: &str, over: &str| format!("0 Ae\nu\n{}{}U\n%_{hidden_rect}f\n1 (Glow) XW\n", look(under, 0.0), look(over, 50.0)); + let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&square("A", "B")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let fills: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match (i, i.paint()) { + (AppearanceItem::Fill(f), Paint::Gradient(g)) => (g.swatch.clone(), g.geom.map(|g| g.start.x), f.opacity, f.blend), + _ => panic!("{i:?}"), + }) + .collect(); + assert_eq!(fills, [(Some("A".into()), Some(0.0), 1.0, BlendMode::Normal), (Some("B".into()), Some(50.0), 0.5, BlendMode::Screen)]); + // Two fills of one swatch: which placement is whose isn't known, so the drawn look stays. + let same = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "A"), layer(&square("A", "A")))); + assert!(same.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", same.warnings); + // A look that doesn't paint the gradient (a raster, say): the drawn look stays. + let flat = format!("0 Ae\nu\n{RED}{rect}f\nU\n%_{hidden_rect}f\n1 (Glow) XW\n"); + let flat = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&flat))); + assert!(flat.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", flat.warnings); +} + /// 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, "") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..d6e325efc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS/`.ai` editing data: paths keep art styles of several fills and strokes live) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS and `.ai` editing data: a path or compound path whose art style stacks several fills and strokes (solid colours and gradients, each with its own visibility, opacity and blend mode, under effects any object takes) comes in as itself with that appearance instead of its drawn look. Each gradient must be its own swatch, placed as the drawn look places it; a stack that repeats a swatch, or whose look doesn't paint its gradients, keeps the drawn look, as do fills and strokes with effects of their own. | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | From 92726632284173b8025135522b1596db82c2093f Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:14:20 -0500 Subject: [PATCH 2/3] EPS/.ai editing data: a stack's one gradient lies where its drawn look has it A stacked style with a single gradient took the object's own gradient fill whenever the object had one, even where the drawn look placed the gradient elsewhere. A stack now always takes the placement of its drawn look, which it already requires for each of its gradients. --- crates/eps/src/import/ai/read/ops.rs | 5 +++-- crates/eps/src/import/ai/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 764598dc6..57eac4d15 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1051,8 +1051,9 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p StyleItem::Gradient { name, angle } => { // The drawn look's gradients are keyed by the names they got in the document. let paint = match (name.as_ref().and_then(|n| placed.get(paints.names.get(Global::Gradient, n))), gradients, own_gradient) { - // Other objects than type: the object's own, as they always were. - (_, 1, Some(own)) if !slot => own.paint.clone(), + // Other objects than type: the object's own, as they always were (a stack's + // where its drawn look places it). + (_, 1, Some(own)) if !slot && !stacked => own.paint.clone(), (Some(p), ..) => p.clone(), (None, 1, Some(own)) => own.paint.clone(), _ => { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index ddce056fd..43c24c0e1 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5346,6 +5346,26 @@ fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_t // Two fills of one swatch: which placement is whose isn't known, so the drawn look stays. let same = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "A"), layer(&square("A", "A")))); assert!(same.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", same.warnings); + // One gradient over a solid fill, on a path filled with that gradient placed elsewhere: the + // gradient lies where the drawn look has it, not where the path's own fill does. + let one = two_gradient_style("A", "B").replace("1 (A) 0 0 0 1 1 0 0 1 0 0 Bg\n", "0 0 0 1 0 0 0 Xa\n"); + let own = format!( + "0 Ae\nu\n0 0 0 1 0 0 0 Xa\n{rect}f\n{}U\n%_{hidden_rect}Bb\n%_1 (B) 0 25 0 100 1 0 0 1 0 0 Bg\n%_f\n%_0 BB\n1 (Glow) XW\n", + look("B", 50.0) + ); + let one = read_ok(&format!("%AI9_BeginArtStyles\n{one}%AI9_EndArtStyles\n{defs}{}", layer(&own))); + let a = art(&one.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let placed: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match i.paint() { + Paint::Gradient(g) => g.geom.map(|g| g.start.x), + _ => None, + }) + .collect(); + assert_eq!(placed, [None, Some(50.0)], "{:?}", one.warnings); // A look that doesn't paint the gradient (a raster, say): the drawn look stays. let flat = format!("0 Ae\nu\n{RED}{rect}f\nU\n%_{hidden_rect}f\n1 (Glow) XW\n"); let flat = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&flat))); From e5674ba1c5c1171507bf484a30269c436a5d596a Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:40:55 -0500 Subject: [PATCH 3/3] EPS/.ai editing data: test a stack of a solid fill and a stroke without a brush A path whose style is a solid fill under a 50% Screen stroke comes in as itself with both, each with its own transparency. --- crates/eps/src/import/ai/tests.rs | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 43c24c0e1..7d907f727 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5372,6 +5372,39 @@ fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_t assert!(flat.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", flat.warnings); } +/// A stack of solid colours comes in live too: a path whose style is a solid fill under a stroke +/// without a brush at 50 % Screen comes in as itself with both, each with its own transparency. +#[test] +fn a_path_with_a_solid_fill_under_a_screened_stroke_comes_in_as_itself() { + let stroke = "/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n"; + let over = "/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 (B) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"; + let style = two_gradient_style("A", "B"); + assert!(style.contains(over)); + let style = style.replace(over, stroke).replace("1 (A) 0 0 0 1 1 0 0 1 0 0 Bg\n", "0 0 0 1 0 0 0 Xa\n"); + let rect = rect_path(0.0, 0.0, 100.0, 50.0); + let hidden_rect = rect.replace('\n', "\n%_"); + let square = format!("0 Ae\nu\n0 0 0 1 0 0 0 Xa\n{rect}f\n0 1 1 0 1 0 0 XA\n2 w\n{rect}S\nU\n%_{hidden_rect}f\n1 (Glow) XW\n"); + let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&square))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let items: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match i { + AppearanceItem::Fill(f) => ("fill", matches!(f.paint, Paint::Solid { .. }), f.opacity, f.blend), + AppearanceItem::Stroke(s) => { + ("stroke", matches!(s.paint, Paint::Solid { .. }) && s.brush.is_none() && s.width == 2.0, s.opacity, s.blend) + } + }) + .collect(); + assert_eq!(items, [("fill", true, 1.0, BlendMode::Normal), ("stroke", true, 0.5, BlendMode::Screen)]); +} + /// 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, "")