From 31698164acacf2348a70beea39e8a2d5cf9b97cf 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:46:21 +0200 Subject: [PATCH] M6: Sketch import: prototype links, export settings, layout grids Layers linked to artboards navigate there on click (back links go back, slide animations slide), hotspots become transparent frames with their link, flow-home artboards start a flow; export options become export settings (scale, width or height, suffix); artboard layout and square grids become layout grids, which instances show like their masters. Smart Layout, stacks and locked overrides are noted once per file. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 2 + crates/format/src/sketch/proto.rs | 117 ++++++++++++++++++++++ crates/format/src/sketch/proto_tests.rs | 125 ++++++++++++++++++++++++ crates/format/src/sketch/symbols.rs | 4 + crates/format/src/sketch/tree.rs | 20 ++++ 6 files changed, 271 insertions(+) create mode 100644 crates/format/src/sketch/proto.rs create mode 100644 crates/format/src/sketch/proto_tests.rs diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index db0421f8..3132e658 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,9 @@ mod fixture; mod path; #[cfg(test)] mod path_tests; +mod proto; +#[cfg(test)] +mod proto_tests; mod shared; #[cfg(test)] mod shared_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 0cce6e08..8fbe84cd 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); m.remove("styles"); super::tree::styles(cx, l, cw, ch, m); + super::tree::extras(cx, l, ch, m); } super::tree::mask(l, m); } @@ -170,6 +171,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, node.insert("children".into(), Value::Array(chain)); super::tree::mask(l, &mut node); super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); Some(Value::Object(node)) } diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs new file mode 100644 index 00000000..afa6df48 --- /dev/null +++ b/crates/format/src/sketch/proto.rs @@ -0,0 +1,117 @@ +//! Sketch prototype links, hotspots, export options and layout grids → REST. +//! +//! A layer's `flow` links it to an artboard (`destinationArtboardID`, or `back`) with an +//! `animationType` (0 none, 1–4 slide from the right, left, bottom, top); hotspots are layers that +//! only carry a link; `isFlowHome` marks an artboard where a prototype starts. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, bool_field, str_field}; + +/// Most export settings read from one layer. +const MAX_EXPORTS: usize = 64; + +/// The click interaction of layer `l`'s `flow`, if it has one. +pub(crate) fn interactions(l: &Value, node: &mut JsonMap) { + let Some(flow) = l.get("flow") else { return }; + let Some(dest) = str_field(flow, "destinationArtboardID").filter(|d| !d.is_empty()) else { return }; + let action = if dest == "back" { + json!({"type": "BACK"}) + } else { + let direction = match flow.get("animationType").and_then(Value::as_i64) { + Some(1) => Some("LEFT"), + Some(2) => Some("RIGHT"), + Some(3) => Some("TOP"), + Some(4) => Some("BOTTOM"), + _ => None, + }; + let transition = + direction.map_or(Value::Null, |d| json!({"type": "SLIDE_IN", "direction": d, "duration": 0.3, "easing": {"type": "EASE_IN_AND_OUT"}})); + json!({"type": "NODE", "destinationId": dest, "navigation": "NAVIGATE", "transition": transition}) + }; + node.insert("interactions".into(), json!([{"trigger": {"type": "ON_CLICK"}, "actions": [action]}])); +} + +/// The artboards of a page marked as where a prototype starts, as REST `flowStartingPoints`. +pub(crate) fn starting_points(page: &Value) -> Vec { + super::tree::layers(page) + .iter() + .filter(|a| bool_field(a, "isFlowHome") == Some(true)) + .filter_map(|a| Some(json!({"nodeId": str_field(a, "do_objectID")?, "name": str_field(a, "name").unwrap_or("Flow")}))) + .collect() +} + +/// Layer `l`'s export options as REST `exportSettings`. +pub(crate) fn export_settings(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let formats = l.get("exportOptions").and_then(|o| o.get("exportFormats")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut out = Vec::new(); + for f in formats.iter().take(MAX_EXPORTS) { + let format = match str_field(f, "fileFormat").unwrap_or("png").to_ascii_lowercase().as_str() { + "png" => "PNG", + "jpg" | "jpeg" => "JPG", + "svg" => "SVG", + "pdf" => "PDF", + "webp" => "WEBP", + _ => { + cx.warn("export settings in formats FigCraft doesn't export (TIFF, EPS, HEIC) were skipped"); + continue; + } + }; + let num = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let constraint = match (f.get("visibleScaleType").and_then(Value::as_i64), num("absoluteSize")) { + (Some(1), Some(s)) => json!({"type": "WIDTH", "value": s.min(16_384.0)}), + (Some(2), Some(s)) => json!({"type": "HEIGHT", "value": s.min(16_384.0)}), + _ => json!({"type": "SCALE", "value": num("scale").unwrap_or(1.0).min(64.0)}), + }; + let name: String = str_field(f, "name").unwrap_or("").chars().take(200).collect(); + if f.get("namingScheme").and_then(Value::as_i64) == Some(1) && !name.is_empty() { + cx.warn("export names Sketch puts before the layer name come after it"); + } + out.push(json!({"format": format, "suffix": name, "constraint": constraint})); + } + if !out.is_empty() { + node.insert("exportSettings".into(), Value::Array(out)); + } +} + +/// An artboard's layout grid (columns, rows) and square grid as REST `layoutGrids`. +pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) { + let mut out = Vec::new(); + if let Some(g) = l.get("layout").filter(|g| bool_field(g, "isEnabled") != Some(false)) { + let num = |k: &str| g.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0); + if bool_field(g, "drawVertical") != Some(false) + && let (Some(count), Some(width)) = (num("numberOfColumns"), num("columnWidth")) + { + out.push(json!({"pattern": "COLUMNS", "alignment": "MIN", "count": count.min(1000.0), "sectionSize": width.min(1e5), + "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": true})); + } + if bool_field(g, "drawHorizontal") == Some(true) { + let gutter = num("gutterHeight").unwrap_or(0.0).min(1e5); + let section = (gutter * num("rowHeightMultiplication").unwrap_or(1.0)).min(1e5); + if section > 0.0 { + let count = (h / (section + gutter)).floor().clamp(1.0, 1000.0); + out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": true})); + } + } + } + if let Some(g) = l.get("grid").filter(|g| bool_field(g, "isEnabled") != Some(false)) + && let Some(size) = g.get("gridSize").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) + { + out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": true})); + } + if !out.is_empty() { + node.insert("layoutGrids".into(), Value::Array(out)); + } +} + +/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout and Stacks, +/// and overrides a master locks. +pub(crate) fn notes(cx: &mut Cx, l: &Value) { + if l.get("groupLayout").and_then(|g| str_field(g, "_class")).is_some_and(|c| c != "MSImmutableFreeformGroupLayout") { + cx.warn("Smart Layout and stacks were imported as laid out; they don't resize with their content"); + } + let locked = l.get("overrideProperties").and_then(Value::as_array).is_some_and(|p| p.iter().any(|o| bool_field(o, "canOverride") == Some(false))); + if locked { + cx.warn("overrides Sketch symbols locked can be changed in FigCraft"); + } +} diff --git a/crates/format/src/sketch/proto_tests.rs b/crates/format/src/sketch/proto_tests.rs new file mode 100644 index 00000000..be9b835d --- /dev/null +++ b/crates/format/src/sketch/proto_tests.rs @@ -0,0 +1,125 @@ +//! Sketch prototype links, hotspots, export options, layout grids, smart layout and locked +//! overrides. + +use figcraft_doc::{Document, ExportFormat, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn screens(button: Value) -> Vec { + vec![artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![button]), artboard("A2", "Detail", 200.0, 0.0, 100.0, 100.0, vec![])] +} + +#[test] +fn flow_navigates() { + let b = with( + rect("Go", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 1}), + ); + let (doc, _) = open(screens(b)); + let go = node(&doc, "Go"); + assert_eq!(go.interactions.len(), 1, "{:?}", go.interactions); + let detail = node(&doc, "Detail").id; + let text = format!("{:?}", go.interactions[0]); + assert!(text.contains(&format!("{detail:?}")), "{text}"); +} + +#[test] +fn flow_back() { + let b = with( + rect("Back", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "back", "animationType": 0}), + ); + let (doc, _) = open(screens(b)); + let text = format!("{:?}", node(&doc, "Back").interactions); + assert!(text.contains("Back"), "{text}"); +} + +#[test] +fn hotspot_is_transparent_frame_with_interaction() { + let mut h = with( + rect("Hot", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 0}), + ); + h["_class"] = json!("MSImmutableHotspotLayer"); + let (doc, _) = open(screens(h)); + let n = node(&doc, "Hot"); + assert!(matches!(n.kind, NodeKind::Frame)); + assert!(n.fills.is_empty()); + assert_eq!(n.interactions.len(), 1); +} + +#[test] +fn flow_home_is_starting_point() { + let mut home = artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![]); + home["isFlowHome"] = json!(true); + let (doc, _) = open(vec![home]); + let page = doc.node(doc.pages[0]).unwrap(); + let NodeKind::Page { flows, .. } = &page.kind else { panic!("not a page") }; + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].start, node(&doc, "Home").id); +} + +#[test] +fn export_options() { + let mut r = rect("Icon", 0.0, 0.0, 20.0, 20.0); + r["exportOptions"] = json!({"_class": "exportOptions", "exportFormats": [ + {"_class": "exportFormat", "fileFormat": "png", "scale": 2, "name": "@2x", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0}, + {"_class": "exportFormat", "fileFormat": "svg", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0}, + {"_class": "exportFormat", "fileFormat": "png", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 64, "visibleScaleType": 1}, + ]}); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]); + let e = &node(&doc, "Icon").exports; + assert_eq!(e.len(), 3); + assert_eq!(e[0].format, ExportFormat::Png); + assert_eq!(e[0].suffix, "@2x"); + assert_eq!(e[1].format, ExportFormat::Svg); + let text = format!("{:?}", e[2]); + assert!(text.contains("64"), "{text}"); +} + +#[test] +fn layout_and_square_grids() { + let mut a = artboard("A", "Board", 0.0, 0.0, 1200.0, 800.0, vec![]); + a["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 84, "gutterWidth": 16, "horizontalOffset": 24, "numberOfColumns": 12, + "totalWidth": 1152, "guttersOutside": false, "drawVertical": true, "drawHorizontal": false, "gutterHeight": 8, "rowHeightMultiplication": 3}); + a["grid"] = json!({"_class": "simpleGrid", "isEnabled": true, "gridSize": 8, "thickGridTimes": 0}); + let (doc, _) = open(vec![a]); + let grids = format!("{:?}", node(&doc, "Board").grids); + assert_eq!(node(&doc, "Board").grids.len(), 2, "{grids}"); + assert!(grids.contains("12") && grids.contains("16"), "{grids}"); +} + +#[test] +fn smart_layout_warns_once() { + let g = |id: &str| { + with( + group(id, 0.0, 0.0, 10.0, 10.0, vec![rect(&format!("{id}r"), 0.0, 0.0, 10.0, 10.0)]), + "groupLayout", + json!({"_class": "MSImmutableInferredGroupLayout", "axis": 0}), + ) + }; + let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![g("G1"), g("G2"), g("G3")])]); + assert_eq!(warnings.iter().filter(|w| w.contains("Smart Layout")).count(), 1, "{warnings:?}"); +} + +#[test] +fn locked_overrides_are_noted() { + let mut m = master("M", "S", "Locked", 50.0, 20.0, vec![text("L", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, [0.0, 0.0, 0.0, 1.0])])]); + m["overrideProperties"] = json!([{"_class": "MSImmutableOverrideProperty", "overrideName": "L_stringValue", "canOverride": false}]); + let (_, warnings) = open(vec![m]); + assert!(warnings.iter().any(|w| w.contains("locked")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index 15079702..d25a6b56 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -102,6 +102,7 @@ pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, ro if let Some(f) = master_fills(l) { node.insert("fills".into(), f); } + super::tree::extras(cx, l, h, &mut node); let sid = str_field(l, "symbolID").unwrap_or("").to_string(); cx.chain.push(sid); let kids = children(cx, l, world, (w, h), depth, false); @@ -139,6 +140,9 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo if let Some(st) = l.get("style") { super::style::apply(cx, st, w, h, &mut node); } + super::tree::extras(cx, l, h, &mut node); + // An instance shows its master's grids (else they'd differ, and count as an override). + super::proto::layout_grids(&m, h, &mut node); let overrides = overrides(l); let mut copy = m; if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 481eec70..d63afa0f 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -75,6 +75,10 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true); node.insert("children".into(), Value::Array(kids)); + let starts = super::proto::starting_points(page); + if !starts.is_empty() { + node.insert("flowStartingPoints".into(), Value::Array(starts)); + } canvases.push(Value::Object(node)); } let foreign = std::mem::take(&mut cx.symbols.foreign); @@ -120,6 +124,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: "slice" => "SLICE", "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", "text" => "TEXT", + "MSImmutableHotspotLayer" | "hotspot" => "FRAME", other => { cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); return None; @@ -160,6 +165,11 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: _ => {} } styles(cx, l, w, h, &mut node); + extras(cx, l, h, &mut node); + if class == "MSImmutableHotspotLayer" || class == "hotspot" { + node.insert("clipsContent".into(), json!(false)); + node.remove("fills"); + } if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); @@ -175,6 +185,16 @@ pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap< cx.shared.link(&cx.style_defs, l, node); } +/// Layer `l`'s prototype link, export settings, layout grids (frames) and notes. +pub(crate) fn extras(cx: &mut Cx, l: &Value, h: f64, node: &mut JsonMap) { + super::proto::interactions(l, node); + super::proto::export_settings(cx, l, node); + if matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + super::proto::layout_grids(l, h, node); + } + super::proto::notes(cx, l); +} + /// 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) {