diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index f54f34cf0..e224d62ef 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -330,6 +330,40 @@ mod tests { assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}"); } + /// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`, + /// in a group with a square and Offset Path 0, which leaves every path as it is. + fn type_in_group(style: impl FnOnce(&mut Node)) -> Node { + let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts); + let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text))); + style(&mut t); + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]); + g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true }); + let art = evaluate_container(&g).unwrap(); + (*art.children().unwrap()[1]).clone() + } + + #[test] + fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() { + use vectorcraft_color::BlendMode; + let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply)); + assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}"); + assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply)); + // Its opacity mask, its own shadow, its knockout shape option and its visibility stay. + let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); + let mask = vectorcraft_doc::OpacityMask::new(mask_art, true); + assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask)); + let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true }; + assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]); + assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape); + assert!(!type_in_group(|t| t.visible = false).visible); + // Its own Transform moves its outlines 30 pt before the group's effect reshapes them. + let x0 = |n: Node| n.geometric_bounds().unwrap().x0; + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }; + let moved = x0(type_in_group(|t| t.appearance.effects.push(transform))); + assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}"); + } + #[test] fn hidden_effect_or_plain_group_is_untouched() { let mut g = group_with("pathfinder.add"); diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 763f30658..c3f07831f 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -86,7 +86,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), _ => return None, }; - outline_nested_text(&mut art); + outline_nested_text(&mut art, false); art.opacity = 1.0; art.blend = Default::default(); art.isolate = false; @@ -97,22 +97,33 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { Some(art) } -/// Replace type anywhere inside `n` by its outlines. -fn outline_nested_text(n: &mut Node) { +/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look +/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes +/// its plain outlines ([`outline_text`]) with its visibility, which is what +/// [`crate::clip_outline`] clips by. +fn outline_nested_text(n: &mut Node, clipping: bool) { + let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; - for c in ch.iter_mut() { - if let Some(o) = outline_text(c) { + for (i, c) in ch.iter_mut().enumerate() { + let clipping = clipping || (clips && i == 0); + let art = match &c.kind { + NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + NodeKind::Text(_) => as_art(c, None), + _ => None, + }; + if let Some(o) = art { *c = Arc::new(o); } else if c.children().is_some() { - outline_nested_text(Arc::make_mut(c)); + outline_nested_text(Arc::make_mut(c), clipping); } } } -/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`] -/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is -/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None` -/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`). +/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: +/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped +/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and +/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a +/// perspective, or has no art (a symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) { return None; @@ -154,11 +165,13 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { // The art is a group with `n`'s id (a clip group for an image). let mut out = art; out.name = n.name.clone(); + out.visible = n.visible; out.opacity = n.opacity; out.blend = n.blend; out.isolate = n.isolate; // One element: its pieces never knock each other out. out.knockout = vectorcraft_doc::Knockout::Off; + out.knockout_shape = n.knockout_shape; out.extras_mut().mask = n.extras().mask.clone(); out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); Some(out) @@ -281,7 +294,7 @@ fn image_count(n: &Node) -> usize { pub(crate) fn outlined_members(n: &Node) -> Node { let hook: &dyn Fn(&Node) -> Option = &outline_text; let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook)); - outline_nested_text(&mut art); + outline_nested_text(&mut art, false); art } diff --git a/crates/engine/src/tests_containers.rs b/crates/engine/src/tests_containers.rs index 211bca2e1..b06aae7eb 100644 --- a/crates/engine/src/tests_containers.rs +++ b/crates/engine/src/tests_containers.rs @@ -149,3 +149,41 @@ fn group_appearance_round_trips_and_expands() { // Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond. assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6); } + +#[test] +fn type_in_a_group_with_a_geometry_effect_exports_and_expands_with_its_look() { + let mut s = session(); + let square = id_of(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40}))); + let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"}))); + run(&mut s, "transparency.set", json!({"ids": [t.0], "opacity": 50})); + run(&mut s, "select.set", json!({"ids": [square.0, t.0]})); + let g = id_of(&run(&mut s, "object.group", json!({}))); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 0}})); + // SVG export writes the type's outlines at its opacity, and none of them while it is hidden. + let svg = |s: &Session| vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default()); + let shown = svg(&s); + assert!(!shown.contains("