diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 3051b4a28..81a76a31c 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1062,10 +1062,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, .. }))) @@ -1080,6 +1082,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 { @@ -1087,8 +1102,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/style.rs b/crates/eps/src/import/ai/style.rs index 0ee91f862..603cee2b2 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -47,7 +47,8 @@ //! with a pattern or gradient too; strokes with a Calligraphic or Art brush or aligned inside or outside 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; @@ -178,8 +179,13 @@ pub(super) struct ArtStyle { /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke), /// Bas Relief read from its settings block 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`]. @@ -1600,6 +1606,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 8ccb3aa35..9ba450cdb 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5925,6 +5925,115 @@ fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() { 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); + // 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))); + 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)]); +} + /// `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) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fdb286dc7..57a0e619a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -313,6 +313,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in | 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 | `.ai` and EPS import: Art brushes whose objects are in the older form (first number 1, one field fewer and 8 numbers at the end) come into the Brushes panel with their art instead of being left out with a note. In a survey of 302 users' EPS files (used locally, not committed), 652 Art brushes in 188 of the 197 files with the note were left out this way; afterwards 55 files have the note, all for Pattern brushes. Test `an_older_art_brush_comes_in_with_its_art` (crate `vectorcraft-eps`); scores unchanged | | 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. | | 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. |