From 8f32e4f504720d7959d98c9299372fe4168aec0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:09:08 +0200 Subject: [PATCH] M6: Sketch import: shape groups that are clipping masks clip Sketch 51 and older files make masks from shape groups; the mask flag was lost when the group became its shape or boolean operation, so a masked map spilled out of its card. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/path.rs | 2 ++ crates/format/src/sketch/style_tests.rs | 15 +++++++++++++++ crates/format/src/sketch/tree.rs | 13 +++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 47a8f2d34..6ebb1a756 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -143,6 +143,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, super::style::clear(m); super::style::apply(cx, st, cw, ch, m); } + super::tree::mask(l, m); } return Some(node); } @@ -166,6 +167,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); node.insert("booleanOperation".into(), json!(op)); node.insert("children".into(), Value::Array(chain)); + super::tree::mask(l, &mut node); if let Some(st) = l.get("style") { super::style::apply(cx, st, w, h, &mut node); } diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 81b9f774e..6bc02bd87 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -233,3 +233,18 @@ fn style_on_frames_and_text_kinds_untouched() { assert!(matches!(node(&doc, "Board").kind, NodeKind::Frame)); assert_eq!(node(&doc, "Board").fills.len(), 1); } + +#[test] +fn shape_group_masks_clip() { + // Sketch 51 and older: the mask is a shape group. + let inner = with(r("Outline"), "booleanOperation", json!(-1)); + let mask = with(shape_group("Card", 0.0, 0.0, 100.0, 50.0, vec![inner]), "hasClippingMask", json!(true)); + let a = rect("A", 0.0, 0.0, 300.0, 300.0); + let b = with(r("B"), "shouldBreakMaskChain", json!(true)); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 300.0, vec![mask, a, b])]); + let m = node(&doc, "Card"); + assert!(m.is_mask); + let holder = doc.node(m.parent.unwrap()).unwrap(); + let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(kids, ["Card", "A"]); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 303be326a..bfa594558 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -95,10 +95,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let shape = super::path::shape(cx, l, w, h)?; super::path::put_shape(cx, shape, &mut node); } - if bool_field(l, "hasClippingMask") == Some(true) { - node.insert("isMask".into(), json!(true)); - node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); - } + mask(l, &mut node); match class { "artboard" | "frame" => { let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; @@ -129,6 +126,14 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: Some(Value::Object(node)) } +/// A clipping mask's flag and kind (outline or alpha). +pub(crate) fn mask(l: &Value, node: &mut JsonMap) { + if bool_field(l, "hasClippingMask") == Some(true) { + node.insert("isMask".into(), json!(true)); + node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); + } +} + /// Count one more layer; false (with a warning) once the file's budget is spent. fn count(cx: &mut Cx) -> bool { if cx.nodes >= cx.max_nodes {