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 <noreply@anthropic.com>
This commit is contained in:
Jindřich Šaršon
2026-10-22 00:27:20 +02:00
co-authored by Claude Opus 5.5
parent d77d57912b
commit 8f32e4f504
3 changed files with 26 additions and 4 deletions
+2
View File
@@ -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);
}
+15
View File
@@ -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"]);
}
+9 -4
View File
@@ -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<String, Value>) {
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 {