From ff9d966ea679803c9203bb14978e3c2df0808aa2 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:18:34 +0200 Subject: [PATCH] M6: Sketch import: masks that show their own style draw it Sketch draws a clipping mask with its fills, borders and effects under what it clips; a FigCraft mask only clips. A plain copy of such a mask goes underneath it, so a light avatar circle with a white icon in it no longer vanishes on a white artboard. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sketch_import_tests.rs | 51 +++++++++++++++++++++++- crates/format/src/sketch/style_tests.rs | 35 ++++++++++++++++ crates/format/src/sketch/tree.rs | 26 ++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index cb3bf2f1..09959bb6 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -10,6 +10,11 @@ use crate::Session; /// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { + sketch_with(app_version, pages, &[]) +} + +/// [`sketch`] with `layers` on the first page. +fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec { let refs: Vec = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect(); let mut out = Vec::new(); { @@ -19,9 +24,10 @@ fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); z.start_file("meta.json", o).unwrap(); z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": 103}).to_string().as_bytes()).unwrap(); - for (id, name) in pages { + for (i, (id, name)) in pages.iter().enumerate() { + let ls = if i == 0 { layers.to_vec() } else { Vec::new() }; z.start_file(format!("pages/{id}.json"), o).unwrap(); - z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": []}).to_string().as_bytes()).unwrap(); + z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": ls}).to_string().as_bytes()).unwrap(); } z.finish().unwrap(); } @@ -87,3 +93,44 @@ fn old_and_damaged_sketch_files_fail_cleanly() { assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +fn shape(class: &str, name: &str, frame: [f64; 4], extra: Value) -> Value { + let mut l = json!({"_class": class, "do_objectID": name, "name": name, "isVisible": true, "resizingConstraint": 63, + "frame": {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]}, "style": {"fills": []}}); + for (k, v) in extra.as_object().unwrap() { + l[k] = v.clone(); + } + l +} + +fn fill(grey: f64) -> Value { + json!({"style": {"fills": [{"isEnabled": true, "fillType": 0, "color": {"red": grey, "green": grey, "blue": grey, "alpha": 1}}]}}) +} + +/// Sketch draws a mask's own fill under what it clips; the clipped layers show inside it. +#[test] +fn sketch_masks_show_their_content() { + let oval = shape("oval", "Ring", [0.0, 0.0, 60.0, 60.0], json!({"booleanOperation": -1, "points": []})); + let mut mask = shape("shapeGroup", "Circle", [0.0, 0.0, 60.0, 60.0], fill(0.8)); + mask["hasClippingMask"] = json!(true); + mask["layers"] = json!([oval]); + let pts = ["{0, 0}", "{1, 0}", "{1, 1}", "{0, 1}"].map(|p| json!({"point": p, "curveFrom": p, "curveTo": p, "curveMode": 1})); + let body = shape("shapePath", "Square", [0.0, 0.0, 20.0, 20.0], json!({"points": pts, "isClosed": true, "booleanOperation": -1})); + let mut content = shape("shapeGroup", "Body", [20.0, 20.0, 20.0, 20.0], fill(0.2)); + content["layers"] = json!([body]); + let icon = shape("group", "Icon", [0.0, 0.0, 60.0, 60.0], json!({"layers": [mask, content]})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[icon])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let icon = doc.nodes().find(|n| n.name == "Icon").unwrap().id; + let pix = figcraft_render::render_node(doc, icon, 1.0).unwrap(); + let px = |x: u32, y: u32| pix.pixel(x, y).unwrap(); + // The square in the middle, dark grey. + let c = px(30, 30); + assert!(c.alpha() > 200 && c.red() < 100, "content: {c:?}"); + // The mask's own fill around it, light grey; outside the circle nothing. + let m = px(30, 8); + assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}"); + assert!(px(2, 2).alpha() < 50); +} diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 6bc02bd8..ee428c88 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -248,3 +248,38 @@ fn shape_group_masks_clip() { let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); assert_eq!(kids, ["Card", "A"]); } + +#[test] +fn masked_icon_keeps_its_geometry() { + let oval = { + let mut o = with(rect("Path", 0.0, 0.0, 67.0, 67.0), "booleanOperation", json!(-1)); + o["_class"] = json!("oval"); + o + }; + let mask = styled( + with(shape_group("Circle", 0.0, 0.0, 67.0, 67.0, vec![oval]), "hasClippingMask", json!(true)), + "fills", + json!([solid_fill(0.9, 0.9, 0.9, 1.0)]), + ); + let p = |id: &str, x: f64, y: f64, w: f64, h: f64, op: i64| { + with(shape_path(id, x, y, w, h, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true), "booleanOperation", json!(op)) + }; + let content = styled( + shape_group( + "Body", + 12.0, + 12.0, + 45.0, + 62.0, + vec![p("P1", 9.0, 9.0, 22.0, 22.0, -1), p("P2", 25.0, 15.0, 20.0, 15.0, 0), p("P3", 0.0, 17.0, 31.0, 45.0, 0)], + ), + "fills", + json!([solid_fill(0.5, 0.5, 0.5, 1.0)]), + ); + let (doc, _) = open(vec![group("Icon", 152.0, 20.0, 67.0, 67.0, vec![mask, content])]); + let b = doc.world_bounds(node(&doc, "Circle").id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (152.0, 20.0, 67.0, 67.0), "mask"); + let b = doc.world_bounds(node(&doc, "Body").id).unwrap(); + assert!((b.x0 - 164.0).abs() < 1e-6 && (b.y0 - 41.0).abs() < 1e-6 && (b.width() - 45.0).abs() < 1e-6, "body {b:?}"); + assert!(matches!(node(&doc, "Circle").kind, NodeKind::Ellipse { .. })); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index bfa59455..c59b8c90 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -247,6 +247,12 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true); layer(cx, c, world, depth + 1, root).map(|n| (breaks, n)) }) + .flat_map(|(breaks, n)| match drawn_mask(&n) { + // Sketch also draws a mask with its own style, under what it clips; a FigCraft mask + // only clips, so a plain copy of it goes underneath. + Some(copy) => vec![(false, copy), (breaks, n)], + None => vec![(breaks, n)], + }) .collect(); let mut out = Vec::new(); let mut i = 0; @@ -278,6 +284,26 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), out } +/// A plain copy of mask `n` when its style shows something (fills, borders or effects). +fn drawn_mask(n: &Value) -> Option { + if n.get("isMask").and_then(Value::as_bool) != Some(true) { + return None; + } + let shows = + |key: &str| n.get(key).and_then(Value::as_array).is_some_and(|l| l.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); + if !(shows("fills") || shows("strokes") || shows("effects")) { + return None; + } + let mut copy = n.clone(); + let m = copy.as_object_mut()?; + m.remove("isMask"); + m.remove("maskType"); + m.remove("id"); + let name = m.get("name").and_then(Value::as_str).unwrap_or("Mask").to_string(); + m.insert("name".into(), json!(format!("{name} (drawn)"))); + Some(copy) +} + /// A text layer whose string is a binary property list (Sketch 43–47). fn is_archived_text(l: &Value) -> bool { l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some())