mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
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:
co-authored by
Claude Opus 5.5
parent
8f32e4f504
commit
ff9d966ea6
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user