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 <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 8f32e4f504
commit ff9d966ea6
3 changed files with 110 additions and 2 deletions
+49 -2
View File
@@ -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<u8> {
sketch_with(app_version, pages, &[])
}
/// [`sketch`] with `layers` on the first page.
fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec<u8> {
let refs: Vec<Value> = 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<u8> {
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);
}
+35
View File
@@ -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 { .. }));
}
+26
View File
@@ -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<Value> {
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())