From aecff872a52eb485abca7970fd5a7f9b25b6c5ee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 18:50:43 -0800 Subject: [PATCH] .ai import: a variable-width stroke whose look draws nothing comes in as itself An object with a variable-width stroke whose drawn look holds nothing that draws, apart from copies read from %_ lines, has no outline to keep. It now comes in as the object with its width profile instead of being dropped. --- crates/eps/src/import/ai/read/ops.rs | 33 +++++++++++++++++++++++++++- crates/eps/src/import/ai/style.rs | 13 +++++++---- crates/eps/src/import/ai/tests.rs | 18 +++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 7e7b79746..08a71b853 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -531,6 +531,24 @@ impl<'a> Reader<'a> { { return; } + // A variable-width stroke's look that draws nothing but copies read from `%_` lines: an + // empty group, which [`styled`] takes the stroke for. + let drawn; + let look = if !hidden + && slot_of(object.name.as_deref()).is_none() + && matches!(look.kind, NodeKind::Group { .. }) + && self.styles.get(&style).is_some_and(|st| st.uneven) + && !draws(look, &self.hidden_ids) + { + let mut copy = look.clone(); + if let Some(children) = copy.children_mut() { + children.clear(); + } + drawn = copy; + &drawn + } else { + look + }; // A style read here: the object, painted as it says. if let Some(appearance) = self .styles @@ -658,7 +676,8 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p // 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(); if !st.plain - || (!slot && !st.simple) + // A look with no outline in it to keep takes a variable-width stroke as the object's own. + || (!slot && !st.simple && (!st.uneven || draws(look, &HashSet::new()))) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) { return None; @@ -733,3 +752,15 @@ fn strip(n: &mut Node, hidden: &HashSet) { } } } + +/// Whether `n` holds anything that draws, apart from the children read from `%_` lines (`hidden`): +/// a node other than a group or a compound path. +fn draws(n: &Node, hidden: &HashSet) -> bool { + if hidden.contains(&n.id) { + return false; + } + match n.children() { + Some(children) if matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) => children.iter().any(|c| draws(c, hidden)), + _ => true, + } +} diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 6dda903c1..7b6c5facc 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -98,6 +98,9 @@ pub(super) struct ArtStyle { /// Shadow on the object: 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. pub simple: bool, + /// It would be [`Self::simple`] but for a variable-width stroke: an object whose drawn look + /// is empty takes it with its profile. + pub uneven: bool, /// Its fills and strokes, bottom first. pub items: Vec, /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. @@ -652,14 +655,16 @@ fn style(filters: &[Filter]) -> ArtStyle { } st.plain = b.plain; let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count(); - st.simple = st.plain + let simple = st.plain && count(true) <= 1 && count(false) <= 1 - // A variable-width stroke curves between its width points, and `WidthProfile` is straight - // between them: the drawn look holds the outline as drawn. - && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { profile: None, .. } | StyleItem::Gradient { .. })) + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. })) && st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty()) && st.effects.iter().all(|e| matches!(e.id.as_str(), "stylize.outerGlow" | "stylize.feather" | "stylize.dropShadow")); + // A variable-width stroke curves between its width points, and `WidthProfile` is straight + // between them: the drawn look holds the outline as drawn. + let even = st.items.iter().all(|i| !matches!(i, StyleItem::Stroke { profile: Some(_), .. })); + (st.simple, st.uneven) = (simple && even, simple && !even); st } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index fff315126..c4786fa0d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1916,6 +1916,24 @@ fn a_variable_width_stroke_comes_in_as_its_drawn_outline() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +#[test] +fn a_variable_width_stroke_with_an_empty_look_comes_in_as_itself() { + // GUARDS: with no outline in its look to keep, the object was dropped. + let hidden_line = "0 5 m\n10 5 L\n".replace('\n', "\n%_"); + let hidden_fill = format!("{}f\n", rect_path(0.0, 3.0, 10.0, 7.0)).replace('\n', "\n%_"); + let lens = width_profile_style(&[(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)]); + // An empty look, and ones whose group or compound path holds only a copy read from `%_` lines. + for look in ["u\nU\n".to_string(), format!("u\nu\n%_{hidden_fill}\nU\nU\n"), format!("u\n*u\n%_{hidden_fill}\n*U\nU\n")] { + let art_ = format!("{look}%_{hidden_line}S\n1 (Tapered) XW\n"); + let r = read_ok(&format!("{lens}{}", layer(&art_))); + let a = art(&r.doc); + let [object] = a.as_slice() else { panic!("{look:?}: {a:#?}") }; + assert!(matches!(object.kind, NodeKind::Path { .. }), "{look:?}: {object:#?}"); + assert_eq!(object.appearance.stroke().map(|s| (s.width, s.profile.is_some())), Some((4.0, true)), "{look:?}: {object:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{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.