From 5805cea9b4527201dd1cf5d27833337ed0bbf72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 15:55:59 +0200 Subject: [PATCH] M6: Sketch import: layer tree, transforms, constraints, basic shapes Artboards (and Sketch 2025 frames) become frames with their background (white by default, as Sketch shows them), groups and graphics groups, rectangles with their corner radii, ovals ellipses, bitmaps rectangles with the image, slices slices. Positions accumulate through groups, rotation and flips keep their direction, resizingConstraint bits become constraints. Not yet mapped layer kinds are listed in the import report. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/fixture.rs | 1 - crates/format/src/sketch/mod.rs | 4 +- crates/format/src/sketch/tree.rs | 149 +++++++++++++++++++- crates/format/src/sketch/tree_tests.rs | 181 +++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 crates/format/src/sketch/tree_tests.rs diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index fd3b37ce..231225c9 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -88,7 +88,6 @@ pub(crate) fn page(id: &str, name: &str, layers: Vec) -> Value { with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers)) } -#[allow(dead_code)] // used by the layer tree tests pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers)) } diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 7334606b..a46706db 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,8 @@ mod fixture; #[cfg(test)] mod tests; mod tree; +#[cfg(test)] +mod tree_tests; use std::collections::BTreeSet; @@ -67,7 +69,7 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result } let archive = Archive::open(bytes, max_member)?; let max_nodes = crate::fig::layer_budget(bytes.len()); - let map = tree::to_rest(&archive, name); + let map = tree::to_rest(&archive, name, max_nodes); if map.too_old { return Err(FormatError::Io(TOO_OLD.into())); } diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 8b5fc0c8..59e3d637 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -1,12 +1,16 @@ //! Sketch pages and layers → REST API file JSON nodes. use figcraft_doc::VectorNetwork; +use figcraft_doc::geom::{Affine, Rect}; use serde_json::{Map as JsonMap, Value, json}; use super::Archive; /// Deepest layer nesting followed (deeper layers are skipped with a warning). const MAX_DEPTH: usize = 200; +/// Largest size and farthest position kept (the REST importer's own bounds). +const MAX_SIZE: f64 = 1e6; +const MAX_COORD: f64 = 1e7; /// The REST JSON of a Sketch file, with what the mapping noticed. pub(crate) struct Map { @@ -23,6 +27,9 @@ pub(crate) struct Cx { pub warnings: Vec, pub networks: Vec>, pub too_old: bool, + /// Layers mapped so far, and the most the file may give. + pub nodes: usize, + pub max_nodes: usize, } impl Cx { @@ -35,15 +42,15 @@ impl Cx { } /// Map the pages of `archive` to a REST file JSON named `name`. -pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map { - let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false }; +pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { + let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false, nodes: 0, max_nodes }; let mut canvases = Vec::new(); for (id, page) in &archive.pages { let mut node = JsonMap::new(); node.insert("type".into(), json!("CANVAS")); node.insert("id".into(), json!(id)); node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); - let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, 0)).collect(); + let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, Affine::IDENTITY, 0, true)).collect(); node.insert("children".into(), Value::Array(kids)); canvases.push(Value::Object(node)); } @@ -51,8 +58,9 @@ pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map { Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } } -/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. -pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option { +/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. `parent`: +/// the parent layer's transform to the page; `root`: the layer sits on the page itself. +pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { if depth > MAX_DEPTH { cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -62,8 +70,135 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option { return None; } let class = str_field(l, "_class")?; - cx.warn(format!("Sketch {class} layers are not supported yet and were skipped")); - None + let ty = match class { + "artboard" | "frame" => "FRAME", + "group" | "graphic" => "GROUP", + "rectangle" | "bitmap" => "RECTANGLE", + "oval" => "ELLIPSE", + "slice" => "SLICE", + other => { + cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); + return None; + } + }; + if cx.nodes >= cx.max_nodes { + cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + return None; + } + cx.nodes += 1; + let (w, h) = size(l); + let world = parent * local(l, w, h); + let mut node = JsonMap::new(); + node.insert("type".into(), json!(ty)); + if let Some(id) = str_field(l, "do_objectID") { + node.insert("id".into(), json!(id)); + } + node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); + node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); + node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); + node.insert("size".into(), json!({"x": w, "y": h})); + let rel = parent.inverse() * world; + let [a, b, c, d, e, f] = rel.as_coeffs(); + node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); + // The page places top-level layers by their transform; deeper ones also say where they end up, + // which tells the importer their transform is relative to their group. + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); + match class { + "artboard" | "frame" => { + let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; + node.insert("clipsContent".into(), json!(clips)); + let background = (bool_field(l, "hasBackgroundColor") == Some(true)).then(|| l.get("backgroundColor").and_then(color)).flatten(); + if class == "artboard" || background.is_some() { + let c = background.unwrap_or_else(|| json!({"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0})); + node.insert("fills".into(), json!([{"type": "SOLID", "color": c}])); + } + } + "rectangle" => corner_radii(l, &mut node), + "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { + Some(r) => { + let paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + node.insert("fills".into(), json!([paint])); + } + None => cx.warn("images stored inside the JSON were skipped"), + }, + _ => {} + } + if matches!(ty, "FRAME" | "GROUP") { + let kids: Vec = layers(l).iter().filter_map(|c| layer(cx, c, world, depth + 1, false)).collect(); + node.insert("children".into(), Value::Array(kids)); + } + Some(Value::Object(node)) +} + +/// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]). +fn size(l: &Value) -> (f64, f64) { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE); + (n("width"), n("height")) +} + +/// A layer's transform within its parent: its frame's position, then its rotation (degrees, +/// counter-clockwise on screen) and flips about its centre. +fn local(l: &Value, w: f64, h: f64) -> Affine { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); + let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0; + let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 }; + let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 }; + Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0)) + * Affine::rotate(-rot.to_radians()) + * Affine::scale_non_uniform(sx, sy) + * Affine::translate((-w / 2.0, -h / 2.0)) +} + +/// REST constraints from Sketch's `resizingConstraint`: six bits, a cleared bit pins that side or +/// fixes that size (right 1, width 2, left 4, bottom 8, height 16, top 32). A layer pinned on +/// neither side keeps its place in proportion (scale) unless its size is fixed (centre). +fn constraints(mask: i64) -> Value { + let pinned = |bit: i64| mask & bit == 0; + let axis = |low: i64, size: i64, high: i64, low_name: &str, high_name: &str, both: &str| match (pinned(low), pinned(high)) { + (true, true) => both.to_string(), + (true, false) => low_name.to_string(), + (false, true) => high_name.to_string(), + (false, false) if pinned(size) => "CENTER".into(), + (false, false) => "SCALE".into(), + }; + json!({ + "horizontal": axis(4, 2, 1, "LEFT", "RIGHT", "LEFT_RIGHT"), + "vertical": axis(32, 16, 8, "TOP", "BOTTOM", "TOP_BOTTOM"), + }) +} + +/// A rectangle's corner radii: per corner from its points (top left, top right, bottom right, +/// bottom left), else its `fixedRadius`. +fn corner_radii(l: &Value, node: &mut JsonMap) { + let radius = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, MAX_SIZE)); + let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let each: Vec = pts.iter().map(|p| radius(p.get("cornerRadius"))).collect(); + if each.len() == 4 && each.iter().any(|r| *r > 0.0) { + node.insert("rectangleCornerRadii".into(), json!(each)); + } else if radius(l.get("fixedRadius")) > 0.0 { + node.insert("cornerRadius".into(), json!(radius(l.get("fixedRadius")))); + } +} + +/// A Sketch colour (`red`, `green`, `blue`, `alpha` in 0–1) as a REST colour. +pub(crate) fn color(c: &Value) -> Option { + let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); + Some(json!({"r": n("red")?, "g": n("green")?, "b": n("blue")?, "a": n("alpha").unwrap_or(1.0)})) +} + +pub(crate) fn bool_field(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_bool) +} + +/// At most 64 characters of a name read from the file, for warnings. +fn clip(s: &str) -> String { + s.chars().take(64).collect() } /// A text layer whose string is a binary property list (Sketch 43–47). diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs new file mode 100644 index 00000000..c9f99558 --- /dev/null +++ b/crates/format/src/sketch/tree_tests.rs @@ -0,0 +1,181 @@ +//! Sketch layers → document layers: kinds, positions, transforms, constraints, visibility. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, HConstraint, NodeKind, VConstraint}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +/// Import one page holding `layers`, with extra archive `files`. +fn open_with(layers: Vec, files: &[(&str, &[u8])]) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], files); + import(&z, "f").unwrap() +} + +fn open(layers: Vec) -> (Document, Vec) { + open_with(layers, &[]) +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn solid(doc: &Document, name: &str) -> Option<[f32; 4]> { + node(doc, name).fills.iter().find_map(|p| match &p.kind { + PaintKind::Solid { color } => Some([color.r, color.g, color.b, p.opacity]), + _ => None, + }) +} + +#[test] +fn artboard_is_frame_with_white_fill_unless_background() { + let plain = artboard("A", "Plain", 0.0, 0.0, 100.0, 50.0, vec![]); + let red = with(artboard("B", "Red", 200.0, 0.0, 100.0, 50.0, vec![]), "hasBackgroundColor", json!(true)); + let red = with(red, "backgroundColor", json!({"_class": "color", "red": 1, "green": 0, "blue": 0, "alpha": 1})); + let (doc, _) = open(vec![plain, red]); + assert!(matches!(node(&doc, "Plain").kind, NodeKind::Frame)); + assert!(node(&doc, "Plain").clip); + assert_eq!(solid(&doc, "Plain"), Some([1.0, 1.0, 1.0, 1.0])); + assert_eq!(solid(&doc, "Red"), Some([1.0, 0.0, 0.0, 1.0])); + let b = doc.world_bounds(node(&doc, "Red").id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (200.0, 0.0, 100.0, 50.0)); +} + +#[test] +fn group_children_positions_accumulate() { + let r = rect("R", 5.0, 5.0, 10.0, 10.0); + let g = group("G", 10.0, 20.0, 15.0, 15.0, vec![r]); + let (doc, _) = open(vec![artboard("A", "Board", 100.0, 100.0, 300.0, 300.0, vec![g])]); + assert!(matches!(node(&doc, "G").kind, NodeKind::Group)); + let b = doc.world_bounds(node(&doc, "R").id).unwrap(); + assert!((b.x0 - 115.0).abs() < 1e-6 && (b.y0 - 125.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn rotation_and_flip() { + let rotated = with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)); + let flipped = with(rect("Flipped", 100.0, 0.0, 40.0, 20.0), "isFlippedHorizontal", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![rotated, flipped])]); + let b = doc.world_bounds(node(&doc, "Turned").id).unwrap(); + assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + assert!((b.center().x - 20.0).abs() < 1e-6 && (b.center().y - 10.0).abs() < 1e-6, "{b:?}"); + // Counter-clockwise on screen: the box's x axis points up. + let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs(); + assert!(t[1] < -0.99, "{t:?}"); + let t = doc.world_transform(node(&doc, "Flipped").id).as_coeffs(); + assert!(t[0] < -0.99, "{t:?}"); + let b = doc.world_bounds(node(&doc, "Flipped").id).unwrap(); + assert!((b.x0 - 100.0).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn resizing_constraint_bits() { + use HConstraint as H; + use VConstraint as V; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + let cases = [ + (63, H::Scale, V::Scale), + (63 - 4 - 32, H::Left, V::Top), + (63 - 1 - 8, H::Right, V::Bottom), + (63 - 1 - 4, H::LeftRight, V::Scale), + (63 - 8 - 32, H::Scale, V::TopBottom), + (63 - 2 - 16, H::Center, V::Center), + (63 - 1 - 2, H::Right, V::Scale), + (0, H::LeftRight, V::TopBottom), + ]; + let layers: Vec = cases + .iter() + .enumerate() + .map(|(i, (mask, _, _))| with(rect(&format!("R{i}"), 0.0, 0.0, 10.0, 10.0), "resizingConstraint", json!(mask))) + .collect(); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]); + for (i, (mask, h, v)) in cases.iter().enumerate() { + let c = node(&doc, &format!("R{i}")).constraints; + assert_eq!((c.horizontal, c.vertical), (*h, *v), "mask {mask}"); + } +} + +#[test] +fn hidden_locked_named() { + let r = with(with(rect("Hidden", 0.0, 0.0, 10.0, 10.0), "isVisible", json!(false)), "isLocked", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]); + let n = node(&doc, "Hidden"); + assert!(!n.visible && n.locked); +} + +#[test] +fn bad_numbers_are_sane() { + let mut nan = rect("Junk", 0.0, 0.0, 10.0, 10.0); + nan["frame"]["width"] = json!("NaN"); + nan["frame"]["x"] = json!(1e300); + let mut neg = rect("Negative", 0.0, 0.0, 10.0, 10.0); + neg["frame"]["height"] = json!(-5); + neg["rotation"] = json!("x"); + let mut none = rect("Frameless", 0.0, 0.0, 10.0, 10.0); + none.as_object_mut().unwrap().remove("frame"); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![nan, neg, none])]); + for n in doc.nodes() { + assert!(n.width.is_finite() && n.height.is_finite() && n.width >= 0.0 && n.height >= 0.0, "{}", n.name); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{}", n.name); + } +} + +#[test] +fn rectangle_corner_radii() { + let mut each = rect("Each", 0.0, 0.0, 40.0, 40.0); + for (i, r) in [1, 2, 3, 4].iter().enumerate() { + each["points"][i]["cornerRadius"] = json!(r); + } + let fixed = with(rect("Fixed", 50.0, 0.0, 40.0, 40.0), "fixedRadius", json!(8)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![each, fixed])]); + assert!(matches!(node(&doc, "Each").kind, NodeKind::Rectangle)); + assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(node(&doc, "Fixed").corners.radii, [8.0; 4]); +} + +#[test] +fn oval_slice_and_bitmap() { + let mut oval = rect("Round", 0.0, 0.0, 30.0, 20.0); + oval["_class"] = json!("oval"); + let mut slice = rect("Cut", 0.0, 0.0, 30.0, 20.0); + slice["_class"] = json!("slice"); + let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/abc123.png"}); + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![oval, slice, bitmap])], &[("images/abc123.png", &png)]); + assert!(matches!(node(&doc, "Round").kind, NodeKind::Ellipse { .. })); + assert!(matches!(node(&doc, "Cut").kind, NodeKind::Slice)); + let photo = node(&doc, "Photo"); + assert!(matches!(photo.kind, NodeKind::Rectangle)); + assert!(photo.fills.iter().any(|p| matches!(&p.kind, PaintKind::Image(_)))); + assert_eq!(doc.images.len(), 1); +} + +#[test] +fn unknown_class_is_a_warning() { + let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0); + odd["_class"] = json!("MSSomethingNew"); + let (doc, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![odd])]); + assert!(doc.nodes().all(|n| n.name != "Odd")); + assert!(warnings.iter().any(|w| w.contains("MSSomethingNew")), "{warnings:?}"); +} + +#[test] +fn sketch_2025_frames_and_graphics() { + let mut frame = artboard("F", "Frame", 0.0, 0.0, 100.0, 100.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]); + frame["_class"] = json!("frame"); + frame["clipsContent"] = json!(false); + let mut graphic = group("Graphic", 10.0, 10.0, 10.0, 10.0, vec![rect("Leaf", 0.0, 0.0, 10.0, 10.0)]); + graphic["_class"] = json!("graphic"); + frame["layers"].as_array_mut().unwrap().push(graphic); + let (doc, _) = open(vec![frame]); + assert!(matches!(node(&doc, "Frame").kind, NodeKind::Frame)); + assert!(!node(&doc, "Frame").clip); + assert!(matches!(node(&doc, "Graphic").kind, NodeKind::Group)); +}