From 8f7486b90f1f3781bec6f26cacc6d7cf7c418848 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 19 Oct 2026 09:08:57 +0300 Subject: [PATCH 1/2] G9: .ai art styles read Adobe Feather as live effects Plain art-style import maps Adobe Feather dictionaries to stylize.feather so styled objects stay editable on open. Co-authored-by: Cursor --- crates/eps/src/import/ai/style.rs | 39 +++++++++++++++++++++++++++---- crates/eps/src/import/ai/tests.rs | 22 +++++++++++++++++ docs/gaps.md | 2 +- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index e6fd8defb..07ffb0728 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -21,7 +21,7 @@ //! ``` //! //! A style is *plain* when it is one fill and one stroke at most, in solid colours (a fill's gradient -//! is the object's own), and only effects read here (Outer Glow, Drop Shadow) after them. Such +//! is the object's own), and only effects read here (Outer Glow, Drop Shadow, Feather) after them. Such //! objects are read as themselves with that fill, stroke and those effects; others keep their //! drawn look. A fill's or stroke's style says whether it paints with `stroke fill Xd`. @@ -36,9 +36,10 @@ use super::obj::V; use super::paint; /// The filters a plain style may chain. -const PLAIN: [&str; 7] = - ["Chain Style Filter", "Stack Style Filter", "Stroke Style Filter", "Fill Style Filter", "Conduit Filter", "Blend Style Filter", GLOW]; +const PLAIN: [&str; 8] = + ["Chain Style Filter", "Stack Style Filter", "Stroke Style Filter", "Fill Style Filter", "Conduit Filter", "Blend Style Filter", GLOW, FEATHER]; const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files +const FEATHER: &str = "Adobe Feather"; // brand-ok: the filter's name in the files const SHADOW: &str = "Adobe Drop Shadow"; // brand-ok: the filter's name in the files /// A plain style's paint, as the object comes in. @@ -307,7 +308,7 @@ fn style(filters: &[Filter]) -> ArtStyle { } } for (i, f) in filters.iter().enumerate() { - let effect = f.name == GLOW || f.name == SHADOW; + let effect = f.name == GLOW || f.name == FEATHER || f.name == SHADOW; if !PLAIN.contains(&f.name.as_str()) && f.name != SHADOW { plain = false; } @@ -330,6 +331,12 @@ fn style(filters: &[Filter]) -> ArtStyle { params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}), visible: true, }), + // LiveEffect XML uses `radius`; art styles abbreviate it `rad` (public scripting posts on applyEffect). + FEATHER => effects.push(Effect { + id: "stylize.feather".into(), + params: json!({"radius": param("rad").or_else(|| param("radius")).filter(|v| v.is_finite()).unwrap_or(5.0)}), + visible: true, + }), SHADOW => { let mut params = json!({ "mode": mode(1), @@ -440,6 +447,30 @@ mod tests { } } + #[test] + fn a_feather_reads_its_radius_from_rad() { + let data = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n12 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(data); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(12.0))); + } + + #[test] + fn a_feather_falls_back_to_radius_and_defaults_to_five_points() { + let with_name = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (radius) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(with_name); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(3.0)); + + let bare = b"/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = art_styles(bare); + let [feather] = styles["Soft"].effects.as_slice() else { panic!() }; + assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); + } + #[test] fn hostile_dash_offsets_and_width_profiles_are_tamed() { let mut f = Filter::default(); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 36f6cb624..4f696d6b1 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1243,6 +1243,28 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +#[test] +fn an_object_with_a_feather_style_comes_in_with_a_live_effect() { + let feather_style = "%AI9_BeginArtStyles\n\ + /KnownStyle :\n(Soft) /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 Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n8 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n\ + %AI9_EndArtStyles\n"; + let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); + let art_ = format!( + "0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Soft) XW\n", + rect_path(0.0, 0.0, 10.0, 10.0) + ); + let r = read_ok(&format!("{feather_style}{}", layer(&art_))); + let a = art(&r.doc); + assert_eq!(a.len(), 1); + let [feather] = a[0].appearance.effects.as_slice() else { panic!("{:#?}", a[0].appearance) }; + assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(8.0))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); +} + #[test] fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() { // GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none. diff --git a/docs/gaps.md b/docs/gaps.md index 6d52bd550..f653ae3f4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -99,7 +99,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), guides and non-printing construction - lines (#779), open speed on large files (#758), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope + lines (#779), open speed on large files (#758), live effects, brushes and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope (undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and From b21b3edb6c64d53e11c69676f07be59f1cbbc537 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 02:23:28 +0700 Subject: [PATCH 2/2] fmt (#3064) --- crates/eps/src/import/ai/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 4f696d6b1..0ab7b1b67 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1253,10 +1253,7 @@ fn an_object_with_a_feather_style_comes_in_with_a_live_effect() { /BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n8 /Real (rad) ,\n; /Dict ;\n /Part ,\n;\n\ %AI9_EndArtStyles\n"; let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_"); - let art_ = format!( - "0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Soft) XW\n", - rect_path(0.0, 0.0, 10.0, 10.0) - ); + let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Soft) XW\n", rect_path(0.0, 0.0, 10.0, 10.0)); let r = read_ok(&format!("{feather_style}{}", layer(&art_))); let a = art(&r.doc); assert_eq!(a.len(), 1);