From c639cc2b76de3e5e81daaaccab281298efea8190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 22:51:11 +0200 Subject: [PATCH] M6: Sketch import: Sketch 2025 frames, stacks, tints and corners Groups as frames and graphics, stacks as auto layout (instances laid out by their stack), corners and per-side borders from the style, pins and sizing, tints (layeringType, fillColor overrides), text colour and visibility overrides, blur lists (progressive, glass), inner shadows in the shadow list, angular gradients starting on the right, text sizing, and texts an override changed measured again on open. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 39 +++ crates/engine/src/sketch_import_tests.rs | 75 +++++ crates/format/src/sketch/fixture.rs | 18 ++ crates/format/src/sketch/frames_tests.rs | 385 +++++++++++++++++++++++ crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 59 ++++ crates/format/src/sketch/proto.rs | 7 +- crates/format/src/sketch/stack.rs | 99 ++++++ crates/format/src/sketch/style.rs | 140 ++++++++- crates/format/src/sketch/style_tests.rs | 8 +- crates/format/src/sketch/symbols.rs | 115 ++++++- crates/format/src/sketch/text.rs | 19 +- crates/format/src/sketch/tree.rs | 107 ++++++- docs/file-format-parity.md | 4 +- 14 files changed, 1044 insertions(+), 34 deletions(-) create mode 100644 crates/format/src/sketch/frames_tests.rs create mode 100644 crates/format/src/sketch/stack.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c6f892c1..5de7e0fd 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -564,10 +564,49 @@ fn load_sketch(bytes: &[u8], name: &str) -> Option = doc + .nodes() + .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { + (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, + _ => false, + }) + .map(|n| n.id) + .collect(); + let m = crate::measure::TextMeasure; + // Instances of a stack at another size than their main: the stack at their size, innermost first. + let mut resized: Vec<(usize, NodeId)> = doc + .nodes() + .filter(|n| n.layout.is_some()) + .filter(|n| match &n.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) + } + _ => false, + }) + .map(|n| (doc.ancestors(n.id).len(), n.id)) + .collect(); + resized.sort_by_key(|r| std::cmp::Reverse(r.0)); + for (_, id) in resized { + figcraft_layout::layout_frame(doc, id, &m); + } + for id in changed { + if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + figcraft_layout::set_size(doc, id, w, h); + } + figcraft_layout::layout_up(doc, id, &m); + } +} + /// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from /// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and /// case. diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index d78db77c..5dc4b31a 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -326,3 +326,78 @@ fn sketch_text_keeps_the_platform_tracking() { let ls = td.style.letter_spacing.resolve(13.0); assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}"); } + +/// A text an override changed is measured again, and the stack around it re-flows: a hugging chip +/// grows to its new label. +#[test] +fn sketch_overridden_text_reflows_its_stack() { + let font = json!({"name": "Helvetica", "size": 12}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let label = shape( + "text", + "Label", + [20.0, 4.0, 14.0, 16.0], + json!({"attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let dot = shape("oval", "Dot", [8.0, 9.0, 6.0, 6.0], json!({"points": []})); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 0, "alignItems": 1, "allGuttersGap": 6, "stackingOrder": 0}); + let master = shape( + "symbolMaster", + "Chip", + [0.0, 0.0, 44.0, 24.0], + json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1, + "leftPadding": 8, "rightPadding": 10, "topPadding": 4, "bottomPadding": 4, "layers": [label, dot]}), + ); + let ov = json!([{"_class": "overrideValue", "overrideName": "Label_stringValue", "value": "A much longer label here"}]); + let inst = shape( + "symbolInstance", + "Long", + [0.0, 100.0, 160.0, 24.0], + json!({"symbolID": "S", "overrideValues": ov, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let inst = doc.nodes().find(|n| n.name == "Long").unwrap().id; + let label = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Label")).unwrap(); + let dot = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Dot")).unwrap(); + let (i, l, d) = (doc.world_bounds(inst).unwrap(), doc.world_bounds(label).unwrap(), doc.world_bounds(dot).unwrap()); + assert!(l.x1 - l.x0 > 60.0, "the label was measured: {l:?}"); + assert!((d.x0 - i.x0 - 8.0).abs() < 0.5, "the dot after the padding: {d:?} in {i:?}"); + assert!((l.x0 - d.x1 - 6.0).abs() < 0.5, "the label after the gap: {l:?}"); + assert!((i.x1 - l.x1 - 10.0).abs() < 0.5, "the chip hugs: {i:?} {l:?}"); +} + +/// An instance sized apart from its master lays its master's stack out at its own size: a wide +/// button keeps its label and icon together in the middle. +#[test] +fn sketch_resized_stack_instances_reflow() { + let a = shape("rectangle", "A", [22.0, 12.0, 40.0, 20.0], json!({"points": []})); + let b = shape("rectangle", "B", [70.0, 12.0, 20.0, 20.0], json!({"points": []})); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 1, "alignItems": 1, "allGuttersGap": 8, "stackingOrder": 0}); + let master = shape( + "symbolMaster", + "Button", + [0.0, 0.0, 112.0, 44.0], + json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1, + "leftPadding": 22, "rightPadding": 22, "topPadding": 12, "bottomPadding": 12, "layers": [b, a]}), + ); + let inst = shape( + "symbolInstance", + "Wide", + [0.0, 100.0, 342.0, 44.0], + json!({"symbolID": "S", "overrideValues": [], "horizontalSizing": 0, "verticalSizing": 1}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let inst = doc.nodes().find(|n| n.name == "Wide").unwrap().id; + let at = |name: &str| doc.world_bounds(doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == name)).unwrap()).unwrap(); + let (a, b) = (at("A"), at("B")); + // 40 + 8 + 20 = 68 wide, centred in 342: from 137. + assert!((a.x0 - 137.0).abs() < 0.5, "{a:?}"); + assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}"); + assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6); +} diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index ac426f0e..19d23eca 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -186,3 +186,21 @@ pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64 Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect()); l } + +/// A Sketch 2025+ frame (a group that behaves as a frame) holding `layers`. +pub(crate) fn frame_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + let mut l = group(id, x, y, w, h, layers); + l["groupBehavior"] = json!(1); + l["clippingBehavior"] = json!(0); + l +} + +/// `l` as a stack (Sketch 2025+): `direction` 0 row, 1 column. +pub(crate) fn stack(mut l: Value, direction: i64, justify: i64, align: i64, gap: f64, padding: [f64; 4]) -> Value { + l["groupLayout"] = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": direction, "justifyContent": justify, "alignItems": align, + "allGuttersGap": gap, "crossAxisGutterGap": 0, "wrappingEnabled": false, "alignContent": 0, "stackingOrder": 0, "bordersAffectLayout": false}); + for (k, v) in ["leftPadding", "topPadding", "rightPadding", "bottomPadding"].iter().zip(padding) { + l[*k] = json!(v); + } + l +} diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs new file mode 100644 index 00000000..aadc4b5f --- /dev/null +++ b/crates/format/src/sketch/frames_tests.rs @@ -0,0 +1,385 @@ +//! Sketch 2025 and newer: frames and graphics (groups with a `groupBehavior`), stacks, corners in +//! the style, pins and sizing, border sides. + +use figcraft_doc::{Document, LayoutMode, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("2026.3.1", 196, 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}")) +} + +#[test] +fn frames_graphics_and_clipping() { + let mut clip_none = frame_group("Loose", 0.0, 0.0, 50.0, 50.0, vec![]); + clip_none["clippingBehavior"] = json!(2); + let mut graphic = frame_group("Icon", 100.0, 0.0, 24.0, 24.0, vec![]); + graphic["groupBehavior"] = json!(2); + let mut plain = group("Plain", 200.0, 0.0, 10.0, 10.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]); + plain["groupBehavior"] = json!(0); + let mut filled = frame_group("Filled", 300.0, 0.0, 50.0, 50.0, vec![]); + filled["style"]["fills"] = json!([solid_fill(1.0, 0.0, 0.0, 1.0)]); + let (doc, _) = open(vec![frame_group("Screen", 0.0, 100.0, 390.0, 844.0, vec![]), clip_none, graphic, plain, filled]); + assert!(matches!(node(&doc, "Screen").kind, NodeKind::Frame) && node(&doc, "Screen").clip); + assert!(!node(&doc, "Loose").clip); + assert!(matches!(node(&doc, "Icon").kind, NodeKind::Frame) && node(&doc, "Icon").clip); + assert!(matches!(node(&doc, "Plain").kind, NodeKind::Group)); + assert_eq!(node(&doc, "Filled").fills.len(), 1); + // Frames show what their style says: no white background of their own. + assert!(node(&doc, "Screen").fills.is_empty()); +} + +#[test] +fn corners_from_the_style() { + let mut a = rect("Each", 0.0, 0.0, 40.0, 40.0); + a["style"]["corners"] = + json!({"_class": "MSImmutableStyleCorners", "radii": [1, 2, 3, 4], "style": 0, "smoothing": 0.6, "prefersConcentric": false}); + let mut b = rect("Smooth", 50.0, 0.0, 40.0, 40.0); + b["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 1, "smoothing": 0.6, "prefersConcentric": false}); + let mut f = frame_group("Card", 100.0, 0.0, 100.0, 100.0, vec![]); + f["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [16, 16, 16, 16], "style": 0, "smoothing": 0.6}); + let (doc, _) = open(vec![a, b, f]); + assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(node(&doc, "Smooth").corners.radii, [12.0; 4]); + assert!((node(&doc, "Smooth").corners.smoothing - 0.6).abs() < 1e-9); + assert_eq!(node(&doc, "Each").corners.smoothing, 0.0); + assert_eq!(node(&doc, "Card").corners.radii, [16.0; 4]); +} + +#[test] +fn stacks_become_auto_layout() { + // A row: Sketch lists the first item last (on top); justify evenly, centred, gap 8. + let a = rect("First", 0.0, 0.0, 20.0, 20.0); + let b = with(rect("Second", 0.0, 0.0, 30.0, 10.0), "horizontalSizing", json!(2)); + let mut free = rect("Free", 0.0, 0.0, 5.0, 5.0); + free["flexItem"] = json!({"_class": "MSImmutableFlexItem", "ignoreLayout": true, "alignSelf": 5, "preserveSpaceWhenHidden": false}); + let mut row = stack(frame_group("Row", 0.0, 0.0, 200.0, 40.0, vec![free, b, a]), 0, 5, 1, 8.0, [4.0, 6.0, 10.0, 2.0]); + row["horizontalSizing"] = json!(0); + row["verticalSizing"] = json!(1); + let col = stack(frame_group("Column", 0.0, 100.0, 100.0, 100.0, vec![rect("One", 0.0, 0.0, 10.0, 10.0)]), 1, 3, 3, 0.0, [0.0; 4]); + let (doc, _) = open(vec![row, col]); + let r = node(&doc, "Row"); + let l = r.layout.as_ref().expect("auto layout"); + assert_eq!(l.mode, LayoutMode::Horizontal); + assert_eq!(l.gap, 8.0); + assert_eq!(l.padding, [6.0, 10.0, 2.0, 4.0], "top, right, bottom, left"); + assert!(l.reverse_z, "the first item is on top, as in Sketch"); + let text = format!("{l:?}"); + assert!(text.contains("SpaceEvenly") || text.contains("Evenly"), "{text}"); + let order: Vec<&str> = r.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(order, ["First", "Second", "Free"], "flow order is the reverse of Sketch's list"); + assert!(node(&doc, "Free").child_layout.absolute); + assert_eq!(node(&doc, "Second").child_layout.sizing_h, figcraft_doc::Sizing::Fill); + assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug); + let c = node(&doc, "Column").layout.as_ref().unwrap(); + assert_eq!(c.mode, LayoutMode::Vertical); + assert!(format!("{c:?}").contains("SpaceBetween"), "{c:?}"); +} + +/// A frame resized as an instance's layer follows its pins and sizing (Sketch 2025+ keep them in +/// `horizontalPins`/`horizontalSizing`, `resizingConstraint` stays 63). +#[test] +fn pins_and_sizing_resize_instances() { + let mut r = rect("R", 20.0, 30.0, 100.0, 40.0); + r["horizontalPins"] = json!(1); // left + r["horizontalSizing"] = json!(0); // fixed + r["verticalPins"] = json!(0); + r["verticalSizing"] = json!(3); // relative + let mut m = master("M", "S", "Box", 200.0, 100.0, vec![r]); + m["groupBehavior"] = json!(1); + let (doc, _) = { + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + import(&z, "f").unwrap() + }; + let inst = doc.nodes().find(|n| n.name == "I").unwrap(); + let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap(); + let b = doc.world_bounds(r.id).unwrap(); + assert!((b.x0 - 20.0).abs() < 0.5 && (b.width() - 100.0).abs() < 0.5, "{b:?}"); + assert!((b.y0 - 18.0).abs() < 0.5 && (b.height() - 24.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn graphics_scale_their_layers() { + let mut r = rect("R", 20.0, 30.0, 100.0, 40.0); + r["horizontalPins"] = json!(5); + r["horizontalSizing"] = json!(0); + let mut m = master("M", "S", "Glyph", 200.0, 100.0, vec![r]); + m["groupBehavior"] = json!(2); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 400.0, 200.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + let (doc, _) = import(&z, "f").unwrap(); + let inst = doc.nodes().find(|n| n.name == "I").unwrap(); + let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap(); + let b = doc.world_bounds(r.id).unwrap(); + assert!((b.x0 - 40.0).abs() < 0.5 && (b.width() - 200.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn border_sides() { + let r = styled( + rect("Sided", 0.0, 0.0, 40.0, 40.0), + "borders", + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 8, + "sides": {"_class": "MSImmutableStyleBorderSides", "leftInset": 7, "rightInset": 7, "topInset": 0, "bottomInset": 5}}]), + ); + // A side is as thick as the border less its inset. + let (doc, _) = open(vec![r]); + assert_eq!(node(&doc, "Sided").stroke.sides, Some([8.0, 1.0, 3.0, 1.0])); +} + +#[test] +fn swatches_bind_solid_fills_only() { + let mut d = document(&["P"]); + d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW", "name": "Brand", "value": color(0.0, 0.0, 1.0, 1.0)}]}); + let mut g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 1.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]}}); + g["color"]["swatchID"] = json!("SW"); + let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![styled(rect("G", 0.0, 0.0, 10.0, 10.0), "fills", json!([g]))])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(!warnings.iter().any(|w| w.contains("not supported")), "{warnings:?}"); +} + +/// Sketch 2025+ masters keep their background, corners and stack in their style and layout, and +/// their instances show the same. +#[test] +fn masters_and_instances_keep_frame_styles_and_stacks() { + let mut m = master("M", "S", "Button", 120.0, 44.0, vec![rect("Icon", 0.0, 0.0, 20.0, 20.0), rect("Label", 0.0, 0.0, 60.0, 20.0)]); + m["groupBehavior"] = json!(1); + m["clippingBehavior"] = json!(2); + m["style"]["fills"] = json!([solid_fill(0.0, 0.0, 1.0, 1.0)]); + m["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [8], "style": 1, "smoothing": 0.6}); + let m = stack(m, 0, 1, 1, 10.0, [16.0, 12.0, 16.0, 12.0]); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 120.0, 44.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + let (doc, _) = import(&z, "f").unwrap(); + let main = doc.nodes().find(|n| matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + for n in [main, inst] { + assert_eq!(n.fills.len(), 1, "{}", n.name); + assert_eq!(n.corners.radii, [8.0; 4], "{}", n.name); + assert!(n.layout.is_some(), "{}", n.name); + assert!(!n.clip, "{}", n.name); + let order: Vec<&str> = n.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(order, ["Label", "Icon"], "{}", n.name); + } +} + +fn solid_of(n: &Node) -> Option<[f32; 3]> { + n.fills.iter().find_map(|p| match &p.kind { + figcraft_doc::color::PaintKind::Solid { color } => Some([color.r, color.g, color.b]), + _ => None, + }) +} + +fn open_symbols(layers: Vec, masters: Vec) -> Document { + let z = sketch_zip("2026.3.1", 196, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap().0 +} + +fn in_instance<'d>(doc: &'d Document, inst: &str, name: &str) -> &'d Node { + let i = doc.nodes().find(|n| n.name == inst && matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no {inst}")); + doc.subtree(i.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name} in {inst}")) +} + +/// A graphic's fill tints its layers (it's no background); an instance's fill, or a `fillColor` +/// override, tints the instance's layers. +#[test] +fn fills_of_graphics_and_instances_are_tints() { + let glyph = styled(rect("Glyph", 0.0, 0.0, 24.0, 24.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![glyph]); + icon["groupBehavior"] = json!(2); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + icon["style"]["fills"] = json!([tint]); + let nested = instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[]); + let mut button = master("MB", "SB", "Button", 100.0, 40.0, vec![nested]); + button["groupBehavior"] = json!(1); + let own = instance("Own", "SI", 0.0, 0.0, 24.0, 24.0, &[("Own_fillColor", color(0.0, 0.0, 1.0, 1.0))]); + let via = instance("Via", "SB", 0.0, 100.0, 100.0, 40.0, &[("N_fillColor", color(0.0, 1.0, 0.0, 1.0))]); + let doc = open_symbols(vec![own, via], vec![icon, button]); + let main = doc.nodes().find(|n| n.name == "Icon" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + assert!(main.fills.is_empty(), "a graphic's fill isn't its background"); + let in_main = doc.subtree(main.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "Glyph").unwrap(); + assert_eq!(solid_of(in_main), Some([1.0, 0.0, 0.0])); + assert_eq!(solid_of(in_instance(&doc, "Own", "Glyph")), Some([0.0, 0.0, 1.0])); + assert!(doc.nodes().find(|n| n.name == "Own").unwrap().fills.is_empty()); + assert_eq!(solid_of(in_instance(&doc, "Via", "Glyph")), Some([0.0, 1.0, 0.0])); +} + +#[test] +fn text_color_overrides() { + let label = text("Label", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + let m = master("M", "S", "Button", 100.0, 40.0, vec![label]); + let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_textColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); + let n = in_instance(&doc, "I", "Label"); + let red = |p: &figcraft_doc::color::Paint| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0); + let NodeKind::Text(td) = &n.kind else { panic!("not text") }; + assert!(n.fills.iter().any(red) || td.style.fills.as_ref().is_some_and(|f| f.iter().any(red)), "{:?} {:?}", n.fills, td.style.fills); +} + +#[test] +fn blurs_list() { + let blur = |ty: i64, r: f64| json!({"_class": "blur", "isEnabled": true, "type": ty, "radius": r, "center": "{0.5, 0.5}", "motionAngle": 25}); + let g = styled(rect("Glow", 0.0, 0.0, 40.0, 40.0), "blurs", json!([blur(0, 90.0)])); + let m = styled(rect("Comet", 50.0, 0.0, 40.0, 4.0), "blurs", json!([blur(1, 6.0)])); + let b = styled(rect("Glass", 100.0, 0.0, 40.0, 40.0), "blurs", json!([blur(3, 10.0)])); + let (doc, warnings) = open(vec![g, m, b]); + assert!(matches!(node(&doc, "Glow").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_))); + assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::BackgroundBlur(_))); + assert!(matches!(node(&doc, "Comet").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_)), "motion blur drawn as a layer blur"); + assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); +} + +#[test] +fn text_sizing_2025() { + let mut fixed = text("Fixed", 0.0, 0.0, 100.0, 40.0, "Wraps here", &[(0, 10, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + fixed.as_object_mut().unwrap().remove("textBehaviour"); + fixed["horizontalSizing"] = json!(0); + fixed["verticalSizing"] = json!(1); + let mut fit = text("Fit", 0.0, 50.0, 100.0, 20.0, "Grows", &[(0, 5, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + fit.as_object_mut().unwrap().remove("textBehaviour"); + fit["horizontalSizing"] = json!(1); + fit["verticalSizing"] = json!(1); + let (doc, _) = open(vec![fixed, fit]); + let auto = |n: &str| match &node(&doc, n).kind { + NodeKind::Text(td) => td.auto_resize, + _ => panic!("not text"), + }; + assert_eq!(auto("Fixed"), figcraft_doc::TextAutoResize::Height); + assert_eq!(auto("Fit"), figcraft_doc::TextAutoResize::WidthAndHeight); +} + +#[test] +fn stacks_are_no_smart_layout_note() { + let (_, warnings) = open(vec![stack(frame_group("Row", 0.0, 0.0, 100.0, 40.0, vec![rect("A", 0.0, 0.0, 10.0, 10.0)]), 0, 0, 0, 0.0, [0.0; 4])]); + assert!(!warnings.iter().any(|w| w.contains("Smart Layout")), "{warnings:?}"); +} + +/// Sketch 2025+: a fill with `layeringType` 1 is a tint (of a frame or master too), 0 a background. +#[test] +fn layering_type_decides_tint_or_background() { + let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut tint = solid_fill(0.0, 0.0, 1.0, 1.0); + tint["layeringType"] = json!(1); + let mut bar = master("MB", "SB", "Bar", 100.0, 20.0, vec![dot]); + bar["groupBehavior"] = json!(1); + bar["style"]["fills"] = json!([tint]); + let mut bg = solid_fill(1.0, 0.0, 0.0, 1.0); + bg["layeringType"] = json!(0); + let mut btn = master("MC", "SC", "Btn", 100.0, 20.0, vec![]); + btn["groupBehavior"] = json!(1); + btn["style"]["fills"] = json!([bg]); + let doc = open_symbols(vec![instance("B", "SB", 0.0, 0.0, 100.0, 20.0, &[]), instance("C", "SC", 0.0, 50.0, 100.0, 20.0, &[])], vec![bar, btn]); + let b = doc.nodes().find(|n| n.name == "B").unwrap(); + assert!(b.fills.is_empty(), "a tint isn't a background"); + assert_eq!(solid_of(in_instance(&doc, "B", "Dot")), Some([0.0, 0.0, 1.0])); + assert_eq!(solid_of(doc.nodes().find(|n| n.name == "C").unwrap()), Some([1.0, 0.0, 0.0])); +} + +#[test] +fn inner_shadows_in_the_shadow_list() { + let mut sh = + json!({"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.5)}); + sh["isInnerShadow"] = json!(true); + let (doc, _) = open(vec![styled(rect("Inset", 0.0, 0.0, 40.0, 40.0), "shadows", json!([sh]))]); + assert!(matches!(node(&doc, "Inset").effects[0].kind, figcraft_doc::EffectKind::InnerShadow(_))); +} + +#[test] +fn visibility_overrides() { + let m = master("M", "S", "Input", 100.0, 40.0, vec![rect("Label", 0.0, 0.0, 50.0, 10.0)]); + let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_isVisible", json!("0"))])], vec![m]); + assert!(!in_instance(&doc, "I", "Label").visible); +} + +/// An instance that fits its content hugs it even when its master has a fixed size. +#[test] +fn instance_sizing_is_its_own() { + let m = stack(master("M", "S", "Chip", 60.0, 24.0, vec![rect("A", 0.0, 0.0, 20.0, 20.0)]), 0, 0, 1, 0.0, [0.0; 4]); + let mut i = instance("I", "S", 0.0, 0.0, 60.0, 24.0, &[]); + i["horizontalSizing"] = json!(1); + let doc = open_symbols(vec![i], vec![m]); + let n = doc.nodes().find(|n| n.name == "I").unwrap(); + assert_eq!(n.layout.as_ref().map(|_| format!("{:?}", n.child_layout.sizing_h)), Some("Hug".to_string())); +} + +/// A tint keeps each layer's own opacity. +#[test] +fn tints_keep_opacity() { + let faint = styled(rect("Faint", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 0.25)])); + let mut g = group("Tinted", 0.0, 0.0, 10.0, 10.0, vec![faint]); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + g["groupBehavior"] = json!(0); + g["style"] = json!({"_class": "style", "fills": [tint]}); + let (doc, _) = open(vec![g]); + let f = &node(&doc, "Faint").fills[0]; + let figcraft_doc::color::PaintKind::Solid { color } = &f.kind else { panic!("not solid") }; + assert_eq!((color.r, color.g), (1.0, 0.0)); + assert!((color.a * f.opacity - 0.25).abs() < 1e-3, "{color:?} {}", f.opacity); + assert!(node(&doc, "Tinted").fills.is_empty()); +} + +#[test] +fn glass_and_progressive_blurs() { + let glass = json!({"_class": "blur", "isEnabled": true, "type": 4, "radius": 10, "distortion": 0.6, "depth": 0.7, "center": "{0.5, 0.5}"}); + let prog = json!({"_class": "blur", "isEnabled": true, "type": 3, "radius": 24, "isProgressive": true, "center": "{0.5, 0.5}", + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let (doc, warnings) = open(vec![ + styled(rect("Glass", 0.0, 0.0, 40.0, 40.0), "blurs", json!([glass])), + styled(rect("Prog", 50.0, 0.0, 40.0, 40.0), "blurs", json!([prog])), + ]); + assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::Glass(_)), "{warnings:?}"); + let figcraft_doc::EffectKind::BackgroundBlur(b) = &node(&doc, "Prog").effects[0].kind else { panic!("not a background blur") }; + assert_eq!(b.blur_type, figcraft_doc::BlurType::Progressive); + assert_eq!((b.start_radius, b.radius), (24.0, 0.0)); +} + +/// Angled (2), inside-square (3) and inside-arc (4) corners make the rectangle a path of that outline. +#[test] +fn cut_corners_become_paths() { + let cut = |name: &str, style: i64| { + let mut r = rect(name, 0.0, 0.0, 100.0, 80.0); + r["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [10], "style": style, "smoothing": 0.6}); + r + }; + let (doc, _) = open(vec![cut("Angled", 2), cut("Square", 3), cut("Arc", 4)]); + let net = |n: &str| match &node(&doc, n).kind { + NodeKind::Vector { network } => network.clone(), + other => panic!("{n}: {other:?}"), + }; + let a = net("Angled"); + assert_eq!(a.vertices.len(), 8); + assert!(a.vertices.iter().any(|v| (v.x, v.y) == (0.0, 10.0)) && a.vertices.iter().any(|v| (v.x, v.y) == (10.0, 0.0))); + let s = net("Square"); + assert_eq!(s.vertices.len(), 12); + assert!(s.vertices.iter().any(|v| (v.x, v.y) == (10.0, 10.0)) && s.vertices.iter().any(|v| (v.x, v.y) == (90.0, 70.0))); + let arc = net("Arc"); + assert_eq!(arc.vertices.len(), 8); + assert!(arc.segments.iter().any(|g| g.ta != (0.0, 0.0)), "the corners are arcs"); +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 576dbe06..8d8a465e 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -13,6 +13,8 @@ #[cfg(test)] mod fixture; +#[cfg(test)] +mod frames_tests; mod path; #[cfg(test)] mod path_tests; @@ -22,6 +24,7 @@ mod proto_tests; mod shared; #[cfg(test)] mod shared_tests; +mod stack; mod style; #[cfg(test)] mod style_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 149b4870..24ceca15 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -100,6 +100,65 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) - Some(VectorNetwork { vertices, segments, regions }) } +/// Rectangle `l` with angled (corner style 2), inside-square (3) or inside-arc (4) corners in its +/// style (Sketch 2025 and newer) as an edited rectangle whose points draw that outline, the +/// corners' `radii` (top left, top right, bottom right, bottom left; repeating) being how far +/// each cut reaches along both edges. `None` for any other layer. +pub(crate) fn cut_corners(l: &Value) -> Option { + if l.get("_class").and_then(Value::as_str) != Some("rectangle") || l.get("edited").and_then(Value::as_bool) == Some(true) { + return None; + } + let c = l.get("style")?.get("corners")?; + let style = c.get("style").and_then(Value::as_i64).filter(|s| (2..=4).contains(s))?; + let radii: Vec = c.get("radii")?.as_array()?.iter().filter_map(Value::as_f64).filter(|r| r.is_finite()).take(4).collect(); + if radii.iter().all(|r| *r <= 0.0) { + return None; + } + let (w, h) = size(l); + if w <= 0.0 || h <= 0.0 { + return None; + } + // Arc handles: a quarter circle's. + const K: f64 = 0.552_284_749_8; + let fmt = |(x, y): (f64, f64)| format!("{{{}, {}}}", x / w, y / h); + let curve = |p: (f64, f64), from: Option<(f64, f64)>, to: Option<(f64, f64)>| { + json!({"_class": "curvePoint", "cornerRadius": 0, "point": fmt(p), "curveMode": if from.or(to).is_some() { 4 } else { 1 }, + "hasCurveFrom": from.is_some(), "hasCurveTo": to.is_some(), "curveFrom": fmt(from.unwrap_or(p)), "curveTo": fmt(to.unwrap_or(p))}) + }; + // Corners clockwise from the top left, each with the directions to the edge before and after. + let corners = [ + ((0.0, 0.0), (0.0, 1.0), (1.0, 0.0)), + ((w, 0.0), (-1.0, 0.0), (0.0, 1.0)), + ((w, h), (0.0, -1.0), (-1.0, 0.0)), + ((0.0, h), (1.0, 0.0), (0.0, -1.0)), + ]; + let mut points = Vec::new(); + for (i, ((cx, cy), (px, py), (nx, ny))) in corners.into_iter().enumerate() { + let r = radii.get(i % radii.len()).copied().unwrap_or(0.0).clamp(0.0, w.min(h) / 2.0); + if r <= 0.0 { + points.push(curve((cx, cy), None, None)); + continue; + } + let a = (cx + r * px, cy + r * py); + let b = (cx + r * nx, cy + r * ny); + match style { + 2 => points.extend([curve(a, None, None), curve(b, None, None)]), + 3 => points.extend([curve(a, None, None), curve((cx + r * (px + nx), cy + r * (py + ny)), None, None), curve(b, None, None)]), + // A quarter circle about the corner, bulging into the shape. + _ => points + .extend([curve(a, Some((a.0 + K * r * nx, a.1 + K * r * ny)), None), curve(b, None, Some((b.0 + K * r * px, b.1 + K * r * py)))]), + } + } + let mut out = l.clone(); + out["edited"] = json!(true); + out["isClosed"] = json!(true); + out["points"] = Value::Array(points); + if let Some(st) = out.get_mut("style").and_then(Value::as_object_mut) { + st.remove("corners"); + } + Some(out) +} + /// A path, star, polygon or triangle layer: a star or polygon while its points are still the /// generated ones (same as FigCraft's, within half a percent), else an editable vector. pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs index afa6df48..1a08d268 100644 --- a/crates/format/src/sketch/proto.rs +++ b/crates/format/src/sketch/proto.rs @@ -104,11 +104,12 @@ pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) } } -/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout and Stacks, +/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout, /// 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"); + // Stacks become auto layout; the older Smart Layout doesn't. + if l.get("groupLayout").and_then(|g| str_field(g, "_class")) == Some("MSImmutableInferredGroupLayout") { + cx.warn("Smart Layout was imported as laid out; it doesn't resize with its 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 { diff --git a/crates/format/src/sketch/stack.rs b/crates/format/src/sketch/stack.rs new file mode 100644 index 00000000..a6b2ddb2 --- /dev/null +++ b/crates/format/src/sketch/stack.rs @@ -0,0 +1,99 @@ +//! Sketch 2025+ stacks (`groupLayout` `MSImmutableFlexGroupLayout`) → REST auto layout. +//! +//! The enums follow the order Sketch's plugin API documents them in (checked against files): +//! direction row, column; justify start, center, end, between, around, evenly; align items start, +//! center, end, stretch, none; stacking order first on top, last on top. Sizing (`horizontalSizing`, +//! `verticalSizing`) is fixed, fit (hug), fill, relative. Sketch lists a stack's first item last +//! (on top in the layer list), so the flow order is the reverse of its `layers`. + +use serde_json::{Map as JsonMap, Value, json}; + +/// Whether `l` is a stack. +pub(crate) fn is_stack(l: &Value) -> bool { + l.get("groupLayout").and_then(|g| g.get("_class")).and_then(Value::as_str) == Some("MSImmutableFlexGroupLayout") +} + +/// Make `node` (the REST node of frame `l`, its children already built in Sketch's order) an auto +/// layout frame when `l` is a stack. +pub(crate) fn apply(l: &Value, node: &mut JsonMap) { + if !is_stack(l) || !matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let Some(g) = l.get("groupLayout") else { return }; + let int = |k: &str| g.get(k).and_then(Value::as_i64).unwrap_or(0); + let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(0.0).clamp(0.0, 1e5); + let row = int("flexDirection") == 0; + node.insert("layoutMode".into(), json!(if row { "HORIZONTAL" } else { "VERTICAL" })); + node.insert("itemSpacing".into(), json!(num(g, "allGuttersGap"))); + node.insert( + "primaryAxisAlignItems".into(), + json!(match int("justifyContent") { + 1 => "CENTER", + 2 => "MAX", + 3 => "SPACE_BETWEEN", + 4 => "SPACE_AROUND", + 5 => "SPACE_EVENLY", + _ => "MIN", + }), + ); + let stretch = int("alignItems") == 3; + node.insert( + "counterAxisAlignItems".into(), + json!(match int("alignItems") { + 1 => "CENTER", + 2 => "MAX", + _ => "MIN", + }), + ); + if g.get("wrappingEnabled").and_then(Value::as_bool) == Some(true) { + node.insert("layoutWrap".into(), json!("WRAP")); + node.insert("counterAxisSpacing".into(), json!(num(g, "crossAxisGutterGap"))); + } + for (k, rest) in + [("leftPadding", "paddingLeft"), ("topPadding", "paddingTop"), ("rightPadding", "paddingRight"), ("bottomPadding", "paddingBottom")] + { + node.insert(rest.into(), json!(num(l, k))); + } + node.insert("itemReverseZIndex".into(), json!(int("stackingOrder") == 0)); + node.insert("strokesIncludedInLayout".into(), json!(g.get("bordersAffectLayout").and_then(Value::as_bool) == Some(true))); + // The stack's own sizing: fit hugs its content. + let (main, cross) = if row { ("horizontalSizing", "verticalSizing") } else { ("verticalSizing", "horizontalSizing") }; + let mode = |k: &str| if l.get(k).and_then(Value::as_i64) == Some(1) { "AUTO" } else { "FIXED" }; + node.insert("primaryAxisSizingMode".into(), json!(mode(main))); + node.insert("counterAxisSizingMode".into(), json!(mode(cross))); + // Items: flow order, sizing, absolute ones. + let sources: Vec<&Value> = super::tree::layers(l).iter().collect(); + if let Some(kids) = node.get_mut("children").and_then(Value::as_array_mut) { + // Children were built one per source layer, skipped ones aside: match them by id. + for kid in kids.iter_mut() { + let id = kid.get("id").and_then(Value::as_str).map(str::to_string); + let Some(src) = sources.iter().find(|s| id.is_some() && s.get("do_objectID").and_then(Value::as_str) == id.as_deref()) else { continue }; + let Some(m) = kid.as_object_mut() else { continue }; + item(src, m, row, stretch); + } + kids.reverse(); + } +} + +/// An item's place in its stack: absolute, or sized fixed, hugging or filling. +fn item(src: &Value, m: &mut JsonMap, row: bool, stretch: bool) { + if src.get("flexItem").and_then(|f| f.get("ignoreLayout")).and_then(Value::as_bool) == Some(true) { + m.insert("layoutPositioning".into(), json!("ABSOLUTE")); + return; + } + let sizing = |k: &str| match src.get(k).and_then(Value::as_i64) { + Some(1) => "HUG", + Some(2) => "FILL", + _ => "FIXED", + }; + let (h, v) = (sizing("horizontalSizing"), sizing("verticalSizing")); + m.insert("layoutSizingHorizontal".into(), json!(h)); + m.insert("layoutSizingVertical".into(), json!(v)); + let (main, cross) = if row { (h, v) } else { (v, h) }; + if main == "FILL" { + m.insert("layoutGrow".into(), json!(1)); + } + if cross == "FILL" || stretch { + m.insert("layoutAlign".into(), json!("STRETCH")); + } +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index f13515a6..13320673 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -38,7 +38,8 @@ pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonM /// A style's fills as REST paints. pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { - list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect() + // Tints (`layeringType` 1) colour the layers inside; they aren't paints of the layer. + list(style, "fills").iter().filter(|f| f.get("layeringType").and_then(Value::as_i64) != Some(1)).filter_map(|f| paint(cx, f, w, h)).collect() } /// A style's borders as REST paints. @@ -101,7 +102,10 @@ fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { } }; p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); - if let Some(var) = swatch(cx, f.get("color")) { + // A colour variable binds a plain colour (a gradient's or image's `color` is unused). + if f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0 + && let Some(var) = swatch(cx, f.get("color")) + { p["boundVariables"] = var; } if let Some(ctx) = f.get("contextSettings") { @@ -131,7 +135,8 @@ fn gradient(g: &Value, w: f64, h: f64) -> Option { let across = if w > 0.0 && h > 0.0 { (from.0 - dy * e / w, from.1 + dx * e / h) } else { from }; ("GRADIENT_RADIAL", json!([pt(from), pt(to), pt(across)])) } - 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((0.5, 0.0))])), + // Sketch starts on the right and turns clockwise; from/to don't change it. + 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((1.0, 0.5)), pt((0.5, 1.0))])), _ => ("GRADIENT_LINEAR", json!([pt(from), pt(to)])), }; Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops})) @@ -167,6 +172,18 @@ fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap = o .get("dashPattern") @@ -218,6 +235,8 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { let mut out = Vec::new(); for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { for s in list(style, key) { + // Sketch 2025 and newer keep inner shadows in the one list, marked. + let ty = if s.get("isInnerShadow").and_then(Value::as_bool) == Some(true) { "INNER_SHADOW" } else { ty }; let mut c = s.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5})); let o = s.get("contextSettings").and_then(|c| num(c, "opacity")).unwrap_or(1.0).clamp(0.0, 1.0); c["a"] = json!(c["a"].as_f64().unwrap_or(1.0) * o); @@ -231,18 +250,54 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { })); } } - if let Some(b) = style.get("blur").filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { + // One `blur` (older files) or a list of `blurs` (Sketch 2025 and newer). + let one = style.get("blur").into_iter(); + let many = style.get("blurs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().take(MAX_LIST); + for b in one.chain(many).filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6); match b.get("type").and_then(Value::as_i64).unwrap_or(0) { 0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})), - 3 => out.push(json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius})), - _ => cx.warn("motion and zoom blurs were skipped"), + 3 => out.push(background_blur(b, radius)), + 4 => out.push(json!({ + "type": "GLASS", + "visible": true, + "radius": radius, + "refraction": num(b, "distortion").unwrap_or(0.5).clamp(0.0, 1.0), + "depth": num(b, "depth").unwrap_or(0.5).clamp(0.0, 1.0), + })), + _ => { + // Motion and zoom blurs: a plain blur of half the radius softens about as much. + cx.warn("motion and zoom blurs were drawn as plain blurs"); + out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius / 2.0})); + } } } out.truncate(MAX_LIST); out } +/// A background blur; a progressive one (`isProgressive`) ramps along its gradient, whose stops' +/// opacity scales the radius at each end. +fn background_blur(b: &Value, radius: f64) -> Value { + let g = b.get("gradient").filter(|_| b.get("isProgressive").and_then(Value::as_bool) == Some(true)); + let Some(g) = g else { return json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius}) }; + let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let at = |s: Option<&Value>| s.and_then(|s| s.get("color")).and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0) * radius; + let end = |k: &str, d: (f64, f64)| { + let (x, y) = g.get(k).and_then(Value::as_str).and_then(super::path::parse_point).unwrap_or(d); + json!({"x": x.clamp(-1e3, 1e3), "y": y.clamp(-1e3, 1e3)}) + }; + json!({ + "type": "BACKGROUND_BLUR", + "visible": true, + "blurType": "PROGRESSIVE", + "startRadius": at(stops.first()), + "radius": at(stops.last()), + "startOffset": end("from", (0.5, 0.0)), + "endOffset": end("to", (0.5, 1.0)), + }) +} + /// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral, /// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note). pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option { @@ -264,6 +319,79 @@ pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}})) } +/// The tint a style gives the layers inside: its first enabled plain fill with `layeringType` 1 +/// (Sketch 2025 and newer), or with no `layeringType` when `legacy` (older files: a group's or +/// instance's fill was its tint). Sketch draws a tint as the colour of everything inside. +pub(crate) fn tint_of(style: Option<&Value>, legacy: bool) -> Option { + let fills = style?.get("fills")?.as_array()?; + let f = fills.iter().filter(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false)).find(|f| { + match f.get("layeringType").and_then(Value::as_i64) { + Some(t) => t == 1, + None => legacy, + } + })?; + (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").cloned()).flatten() +} + +/// Recolour `layers` (Sketch JSON) with tint `color`: every enabled fill and border takes it, +/// text takes it, nested instances take it as their own tint. Opacity stays as it was. +pub(crate) fn tint_layers(layers: &mut [Value], color: &Value, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + if let Some(st) = l.get_mut("style").and_then(Value::as_object_mut) { + for key in ["fills", "borders"] { + for p in st.get_mut(key).and_then(Value::as_array_mut).into_iter().flatten() { + if let Some(m) = p.as_object_mut() + && m.get("isEnabled").and_then(Value::as_bool) != Some(false) + && !matches!(m.get("fillType").and_then(Value::as_i64), Some(4)) + { + let c = tinted(m.get("color"), color); + m.insert("fillType".into(), json!(0)); + m.insert("color".into(), c); + } + } + } + if let Some(enc) = st.get_mut("textStyle").and_then(|t| t.get_mut("encodedAttributes")).and_then(Value::as_object_mut) { + let c = tinted(enc.get("MSAttributedStringColorAttribute"), color); + enc.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) { + for r in runs.iter_mut().filter_map(|r| r.get_mut("attributes")).filter_map(Value::as_object_mut) { + let c = tinted(r.get("MSAttributedStringColorAttribute"), color); + r.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if l.get("_class").and_then(Value::as_str) == Some("symbolInstance") { + set_tint(l, color); + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + tint_layers(kids, color, depth + 1); + } + } +} + +/// Colour `old` (Sketch JSON) tinted with `tint`: the tint's colour, with both opacities. +fn tinted(old: Option<&Value>, tint: &Value) -> Value { + let alpha = |c: Option<&Value>| c.and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0); + let mut c = tint.clone(); + c["alpha"] = json!(alpha(Some(tint)) * alpha(old)); + c +} + +/// Give layer `l` (Sketch JSON) tint `color`: as its one fill. +pub(crate) fn set_tint(l: &mut Value, color: &Value) { + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color}); + if let Some(m) = l.as_object_mut() { + let st = m.entry("style").or_insert_with(|| json!({"_class": "style"})); + if let Some(st) = st.as_object_mut() { + st.insert("fills".into(), json!([fill])); + } + } +} + /// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. fn blend(mode: i64) -> Option<&'static str> { const MODES: [&str; 18] = [ diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 242b0478..b5d24bdf 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -78,7 +78,10 @@ fn gradients() { assert_eq!(lin.stops.len(), 2); assert_eq!(lin.stops[1].color.b, 1.0); assert_eq!(g("Radial").kind, GradientKind::Radial); - assert_eq!(g("Angular").kind, GradientKind::Angular); + // Sketch starts an angular gradient on the right and turns clockwise, whatever its from/to. + let ang = g("Angular"); + assert_eq!(ang.kind, GradientKind::Angular); + assert_eq!((ang.from, ang.to, ang.width), ((0.5, 0.5), (1.0, 0.5), (0.5, 1.0))); // The radial gradient's width handle: half the length, across the main axis (in pixels). let rad = g("Radial"); let (dx, dy) = ((rad.width.0 - rad.from.0) * 100.0, (rad.width.1 - rad.from.1) * 50.0); @@ -160,7 +163,8 @@ fn shadows_and_blurs() { assert!(matches!(fx[2].kind, EffectKind::InnerShadow(_))); assert!(matches!(node(&doc, "Blurred").effects[0].kind, EffectKind::LayerBlur(_))); assert!(matches!(node(&doc, "Frosted").effects[0].kind, EffectKind::BackgroundBlur(_))); - assert!(node(&doc, "Moving").effects.is_empty()); + // Motion blur: drawn as a plain blur of half the radius. + assert!(matches!(node(&doc, "Moving").effects[0].kind, EffectKind::LayerBlur(_))); assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); } diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index 1292c025..06a39f68 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -114,21 +114,49 @@ pub(crate) fn master_fills(m: &Value) -> Option { Some(json!([{"type": "SOLID", "color": c}])) } +/// What a master shows as a frame (its instances the same): clipping, and in Sketch 2025 and newer +/// its style (fills, borders, effects) and corners. +fn frame_look(cx: &mut Cx, m: &Value, w: f64, h: f64, node: &mut JsonMap) { + // `clippingBehavior` 2 doesn't clip. + node.insert("clipsContent".into(), json!(m.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + if m.get("groupBehavior").is_some() + && let Some(st) = m.get("style") + { + // Tint fills aren't painted (see `style::fills`). + super::style::apply(cx, st, w, h, node); + } + super::tree::style_corners(m, node); +} + /// A symbol master as a COMPONENT node. pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { let (w, h) = size(l); let world = parent * local(l, w, h); let mut node = base(l, "COMPONENT", parent, world, w, h, root); - node.insert("clipsContent".into(), json!(true)); + frame_look(cx, l, w, h, &mut node); 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(); + // A graphic's fill tints its layers. + let tinted; + let l = match super::style::tint_of(l.get("style"), false) { + Some(t) => { + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + tinted = c; + &tinted + } + _ => l, + }; cx.chain.push(sid); let kids = children(cx, l, world, (w, h), depth, false); cx.chain.pop(); node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); Some(Value::Object(node)) } @@ -161,15 +189,20 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo } let mut node = base(l, "INSTANCE", parent, world, w, h, root); node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); - node.insert("clipsContent".into(), json!(true)); + frame_look(cx, &m, w, h, &mut node); // Instances show the master's background only when it says so. if bool_field(&m, "includeBackgroundColorInInstance") == Some(true) && let Some(f) = master_fills(&m) { node.insert("fills".into(), f); } + // An instance's fill is a tint for its layers, not a paint of its own. if let Some(st) = l.get("style") { - super::style::apply(cx, st, w, h, &mut node); + let mut st = st.clone(); + if let Some(o) = st.as_object_mut() { + o.remove("fills"); + } + 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). @@ -187,21 +220,45 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo // The budget ran out handing overrides on: noted. cx.spend(1); } - // The master's layers laid out for the instance's size, as Sketch does it. - let (mw, mh) = size(©); - if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + // The tint: the instance's own (a colour override on it, or its fill), else a graphic master's. + let own = str_field(l, "do_objectID").map(|id| format!("{id}_fillColor")); + let tint = own + .and_then(|k| overrides.get(&k).filter(|c| c.is_object()).cloned()) + .or_else(|| super::style::tint_of(l.get("style"), true)) + .or_else(|| super::style::tint_of(copy.get("style"), false)); + if let Some(t) = tint && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { - resize(layers, (mw, mh), (w, h), cx.legacy_resize, 0); + super::style::tint_layers(layers, &t, 0); + } + // The master's layers laid out for the instance's size, as Sketch does it. + let (mw, mh) = size(©); + // A graphic's layers resize in proportion, whatever their pins. + let graphic = copy.get("groupBehavior").and_then(Value::as_i64) == Some(2); + // A stack's layers are placed by its auto layout (when the file is opened), not by their pins. + let stacked = !graphic && super::stack::is_stack(©); + if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + && !stacked + && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) + { + resize(layers, (mw, mh), (w, h), cx.legacy_resize || graphic, 0); } cx.chain.push(sid.to_string()); let mut kids = children(cx, ©, world, (w, h), depth, false); cx.chain.pop(); + node.insert("children".into(), Value::Array(std::mem::take(&mut kids))); + // The master's stack lays the instance out too (matched by the master's layer ids), sized as + // the instance says. + for k in ["horizontalSizing", "verticalSizing"] { + if let Some(v) = l.get(k) { + copy[k] = v.clone(); + } + } + super::stack::apply(©, &mut node); let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); - for k in &mut kids { + for k in node.get_mut("children").and_then(Value::as_array_mut).into_iter().flatten() { prefix_ids(k, &prefix, 0); } - node.insert("children".into(), Value::Array(kids)); Some(Value::Object(node)) } @@ -213,7 +270,7 @@ fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, return; } for l in layers { - let mask = if legacy { 63 } else { l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63) }; + let mask = if legacy { 63 } else { super::tree::pin_mask(l) }; let Some(f) = l.get_mut("frame").and_then(Value::as_object_mut) else { continue }; let n = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); let (x, y, w, h) = (n("x"), n("y"), n("width").max(0.0), n("height").max(0.0)); @@ -224,8 +281,10 @@ fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, for (k, v) in [("x", nx), ("y", ny), ("width", nw), ("height", nh)] { f.insert(k.into(), json!(v)); } + // A graphic's layers resize in proportion, whatever their pins. + let graphic = l.get("groupBehavior").and_then(Value::as_i64) == Some(2); if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { - resize(kids, (w, h), (nw, nh), legacy, depth + 1); + resize(kids, (w, h), (nw, nh), legacy || graphic, depth + 1); } } } @@ -316,19 +375,46 @@ fn apply( own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); l["overrideValues"] = Value::Array(own); } + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on an instance is its tint. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) { + super::style::set_tint(l, c); + } continue; } Some("text") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } if let Some(s) = get("stringValue").and_then(Value::as_str) { set_string(l, s); } + if let Some(c) = get("textColor").filter(|c| c.is_object()) { + super::style::tint_layers(std::slice::from_mut(l), c, 0); + } } Some("bitmap") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) { l["image"] = img.clone(); } } - _ => {} + _ => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on a shape or group: its first fill's colour. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) + && let Some(f) = l.get_mut("style").and_then(|s| s.get_mut("fills")).and_then(Value::as_array_mut).and_then(|f| f.first_mut()) + { + f["fillType"] = json!(0); + f["color"] = c.clone(); + } + } } for (prop, text) in [("layerStyle", false), ("textStyle", true)] { let sid = get(prop).and_then(Value::as_str); @@ -357,6 +443,11 @@ fn apply( } } +/// A visibility override's value: "0", 0 or false hide. +fn visible(v: &Value) -> bool { + !matches!(v, Value::Bool(false)) && v.as_str() != Some("0") && v.as_i64() != Some(0) +} + /// A text layer's string replaced, in the style of its first run. fn set_string(l: &mut Value, s: &str) { let units = s.encode_utf16().count(); diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index e1f0c263..0dd8d596 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -60,14 +60,17 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { _ => "TOP", }), ); - m.insert( - "textAutoResize".into(), - json!(match l.get("textBehaviour").and_then(Value::as_i64) { - Some(1) => "HEIGHT", - Some(2) => "NONE", - _ => "WIDTH_AND_HEIGHT", - }), - ); + // Sketch 2025 and newer size text by `horizontalSizing`/`verticalSizing` (1 fits). + let fits = |k: &str| l.get(k).and_then(Value::as_i64) == Some(1); + let resize = match l.get("textBehaviour").and_then(Value::as_i64) { + Some(1) => "HEIGHT", + Some(2) => "NONE", + Some(_) => "WIDTH_AND_HEIGHT", + None if l.get("horizontalSizing").is_none() || fits("horizontalSizing") => "WIDTH_AND_HEIGHT", + None if fits("verticalSizing") => "HEIGHT", + None => "NONE", + }; + m.insert("textAutoResize".into(), json!(resize)); } node.insert("characters".into(), json!(string)); node.insert("style".into(), style); diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 9a5bfaa3..3d6a48d3 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -151,6 +151,15 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: cx.too_old = true; return None; } + // A rectangle with angled or inside corners is a path of that outline. + let outlined; + let l = match super::path::cut_corners(l) { + Some(p) => { + outlined = p; + &outlined + } + None => l, + }; let class = str_field(l, "_class")?; if class == "shapeGroup" { return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); @@ -163,6 +172,8 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: } let ty = match class { "artboard" | "frame" => "FRAME", + // Sketch 2025 and newer: a group that behaves as a frame (1) or a graphic (2), or clips. + "group" if is_frame(l) => "FRAME", "group" | "graphic" => "GROUP", // Shapes whose points were moved are paths. "rectangle" | "oval" if bool_field(l, "edited") == Some(true) => "VECTOR", @@ -204,6 +215,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: node.insert("fills".into(), json!([{"type": "SOLID", "color": c}])); } } + "group" if ty == "FRAME" => { + // `clippingBehavior`: 0 the default (frames clip), 1 clip, 2 don't. + node.insert("clipsContent".into(), json!(l.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + } "rectangle" => corner_radii(l, &mut node), "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { Some(r) => { @@ -219,17 +234,80 @@ 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); + style_corners(l, &mut node); if class == "MSImmutableHotspotLayer" || class == "hotspot" { node.insert("clipsContent".into(), json!(false)); node.remove("fills"); } + // A group's or graphic's fill tints its layers (a frame's is its background). + let tinted; + let gb = l.get("groupBehavior").and_then(Value::as_i64); + let l = match (class, super::style::tint_of(l.get("style"), gb != Some(1))) { + ("group", Some(t)) => { + // A plain group's legacy fill is all tint; a frame keeps its background fills. + if gb != Some(1) { + node.remove("fills"); + } + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + tinted = c; + &tinted + } + _ => l, + }; if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); } Some(Value::Object(node)) } +/// A Sketch 2025+ group that is a frame: it behaves as a frame or a graphic, or clips. +pub(crate) fn is_frame(l: &Value) -> bool { + matches!(l.get("groupBehavior").and_then(Value::as_i64), Some(1 | 2)) || l.get("clippingBehavior").and_then(Value::as_i64) == Some(1) +} + +/// Corners kept in the style (Sketch 2025+: one radius for all, or one per corner from the top +/// left, clockwise) on shapes and frames that have corners; smooth corners keep their smoothing. +pub(crate) fn style_corners(l: &Value, node: &mut JsonMap) { + let Some(c) = l.get("style").and_then(|s| s.get("corners")) else { return }; + if !matches!(node.get("type").and_then(Value::as_str), Some("RECTANGLE" | "FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let radii: Vec = c + .get("radii") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|r| r.is_finite()) + .map(|r| r.clamp(0.0, MAX_SIZE)) + .take(4) + .collect(); + match radii.as_slice() { + [] => return, + [r] => { + node.insert("cornerRadius".into(), json!(r)); + node.remove("rectangleCornerRadii"); + } + more => { + // Values repeat to fill the four corners. + let all: Vec = (0..4).map(|i| more.get(i % more.len()).copied().unwrap_or(0.0)).collect(); + node.insert("rectangleCornerRadii".into(), json!(all)); + } + } + // Style 1 is smooth: its smoothing applies. + if c.get("style").and_then(Value::as_i64) == Some(1) + && let Some(sm) = c.get("smoothing").and_then(Value::as_f64).filter(|v| v.is_finite()) + { + node.insert("cornerSmoothing".into(), json!(sm.clamp(0.0, 1.0))); + } +} + /// Layer `l`'s style into `node`, and its links to the styles made from its shared style. pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { // An artboard shows its background colour only; Sketch doesn't draw a style it carries. @@ -291,7 +369,7 @@ pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h 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))); + node.insert("constraints".into(), constraints(pin_mask(l))); node } @@ -317,6 +395,33 @@ pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { * Affine::translate((-w / 2.0, -h / 2.0)) } +/// A layer's pins as a `resizingConstraint` mask. Sketch 2025 and newer keep them in +/// `horizontalPins`/`verticalPins` (bits: 1 the start, 4 the end) and `horizontalSizing`/ +/// `verticalSizing` (fixed and fit keep the size; fill and relative don't), with +/// `resizingConstraint` left at 63. +pub(crate) fn pin_mask(l: &Value) -> i64 { + if l.get("horizontalPins").is_none() && l.get("horizontalSizing").is_none() { + return l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63); + } + let int = |k: &str| l.get(k).and_then(Value::as_i64).unwrap_or(0); + let fixed = |k: &str| matches!(int(k), 0 | 1); + let mut mask = 63; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + for (on, bit) in [ + (int("horizontalPins") & 1 != 0, 4), + (int("horizontalPins") & 4 != 0, 1), + (fixed("horizontalSizing"), 2), + (int("verticalPins") & 1 != 0, 32), + (int("verticalPins") & 4 != 0, 8), + (fixed("verticalSizing"), 16), + ] { + if on { + mask &= !bit; + } + } + mask +} + /// 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). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8759a359..4d7b92ed 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), embedded fonts (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -166,7 +166,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Checked on fixtures and locally on one real Sketch 50 file and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | +| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |