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.
This commit is contained in:
Don Omondi
2026-11-06 20:34:10 -05:00
parent bc57bdaee3
commit e88d1b6380
4 changed files with 88 additions and 4 deletions
+16 -1
View File
@@ -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 {
+14 -2
View File
@@ -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<StyleItem>,
/// 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
}
+56
View File
@@ -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, "")
+2 -1
View File
@@ -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 |