From 149f4243b235219628aa8ece0b5f2c16a6613b7e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:08:22 -0800 Subject: [PATCH 1/2] Place: DCS transparency interactions look only at what clipping paths show Show DCS Transparency Interactions compared full object bounds, so a transparent object over the part of a DCS image that its clipping path hides marked the image. Bounds are now cut to the clipping paths of the clip groups and clipping layers that hold each object. --- crates/engine/src/cmd/links.rs | 29 ++++++++++++++++++++++------- crates/engine/src/tests/dcs.rs | 26 ++++++++++++++++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 720a906462..f480a49e8c 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1939,15 +1939,21 @@ fn status_rank(status: &str) -> usize { /// holds them) or overlap an object that is, as Show DCS Transparency Interactions marks them. fn dcs_transparency(d: &Document) -> BTreeSet { /// Each visible DCS image's id, bounds and whether it is transparent, and the bounds of each - /// other visible object with its own transparency. - fn walk(d: &Document, n: &Node, held: bool, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec) { + /// other visible object with its own transparency; bounds are cut to `clip`, the bounds of the + /// clipping paths of the clip groups and layers holding the object. + fn walk(d: &Document, n: &Node, held: bool, clip: Option, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec) { if !n.visible { return; } let own = n.shows_own_transparency(); + // The part of the object that shows through `clip`; none when it is clipped away. + let shown = n.geometric_bounds().and_then(|b| match clip { + Some(c) => Some(c.intersect(b)).filter(|i| i.area() > 0.0), + None => Some(b), + }); match &n.kind { NodeKind::Image(im) if d.images.get(&im.key).is_some_and(ImageBlob::is_separated) => { - if let Some(b) = n.geometric_bounds() { + if let Some(b) = shown { images.push((n.id, b, held || own)); // A transparent DCS image is an object with its own transparency for the // others it overlaps. @@ -1956,16 +1962,25 @@ fn dcs_transparency(d: &Document) -> BTreeSet { } } } - _ if own => see_through.extend(n.geometric_bounds()), + _ if own => see_through.extend(shown), _ => {} } - for c in n.children().into_iter().flatten() { - walk(d, c, held || own, images, see_through); + let Some(children) = n.children() else { return }; + // A clip group's or clipping layer's first child clips the others. + let clip = match &n.kind { + NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. } => match children.first().and_then(|c| c.geometric_bounds()) { + Some(c) => Some(clip.map_or(c, |o| o.intersect(c))), + None => clip, + }, + _ => clip, + }; + for c in children { + walk(d, c, held || own, clip, images, see_through); } } let (mut images, mut see_through) = (vec![], vec![]); for l in &d.layers { - walk(d, l, false, &mut images, &mut see_through); + walk(d, l, false, None, &mut images, &mut see_through); } images .into_iter() diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 2801432a0a..271df2e106 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -657,6 +657,32 @@ fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() { assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false"); } +#[test] +fn dcs_transparency_looks_only_at_the_part_of_an_image_its_clipping_path_shows() { + let dir = temp_dir("transparency-clipped"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let image = image_of(&s, &r["ids"][0]).id; + let (clip, all) = { + let doc = &s.doc().unwrap().doc; + let NodeKind::Group { children, clip: true } = &doc.node(group).unwrap().kind else { panic!("a clipping group") }; + (children[0].geometric_bounds().unwrap(), doc.node(image).unwrap().geometric_bounds().unwrap()) + }; + assert!(all.x1 - clip.x1 > 2.0, "the file's clipping path shows the left half: {clip:?} of {all:?}"); + // A half-transparent square over the part of the image the clipping path hides. + let x = (clip.x1 + all.x1) / 2.0 - 1.0; + let over = s.execute("shape.rectangle", &json!({"x": x, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap(); + s.execute("transparency.set", &json!({"ids": [over], "opacity": 50})).unwrap(); + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + assert!(!marked(&mut s), "the square is over the hidden part only"); + s.execute("transparency.set", &json!({"ids": [over], "opacity": 100})).unwrap(); + let shown = s.execute("shape.rectangle", &json!({"x": clip.x0, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap(); + s.execute("transparency.set", &json!({"ids": [shown], "opacity": 50})).unwrap(); + assert!(marked(&mut s), "a square over the part that shows"); +} + #[test] fn dcs_transparency_counts_a_transparent_dcs_image_over_another() { let dir = temp_dir("transparency-two"); diff --git a/docs/mcp.md b/docs/mcp.md index 20e37222c6..cf7bbbf60a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6705,7 +6705,8 @@ DCS Transparency Interactions, off by default) adds `dcsTransparency: true` to t and the panel shows a warning mark on them. VectorCraft's rule for it: the image, or a group or layer holding it, has an opacity below 100%, a blend mode other than Normal, an opacity mask or an effect, or its bounds overlap the bounds of another visible object that has one of those or a gradient with a transparent stop (in a fill, a stroke or -a character's fill or stroke); a DCS image with transparency of its own counts as such an object. The check looks at +a character's fill or stroke); a DCS image with transparency of its own counts as such an object. Bounds are cut to the +bounds of the clipping paths of the clip groups and clipping layers holding an object. The check looks at every object in the layers each time the list is read. A `dcsTransparency` that is not true or false is an error. `links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file From c11cb9c180084518d74cb2f3bcf11fac04906752 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:04:56 -0800 Subject: [PATCH 2/2] Place: test DCS transparency with clip groups, clipping layers and nested clips Adds a test with a half-transparent square that its own clip group, its clipping layer, or two nested clipping paths together hide, so the square no longer marks the DCS image under it. --- crates/engine/src/tests/dcs.rs | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 271df2e106..3f61388158 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -683,6 +683,62 @@ fn dcs_transparency_looks_only_at_the_part_of_an_image_its_clipping_path_shows() assert!(marked(&mut s), "a square over the part that shows"); } +#[test] +fn dcs_transparency_skips_a_transparent_object_its_own_clipping_paths_hide() { + let dir = temp_dir("transparency-clipped-over"); + let path = write(&dir, "pic.eps", &two_plates()); + // A session with the file placed, and the top-left corner of the image, inside the part its + // clipping path shows. + let placed = || { + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let b = image_of(&s, &r["ids"][0]).geometric_bounds().unwrap(); + (s, b.x0, b.y0) + }; + let rect = |s: &mut Session, x: f64, y: f64, width: f64| { + s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": width, "height": 2})).unwrap()["id"].as_u64().unwrap() + }; + let make = |s: &mut Session, ids: [u64; 2]| { + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap() + }; + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + // A half-transparent square over the part of the image that shows. + let square = |s: &mut Session, x: f64, y: f64| { + let id = rect(s, x, y, 2.0); + s.execute("transparency.set", &json!({"ids": [id], "opacity": 50})).unwrap(); + assert!(marked(s), "the square over the image"); + id + }; + + // Its own clip group's clipping path is beside it. + let (mut s, x, y) = placed(); + let over = square(&mut s, x, y); + let beside = rect(&mut s, x + 5.0, y, 2.0); + make(&mut s, [over, beside]); + assert!(!marked(&mut s), "a clip group that hides the square"); + + // Its layer's clipping path is beside it. + let (mut s, x, y) = placed(); + s.execute("layer.new", &json!({})).unwrap(); + square(&mut s, x, y); + rect(&mut s, x + 5.0, y, 2.0); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("layer.clippingMask.toggle", &json!({})).unwrap()["clip"].as_bool().unwrap()); + assert!(!marked(&mut s), "a clipping layer that hides the square"); + + // Each of two nested clipping paths shows part of the inner group, and only the inner one + // covers the square. + let (mut s, x, y) = placed(); + let over = square(&mut s, x, y); + let inner = rect(&mut s, x, y, 40.0); + let group = make(&mut s, [over, inner]); + assert!(marked(&mut s), "the inner clipping path shows the square"); + let outer = rect(&mut s, x + 10.0, y, 20.0); + make(&mut s, [group, outer]); + assert!(!marked(&mut s), "the two clipping paths together hide the square"); +} + #[test] fn dcs_transparency_counts_a_transparent_dcs_image_over_another() { let dir = temp_dir("transparency-two");