Clipping: painted type keeps its fill and stroke in a group with geometry effects

A clip group whose clipping path is type with a fill or stroke lost that
paint when a group around it had a geometry effect. Outlining the type
for the effect replaced it with a group of glyph outlines, and a group
paints nothing as a clipping path. Distort effects lost it on the canvas
and in SVG and PDF; Transform effects that only move lost it in SVG and
PDF when other type in the group stayed type.

The type's outlines now clip, its fills become art behind the clipped
art inside the clip, and its strokes become art over it outside the
clip, as the canvas draws an unaffected clip group.
This commit is contained in:
dotCooCoo
2026-11-14 21:34:17 -08:00
parent 0c1ac3bdc5
commit 814cae044f
3 changed files with 99 additions and 3 deletions
+6 -1
View File
@@ -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<Node> {
/// them. `None` when no type stays type.
fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
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() {
+36 -2
View File
@@ -746,9 +746,13 @@ pub(crate) fn clipping_text_outline(t: &Node) -> Option<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 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<Node>| part.and_then(|p| as_art(doc, &p, None)).map(Arc::new);
let members: Vec<Arc<Node>> = 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
+57
View File
@@ -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();