diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 54d97a4527..d8f3ae0fe4 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -27,7 +27,9 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{ + as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, paint_type_clip, reshape_leaves, +}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, @@ -689,6 +691,9 @@ fn moved_type_group(n: &Node) -> Option { /// them. `None` when no type stays type. fn container_keeping_type(d: &Document, n: &Node) -> Option { fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { + if !clipping { + paint_type_clip(Some(d), n); + } let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 70e5994cb5..4c8191fd2b 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -746,9 +746,13 @@ pub(crate) fn clipping_text_outline(t: &Node) -> Option { /// 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 outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and -/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`. +/// its outlines with its visibility and effects ([`clipping_text_outline`]), and its fills and +/// strokes become art of their own ([`paint_type_clip`]). The swatches and patterns that +/// live-effect plug-ins painting their pieces name are looked up in `doc`. pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { + if !clipping { + paint_type_clip(doc, n); + } let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { @@ -766,6 +770,36 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping } } +/// When `n` is a clip group or clipping layer whose clipping path is type that paints +/// ([`Node::clip_paint`]), make it the art the canvas draws for it: the type's outlines clip +/// ([`clipping_text_outline`]), its fills as art ([`as_art`]) paint behind the clipped art inside +/// the clip, and its strokes as art paint over it, outside the clip, in a group that `n` becomes +/// (keeping its id, name and transparency) with the clip group inside. The other members stay as +/// they are. Hidden type paints nothing. +pub(crate) fn paint_type_clip(doc: Option<&Document>, n: &mut Node) { + let Some((first, rest)) = n.children().and_then(|ch| ch.split_first()) else { return }; + if !n.clips() || !matches!(first.kind, NodeKind::Text(_)) || !first.visible { + return; + } + let paint = first.clip_paint(); + if paint.fill.is_none() && paint.stroke.is_none() { + return; + } + let Some(clip) = clipping_text_outline(first) else { return }; + let art = |part: Option| part.and_then(|p| as_art(doc, &p, None)).map(Arc::new); + let members: Vec> = std::iter::once(Arc::new(clip)).chain(art(paint.fill)).chain(rest.iter().cloned()).collect(); + let stroke = art(paint.stroke); + let id = n.id; + match (&mut n.kind, stroke) { + (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }, None) => *children = members, + (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }, Some(stroke)) => { + *children = vec![Arc::new(Node::new(id, NodeKind::Group { children: members, clip: true })), stroke]; + *clip = false; + } + _ => {} + } +} + /// `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. Type's own fills and strokes keep their effects on its whole outline. An image's own diff --git a/crates/engine/src/tests/clippaint.rs b/crates/engine/src/tests/clippaint.rs index de637870a3..1355c11b7d 100644 --- a/crates/engine/src/tests/clippaint.rs +++ b/crates/engine/src/tests/clippaint.rs @@ -84,6 +84,63 @@ fn release_and_ungroup_keep_the_clipping_paths_paint() { } } +/// Pixels of `doc` rendered on white that are mostly blue and mostly green. +fn blue_and_green(doc: &Document) -> (usize, usize) { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let r = Renderer::new().render(doc, 200, 200, Affine::IDENTITY, &opts); + let (mut blue, mut green) = (0, 0); + for y in 0..200 { + for x in 0..200 { + let p = r.pixel(x, y); + blue += usize::from(p[2] > 200 && p[0] < 60 && p[1] < 60); + green += usize::from(p[1] > 200 && p[0] < 60 && p[2] < 60); + } + } + (blue, green) +} + +#[test] +fn painted_type_clipping_inside_a_group_with_geometry_effects_keeps_its_paint() { + // The canvas and the SVG and PDF output paint the type's fill and stroke as they did before + // the effect. Type that stays type beside the clip group takes the second Transform case + // through baking that keeps type. + let cases = + [("distort.transform", json!({"moveH": 5}), false), ("distort.transform", json!({"moveH": 5}), true), ("distort.roughen", json!({}), false)]; + for (effect, params, other_type) in cases { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + rect(&mut s, 0.0, 30.0, "#ff0000"); + let t = NodeId(s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Clip", "size": 72})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.all", &json!({})).unwrap(); + let clip = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [t.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [t.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2, "ids": [t.0]})).unwrap(); + let before = blue_and_green(&s.doc().unwrap().doc); + let mut ids = vec![clip.0]; + if other_type { + ids.push(s.execute("text.create", &json!({"x": 10, "y": 190, "text": "x", "size": 10})).unwrap()["id"].as_u64().unwrap()); + } + let group = NodeId(s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": effect, "params": params, "ids": [group.0], "item": null})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let (blue, green) = blue_and_green(&doc); + assert!( + blue * 2 > before.0 && green * 2 > before.1, + "{effect} {other_type}: the canvas paints the type's fill and stroke: {blue} {green}, {before:?} before" + ); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + let pdf = vectorcraft_pdf::export(&doc, &Default::default()).unwrap(); + for (what, out) in [("SVG", vectorcraft_svg::import(&svg).unwrap()), ("PDF", vectorcraft_pdf::import(&pdf).unwrap())] { + let (b, g) = blue_and_green(&out); + assert!( + b.abs_diff(blue) * 10 < blue && g.abs_diff(green) * 5 < green, + "{effect} {other_type}, {what}: {b} {g} against the canvas's {blue} {green}" + ); + } + } +} + #[test] fn type_as_a_clipping_path_paints_its_characters() { let mut s = Session::new();