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 <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 4be7017ed1
commit 31698164ac
6 changed files with 271 additions and 0 deletions
+3
View File
@@ -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;
+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);
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))
}
+117
View File
@@ -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<String, Value>) {
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<Value> {
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<String, Value>) {
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<String, Value>) {
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");
}
}
+125
View File
@@ -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<Value>) -> (Document, Vec<String>) {
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<Value> {
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:?}");
}
+4
View File
@@ -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) {
+20
View File
@@ -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<String, Value>) {
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<String, Value>) {
if bool_field(l, "hasClippingMask") == Some(true) {