diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2379026b..9e38af58 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -628,7 +628,23 @@ fn reflow_overridden_text(doc: &mut Document) { } for id in changed { if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + // Sketch grows auto-width text from its aligned edge: a centred one stays centred, a + // right-aligned one keeps its right edge. + let shift = doc.node(id).map_or(0.0, |n| match &n.kind { + NodeKind::Text(t) if t.auto_resize == figcraft_doc::TextAutoResize::WidthAndHeight => match t.align_h { + figcraft_doc::TextAlignH::Center => (n.width - w) / 2.0, + figcraft_doc::TextAlignH::Right => n.width - w, + _ => 0.0, + }, + _ => 0.0, + }); figcraft_layout::set_size(doc, id, w, h); + if shift.is_finite() + && shift != 0.0 + && let Some(n) = doc.node_mut(id) + { + n.transform *= figcraft_doc::geom::Affine::translate((shift, 0.0)); + } } figcraft_layout::layout_up(doc, id, &m); } diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index f9d3f14b..e05dade6 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -527,3 +527,46 @@ fn sketch_shared_text_styles_match_their_layers() { assert_eq!(st.font_family, td.style.font_family); assert_eq!(st.letter_spacing.resolve(13.0), td.style.letter_spacing.resolve(13.0), "{st:?}"); } + +/// An auto-width text an override made longer grows from its aligned edge, as in Sketch's renders: +/// a left-aligned one keeps its left edge, a centred one its centre, a right-aligned one its right +/// edge. +#[test] +fn sketch_overridden_text_grows_from_its_aligned_edge() { + let font = json!({"name": "Helvetica", "size": 12}); + let mut layers = Vec::new(); + for (i, align) in [0, 2, 1].into_iter().enumerate() { + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}, + "paragraphStyle": {"_class": "paragraphStyle", "alignment": align}}}); + let label = shape( + &format!("L{i}"), + &format!("L{i}"), + [70.0, 4.0, 14.0, 16.0], + json!({"_class": "text", "attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let sid = format!("S{i}"); + layers.push(shape("symbolMaster", &format!("M{i}"), [0.0, 100.0 * i as f64, 154.0, 24.0], json!({"symbolID": sid, "layers": [label]}))); + let ov = json!([{"_class": "overrideValue", "overrideName": format!("L{i}_stringValue"), "value": "A much longer label"}]); + layers.push(shape( + "symbolInstance", + &format!("I{i}"), + [300.0, 100.0 * i as f64, 154.0, 24.0], + json!({"symbolID": sid, "overrideValues": ov}), + )); + } + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &layers)); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let bounds = |i: usize| { + let inst = doc.nodes().find(|n| n.name == format!("I{i}")).unwrap(); + let label = doc.subtree(inst.id).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == format!("L{i}"))).unwrap(); + let (b, l) = (doc.world_bounds(inst.id).unwrap(), doc.world_bounds(label).unwrap()); + assert!(l.x1 - l.x0 > 40.0, "the label was measured: {l:?}"); + (l.x0 - b.x0, l.x1 - b.x0) + }; + let (left, centre, right) = (bounds(0), bounds(1), bounds(2)); + assert!((left.0 - 70.0).abs() < 0.5, "{left:?}"); + assert!(((centre.0 + centre.1) / 2.0 - 77.0).abs() < 0.5, "{centre:?}"); + assert!((right.1 - 84.0).abs() < 0.5, "{right:?}"); +} diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs index 5a5cefca..af50c5cf 100644 --- a/crates/format/src/sketch/frames_tests.rs +++ b/crates/format/src/sketch/frames_tests.rs @@ -159,12 +159,15 @@ fn swatches_bind_solid_fills_only() { } /// Sketch 2025+ masters keep their background, corners and stack in their style and layout, and -/// their instances show the same. +/// their instances show the same (the background when the master includes it in instances and +/// exports). #[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["includeBackgroundColorInInstance"] = json!(true); + m["includeBackgroundColorInExport"] = json!(true); 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]); @@ -292,6 +295,8 @@ fn layering_type_decides_tint_or_background() { bg["layeringType"] = json!(0); let mut btn = master("MC", "SC", "Btn", 100.0, 20.0, vec![]); btn["groupBehavior"] = json!(1); + btn["includeBackgroundColorInInstance"] = json!(true); + btn["includeBackgroundColorInExport"] = json!(true); 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(); @@ -441,3 +446,37 @@ fn faded_stack_items_keep_their_sizing() { let wrap = doc.node(row.children[0]).unwrap(); assert_eq!(format!("{:?}", wrap.child_layout.sizing_h), "Fill", "{:?}", wrap.child_layout); } + +/// Tints came with Sketch 64 (format 124 and newer): before them a group's fill draws nothing, so +/// its layers keep their own colours (seen on a Sketch 63 file's renders). +#[test] +fn group_fills_tint_only_from_sketch_64() { + let make = |version: u64| { + let white = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(1.0, 1.0, 1.0, 1.0)])); + let g = styled(group("G", 0.0, 0.0, 10.0, 10.0, vec![white]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let z = sketch_zip(if version > 123 { "64" } else { "63.1" }, version, document(&["P"]), &[page("P", "Page", vec![g])], &[]); + import(&z, "f").unwrap().0 + }; + assert_eq!(solid_of(node(&make(123), "Dot")), Some([1.0, 1.0, 1.0])); + assert_eq!(solid_of(node(&make(124), "Dot")), Some([0.0, 0.0, 0.0])); +} + +/// A stretching stack (`alignItems` 3) fills its items across, fixed and hugging ones too. +#[test] +fn stretching_stacks_fill_items_across() { + let mut fixed = rect("Fixed", 0.0, 0.0, 60.0, 20.0); + fixed["horizontalSizing"] = json!(0); + let mut hug = frame_group("Hug", 0.0, 20.0, 60.0, 20.0, vec![]); + hug["horizontalSizing"] = json!(1); + let col = |name: &str, align: i64| stack(frame_group(name, 0.0, 0.0, 60.0, 40.0, vec![hug.clone(), fixed.clone()]), 1, 0, align, 0.0, [0.0; 4]); + let (doc, _) = open(vec![col("Start", 0), col("Stretch", 3)]); + let sizing = |parent: &str, name: &str| { + let p = node(&doc, parent); + let n = p.children.iter().filter_map(|c| doc.node(*c)).find(|n| n.name == name).unwrap(); + n.child_layout.sizing_h + }; + use figcraft_doc::Sizing as LayoutSizing; + assert_eq!(sizing("Start", "Fixed"), LayoutSizing::Fixed); + assert_eq!(sizing("Stretch", "Fixed"), LayoutSizing::Fill); + assert_eq!(sizing("Stretch", "Hug"), LayoutSizing::Fill); +} diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 24ceca15..9ccd9f01 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -232,6 +232,21 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, out } +/// `node` (REST JSON) and the layers in it without strokes. +fn unstroked(node: &mut Value, depth: usize) { + if depth > 64 { + return; + } + if let Some(m) = node.as_object_mut() { + m.remove("strokes"); + if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) { + for k in kids { + unstroked(k, depth + 1); + } + } + } +} + /// A shape group of one shape: that shape in the group's place, with its name and style. fn single(cx: &mut Cx, l: &Value, only: &Value, parent: Affine, world: Affine, depth: usize, root: bool) -> Option { let mut node = layer(cx, only, world, depth + 1, false)?; @@ -301,6 +316,12 @@ fn combined(cx: &mut Cx, l: &Value, kids: &[Value], parent: Affine, world: Affin if operands.is_empty() { continue; } + // Sketch draws a shape group in the group's style only. Its operands' own borders go: a + // FigCraft operand with only a stroke gives its stroke's band, not its area. + let mut operands = operands; + for o in &mut operands { + unstroked(o, 0); + } let this = run.first().map_or("UNION", |c| boolean_op(c)); if i > 0 && this != op && chain.len() > 1 { let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain)); diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs index 70ad9527..f1d0b695 100644 --- a/crates/format/src/sketch/path_tests.rs +++ b/crates/format/src/sketch/path_tests.rs @@ -233,3 +233,20 @@ fn open_operands_of_booleans_are_closed() { let NodeKind::Vector { network } = &node(&doc, "Snout").kind else { panic!("not a vector") }; assert_eq!(network.regions.len(), 1); } + +/// Sketch draws a shape group in the group's style only: its operands' own borders go, so a +/// FigCraft operand gives its area to the operation, not a stroke's band. +#[test] +fn shape_group_operands_lose_their_borders() { + let border = json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 0, "thickness": 8}]); + let outer = styled(with_op(rect("Outer", 0.0, 0.0, 100.0, 100.0), 1), "borders", border.clone()); + let hole = styled(with_op(rect("Hole", 25.0, 25.0, 50.0, 50.0), 1), "borders", border); + let g = styled(shape_group("Pin", 0.0, 0.0, 100.0, 100.0, vec![outer, hole]), "fills", json!([solid_fill(0.0, 0.0, 1.0, 1.0)])); + let doc = open(vec![g]); + let pin = node(&doc, "Pin"); + assert!(matches!(pin.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }), "{:?}", pin.kind); + assert_eq!(pin.fills.len(), 1); + for name in ["Outer", "Hole"] { + assert!(node(&doc, name).strokes.is_empty(), "{name}: {:?}", node(&doc, name).strokes); + } +} diff --git a/crates/format/src/sketch/stack.rs b/crates/format/src/sketch/stack.rs index 89f67ca8..72fe8ad4 100644 --- a/crates/format/src/sketch/stack.rs +++ b/crates/format/src/sketch/stack.rs @@ -91,7 +91,15 @@ fn item(src: &Value, m: &mut JsonMap, row: bool, stretch: bool) { Some(2) => "FILL", _ => "FIXED", }; - let (h, v) = (sizing("horizontalSizing"), sizing("verticalSizing")); + let (mut h, mut v) = (sizing("horizontalSizing"), sizing("verticalSizing")); + // A stretching stack fills its items across, fixed and hugging ones too. + if stretch { + if row { + v = "FILL"; + } else { + h = "FILL"; + } + } m.insert("layoutSizingHorizontal".into(), json!(h)); m.insert("layoutSizingVertical".into(), json!(v)); let (main, cross) = if row { (h, v) } else { (v, h) }; diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index 5e476803..dfbc7191 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -71,7 +71,12 @@ pub(crate) fn clear(node: &mut JsonMap) { fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { let mut p = match f.get("fillType").and_then(Value::as_i64).unwrap_or(0) { 0 => json!({"type": "SOLID", "color": f.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}))}), - 1 => gradient(f.get("gradient")?, w, h)?, + 1 => { + if matches!(f.get("gradient").and_then(|g| g.get("colorInterpolation")).and_then(Value::as_i64), Some(1 | 2)) { + cx.warn("gradients mixed in OKLab or OKLCH became sRGB gradients with stops along the same colours"); + } + gradient(f.get("gradient")?, w, h)? + } 4 => { let Some(r) = f.get("image").and_then(|i| i.get("_ref")).and_then(Value::as_str) else { cx.warn("image fills stored inside the JSON were skipped"); @@ -128,6 +133,12 @@ fn gradient(g: &Value, w: f64, h: f64) -> Option { .iter() .filter_map(|s| Some(json!({"position": num(s, "position")?.clamp(0.0, 1.0), "color": s.get("color").and_then(color)?}))) .collect(); + // `colorInterpolation` 1 mixes in OKLab, 2 in OKLCH (the shorter way round the hue); FigCraft + // mixes in sRGB, so stops in between carry the curve. + let stops = match g.get("colorInterpolation").and_then(Value::as_i64) { + Some(mode @ (1 | 2)) => oklab::resample(&stops, mode == 2), + _ => stops, + }; let (ty, handles) = match g.get("gradientType").and_then(Value::as_i64).unwrap_or(0) { 1 => { let e = num(g, "elipseLength").filter(|e| *e > 0.0).unwrap_or(1.0); @@ -261,7 +272,8 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { 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})), + // Sketch's Gaussian radius is the blur's standard deviation; FigCraft's is twice it. + 0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius * 2.0})), 3 => out.push(background_blur(b, radius)), 4 => out.push(json!({ "type": "GLASS", @@ -311,10 +323,18 @@ pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option 1e-6 { cx.warn("hue changes of bitmaps were skipped"); } + // Sketch scales about mid-grey by the contrast and then adds the brightness, + // (v - 0.5)·C + 0.5 + b; FigCraft multiplies by the exposure E first, (v·E - 0.5)·c + 0.5. + // The two agree for c = C - 2b and E = C / c within FigCraft's ranges (c in 0..=2, E in + // 1/4..=4); past them the slope C stays exact and the offset gives. + let (b, contrast) = (get("brightness", 0.0).clamp(-1.0, 1.0), get("contrast", 1.0).clamp(0.0, 2.0)); + let wanted = contrast - 2.0 * b; + let e = if wanted > 1e-6 { (contrast / wanted).clamp(0.25, 4.0) } else { 4.0 }; + let c = (contrast / e).clamp(0.0, 2.0); Some(json!({ "saturation": (get("saturation", 1.0) - 1.0).clamp(-1.0, 1.0), - "contrast": (get("contrast", 1.0) - 1.0).clamp(-1.0, 1.0), - "exposure": get("brightness", 0.0).clamp(-1.0, 1.0), + "contrast": (c - 1.0).clamp(-1.0, 1.0), + "exposure": (e.log2() / 2.0).clamp(-1.0, 1.0), })) } @@ -432,3 +452,101 @@ fn list<'v>(v: &'v Value, key: &str) -> &'v [Value] { fn num(v: &Value, key: &str) -> Option { v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()) } + +/// Gradient stops mixed in OKLab or OKLCH, as sRGB stops close enough to follow the curve. +mod oklab { + use serde_json::{Value, json}; + + /// Stops added between each pair. + const STEPS: usize = 16; + /// Most stops given back. + const MAX_STOPS: usize = 1024; + + type Lab = [f64; 3]; + + fn lin(v: f64) -> f64 { + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + } + + fn enc(v: f64) -> f64 { + let v = v.max(0.0); + if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } + } + + fn to_lab([r, g, b]: [f64; 3]) -> Lab { + let (r, g, b) = (lin(r), lin(g), lin(b)); + let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt(); + let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt(); + let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt(); + [ + 0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s, + 1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s, + 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s, + ] + } + + fn from_lab([l, a, b]: Lab) -> [f64; 3] { + let l_ = (l + 0.396_337_777_4 * a + 0.215_803_757_3 * b).powi(3); + let m_ = (l - 0.105_561_345_8 * a - 0.063_854_172_8 * b).powi(3); + let s_ = (l - 0.089_484_177_5 * a - 1.291_485_548_0 * b).powi(3); + [ + enc(4.076_741_662_1 * l_ - 3.307_711_591_3 * m_ + 0.230_969_929_2 * s_), + enc(-1.268_438_004_6 * l_ + 2.609_757_401_1 * m_ - 0.341_319_396_5 * s_), + enc(-0.004_196_086_3 * l_ - 0.703_418_614_7 * m_ + 1.707_614_701_0 * s_), + ] + .map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }) + } + + /// The colour `t` of the way from `a` to `b`; in OKLCH the hue turns the shorter way (a grey + /// end takes the other's hue). + fn mix(a: Lab, b: Lab, t: f64, lch: bool) -> Lab { + let lerp = |x: f64, y: f64| x + (y - x) * t; + if !lch { + return [lerp(a[0], b[0]), lerp(a[1], b[1]), lerp(a[2], b[2])]; + } + let (ca, cb) = (a[1].hypot(a[2]), b[1].hypot(b[2])); + let (mut ha, mut hb) = (a[2].atan2(a[1]), b[2].atan2(b[1])); + if ca < 1e-4 { + ha = hb; + } + if cb < 1e-4 { + hb = ha; + } + let mut dh = hb - ha; + if dh > std::f64::consts::PI { + dh -= std::f64::consts::TAU; + } else if dh < -std::f64::consts::PI { + dh += std::f64::consts::TAU; + } + let (c, h) = (lerp(ca, cb), ha + dh * t); + [lerp(a[0], b[0]), c * h.cos(), c * h.sin()] + } + + fn rgba(stop: &Value) -> Option<(f64, [f64; 3], f64)> { + let c = stop.get("color")?; + let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + Some((stop.get("position").and_then(Value::as_f64)?, [n("r")?, n("g")?, n("b")?], n("a").unwrap_or(1.0))) + } + + pub(super) fn resample(stops: &[Value], lch: bool) -> Vec { + let mut parsed: Vec<(f64, [f64; 3], f64)> = stops.iter().filter_map(rgba).collect(); + if parsed.len() != stops.len() || parsed.len() < 2 || parsed.len() * STEPS > MAX_STOPS { + return stops.to_vec(); + } + parsed.sort_by(|a, b| a.0.total_cmp(&b.0)); + let stop = |pos: f64, [r, g, b]: [f64; 3], a: f64| json!({"position": pos, "color": {"r": r, "g": g, "b": b, "a": a}}); + let mut out = Vec::new(); + for pair in parsed.windows(2) { + let [(p0, c0, a0), (p1, c1, a1)] = pair else { continue }; + let (l0, l1) = (to_lab(*c0), to_lab(*c1)); + for k in 0..STEPS { + let t = k as f64 / STEPS as f64; + out.push(stop(p0 + (p1 - p0) * t, from_lab(mix(l0, l1, t, lch)), a0 + (a1 - a0) * t)); + } + } + if let Some((p, c, a)) = parsed.last() { + out.push(stop(*p, *c, *a)); + } + out + } +} diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index e553ab6b..d3307016 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -364,3 +364,120 @@ fn linear_gradients_are_square_in_pixels() { let (wx, wy) = ((g.width.0 - g.from.0) * 200.0, (g.width.1 - g.from.1) * 100.0); assert!((wx * 200.0 + wy * 100.0).abs() < 1e-6, "{g:?}"); } + +// Rules below were read off Sketch's own renders (sketchtool) of files built for each case. + +/// Sketch's Gaussian blur radius is the blur's standard deviation; FigCraft's radius is twice it. +#[test] +fn layer_blur_radius_doubles() { + let (doc, _) = open(vec![styled(r("Blurred"), "blur", json!({"_class": "blur", "isEnabled": true, "radius": 6, "type": 0}))]); + let EffectKind::LayerBlur(b) = &node(&doc, "Blurred").effects[0].kind else { panic!("no layer blur") }; + assert_eq!(b.radius, 12.0); +} + +/// Sketch's colour controls scale by the contrast about mid-grey, then add the brightness; the +/// filters give the same tone curve. +#[test] +fn bitmap_brightness_adds_after_contrast() { + 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 + }; + for (brightness, contrast) in [(-0.24, 1.0), (0.2, 1.0), (-0.2, 1.3), (0.1, 0.7)] { + let mut bitmap = r("Photo"); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + bitmap["style"]["colorControls"] = + json!({"_class": "colorControls", "isEnabled": true, "brightness": brightness, "contrast": contrast, "hue": 0, "saturation": 1}); + let (doc, _) = open_doc(document(&["P"]), vec![bitmap], &[("images/a.png", &png)]); + let PaintKind::Image(img) = &node(&doc, "Photo").fills[0].kind else { panic!("no image") }; + let (e, c) = (2f64.powf(f64::from(img.filters.exposure) * 2.0), 1.0 + f64::from(img.filters.contrast)); + for v in [0.1, 0.4, 0.6, 0.9] { + let figcraft = (v * e - 0.5) * c + 0.5; + let sketch = (v - 0.5) * contrast + 0.5 + brightness; + assert!((figcraft - sketch).abs() < 1e-4, "{brightness} {contrast} at {v}: {figcraft} vs {sketch}"); + } + } +} + +/// A bitmap's own image lies under its style's fills: a disabled fill leaves it showing, an +/// enabled one covers it. +#[test] +fn bitmap_image_under_its_fills() { + 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 bitmap = |name: &str, enabled: bool| { + let mut b = r(name); + b["_class"] = json!("bitmap"); + b["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + let mut f = solid_fill(1.0, 0.0, 0.0, 1.0); + f["isEnabled"] = json!(enabled); + styled(b, "fills", json!([f])) + }; + let (doc, _) = open_doc(document(&["P"]), vec![bitmap("Off", false), bitmap("On", true)], &[("images/a.png", &png)]); + for (name, solid_visible) in [("Off", false), ("On", true)] { + let fills = &node(&doc, name).fills; + assert_eq!(fills.len(), 2, "{name}"); + assert!(matches!(fills[0].kind, PaintKind::Image(_)) && fills[0].visible, "{name}"); + assert!(matches!(fills[1].kind, PaintKind::Solid { .. }), "{name}"); + assert_eq!(fills[1].visible, solid_visible, "{name}"); + } +} + +/// An outline mask's opacity fades the mask (its drawn copy) and what it clips together; an alpha +/// mask's opacity is its alpha already, so nothing more fades. +#[test] +fn faded_masks_fade_what_they_clip() { + let fill = json!([solid_fill(0.8, 0.8, 0.8, 1.0)]); + let faded = |name: &str, mode: i64| { + let mut m = styled(with(with(r(name), "hasClippingMask", json!(true)), "clippingMaskMode", json!(mode)), "fills", fill.clone()); + m["style"]["contextSettings"] = json!({"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 0.5}); + m + }; + let (doc, _) = open(vec![ + group("G1", 0.0, 0.0, 100.0, 50.0, vec![faded("Outline", 0), r("Clipped")]), + group("G2", 0.0, 100.0, 100.0, 50.0, vec![faded("Alpha", 1), r("Faded")]), + ]); + let holder = doc.node(node(&doc, "Outline").parent.unwrap()).unwrap(); + assert!((holder.opacity - 0.5).abs() < 1e-6, "{}", holder.opacity); + let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(kids, ["Outline (drawn)", "Outline", "Clipped"]); + assert!((node(&doc, "Outline (drawn)").opacity - 1.0).abs() < 1e-6); + assert!((node(&doc, "Clipped").opacity - 1.0).abs() < 1e-6); + let alpha_parent = doc.node(node(&doc, "Alpha").parent.unwrap()).unwrap(); + assert!((alpha_parent.opacity - 1.0).abs() < 1e-6, "{}", alpha_parent.opacity); +} + +/// `colorInterpolation` 1 mixes a gradient in OKLab, 2 in OKLCH; FigCraft gets sRGB stops along +/// the same colours (values from Sketch's render of this gradient), and a note. +#[test] +fn oklab_gradients_become_srgb_stops() { + let stops = json!([{"_class": "gradientStop", "position": 0, "color": color(0.357, 0.294, 1.0, 1.0)}, + {"_class": "gradientStop", "position": 1, "color": color(1.0, 0.478, 0.349, 1.0)}]); + let grad = |mode: i64| { + json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "colorInterpolation": mode, "from": "{0, 0.5}", "to": "{1, 0.5}", "stops": stops.clone()}}) + }; + let (doc, warnings) = open(vec![ + styled(r("Srgb"), "fills", json!([grad(0)])), + styled(r("Oklab"), "fills", json!([grad(1)])), + styled(r("Oklch"), "fills", json!([grad(2)])), + ]); + let middle = |name: &str| { + let PaintKind::Gradient(g) = &node(&doc, name).fills[0].kind else { panic!("{name}: no gradient") }; + let mid = g.stops.iter().find(|s| (s.position - 0.5).abs() < 1e-6).map(|s| [s.color.r, s.color.g, s.color.b]); + (g.stops.len(), mid) + }; + assert_eq!(middle("Srgb"), (2, None)); + let near = |got: Option<[f32; 3]>, want: [f32; 3]| got.is_some_and(|g| g.iter().zip(want).all(|(a, b)| (a * 255.0 - b).abs() <= 2.0)); + let (n, mid) = middle("Oklab"); + assert_eq!(n, 17); + assert!(near(mid, [170.0, 116.0, 184.0]), "{mid:?}"); + let (_, mid) = middle("Oklch"); + assert!(near(mid, [214.0, 75.0, 186.0]), "{mid:?}"); + assert!(warnings.iter().any(|w| w.contains("OKLab")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index dd806282..aab5ef5c 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -117,8 +117,9 @@ pub(crate) fn master_fills(m: &Value) -> Option { /// 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))); + // A symbol clips only with `clippingBehavior` 1, whatever its `groupBehavior`; 0 (the + // default) and 2 don't. + node.insert("clipsContent".into(), json!(m.get("clippingBehavior").and_then(Value::as_i64) == Some(1))); if m.get("groupBehavior").is_some() && let Some(st) = m.get("style") { @@ -190,10 +191,12 @@ 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))); 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) - { + // An instance shows its master's background (its fills, or an older file's background + // colour) only when the master includes it both in instances and in exports. + let shown = bool_field(&m, "includeBackgroundColorInInstance") == Some(true) && bool_field(&m, "includeBackgroundColorInExport") == Some(true); + if !shown { + node.remove("fills"); + } else if 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. @@ -224,7 +227,7 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo 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(l.get("style"), cx.tints)) .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) @@ -427,6 +430,21 @@ fn apply( f.insert("fillType".into(), json!(0)); f.insert("color".into(), c.clone()); } + // An image override on a shape: its top image fill's image. + if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) + && let Some(f) = l + .get_mut("style") + .and_then(|s| s.get_mut("fills")) + .and_then(Value::as_array_mut) + .and_then(|f| { + f.iter_mut().rev().find(|p| { + p.get("fillType").and_then(Value::as_i64) == Some(4) && p.get("isEnabled").and_then(Value::as_bool) != Some(false) + }) + }) + .and_then(Value::as_object_mut) + { + f.insert("image".into(), img.clone()); + } } } for (prop, text) in [("layerStyle", false), ("textStyle", true)] { diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs index 7c2d73db..1d675e4a 100644 --- a/crates/format/src/sketch/symbols_tests.rs +++ b/crates/format/src/sketch/symbols_tests.rs @@ -233,26 +233,128 @@ fn scale_zero_is_unscaled() { assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}"); } -/// A master's background shows in its instances only when Sketch says so. +/// A master's background shows in its instances only when the master includes it both in +/// instances and in exports (Sketch's own renders and canvas, on files built for it). #[test] fn master_background_in_instances_only_when_included() { - let bg = |id: &str, sid: &str, include: bool| { + let bg = |id: &str, sid: &str, instances: bool, exports: bool| { let mut m = master(id, sid, id, 20.0, 20.0, vec![]); m["hasBackgroundColor"] = json!(true); m["backgroundColor"] = json!({"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1}); - m["includeBackgroundColorInInstance"] = json!(include); + m["includeBackgroundColorInInstance"] = json!(instances); + m["includeBackgroundColorInExport"] = json!(exports); m }; let (doc, _) = open( - vec![instance("Hidden", "SH", 0.0, 0.0, 20.0, 20.0, &[]), instance("Shown", "SS", 50.0, 0.0, 20.0, 20.0, &[])], - vec![bg("MH", "SH", false), bg("MS", "SS", true)], + vec![ + instance("Hidden", "SH", 0.0, 0.0, 20.0, 20.0, &[]), + instance("InstancesOnly", "SI", 25.0, 0.0, 20.0, 20.0, &[]), + instance("ExportsOnly", "SE", 50.0, 0.0, 20.0, 20.0, &[]), + instance("Shown", "SS", 75.0, 0.0, 20.0, 20.0, &[]), + ], + vec![bg("MH", "SH", false, false), bg("MI", "SI", true, false), bg("ME", "SE", false, true), bg("MS", "SS", true, true)], ); let (hidden, main) = instance_of(&doc, "Hidden"); assert!(hidden.fills.is_empty(), "{:?}", hidden.fills); assert_eq!(main.fills.len(), 1, "the master shows its background"); + assert!(instance_of(&doc, "InstancesOnly").0.fills.is_empty()); + assert!(instance_of(&doc, "ExportsOnly").0.fills.is_empty()); assert_eq!(instance_of(&doc, "Shown").0.fills.len(), 1); } +/// Sketch 2025+: a symbol's (`groupBehavior` 0) or frame master's fills are its background, shown +/// in instances on the same terms. +#[test] +fn style_background_in_instances_only_when_included() { + let m = |id: &str, sid: &str, behaviour: i64, shown: bool| { + let mut m = master(id, sid, id, 20.0, 20.0, vec![]); + m["groupBehavior"] = json!(behaviour); + m["style"]["fills"] = json!([solid_fill(1.0, 1.0, 1.0, 1.0)]); + m["includeBackgroundColorInInstance"] = json!(shown); + m["includeBackgroundColorInExport"] = json!(shown); + m + }; + let (doc, _) = open_version( + "2026.3.1", + 196, + vec![ + instance("Symbol", "S0", 0.0, 0.0, 20.0, 20.0, &[]), + instance("Frame", "S1", 25.0, 0.0, 20.0, 20.0, &[]), + instance("Shown", "S2", 50.0, 0.0, 20.0, 20.0, &[]), + ], + vec![m("M0", "S0", 0, false), m("M1", "S1", 1, false), m("M2", "S2", 0, true)], + ); + assert!(instance_of(&doc, "Symbol").0.fills.is_empty()); + assert!(instance_of(&doc, "Frame").0.fills.is_empty()); + assert_eq!(instance_of(&doc, "Shown").0.fills.len(), 1); +} + +/// A symbol clips its layers only with `clippingBehavior` 1, whatever its `groupBehavior`. +#[test] +fn symbols_clip_only_when_asked() { + let m = |id: &str, sid: &str, behaviour: Option, clipping: Option| { + let mut m = master(id, sid, id, 20.0, 20.0, vec![rect(&format!("R{id}"), 0.0, 10.0, 20.0, 20.0)]); + if let Some(b) = behaviour { + m["groupBehavior"] = json!(b); + } + if let Some(c) = clipping { + m["clippingBehavior"] = json!(c); + } + m + }; + let masters = vec![ + m("M0", "S0", Some(0), Some(0)), + m("M1", "S1", Some(1), Some(0)), + m("M2", "S2", Some(2), Some(0)), + m("M3", "S3", Some(1), Some(1)), + m("M4", "S4", Some(1), Some(2)), + m("M5", "S5", None, None), + ]; + let layers = (0..6).map(|i| instance(&format!("I{i}"), &format!("S{i}"), 30.0 * f64::from(i), 0.0, 20.0, 20.0, &[])).collect(); + let (doc, _) = open_version("2026.3.1", 196, layers, masters); + let clips: Vec = (0..6).map(|i| instance_of(&doc, &format!("I{i}")).0.clip).collect(); + assert_eq!(clips, [false, false, false, true, false, false]); +} + +/// An image override on a shape replaces its top image fill's image. +#[test] +fn image_overrides_on_shapes() { + let pattern = |r: &str| { + json!({"_class": "fill", "isEnabled": true, "fillType": 4, "patternFillType": 1, "patternTileScale": 1, + "image": {"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": r}}) + }; + let avatar = styled(rect("Avatar", 0.0, 0.0, 20.0, 20.0), "fills", json!([pattern("images/a.png"), pattern("images/b.png")])); + let over = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/c.png"}); + let png = |n: u8| -> Vec { vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, n] }; + let (a, b, c) = (png(1), png(2), png(3)); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[ + page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 20.0, 20.0, &[("Avatar_image", over)])]), + page("S", "Symbols", vec![master("M", "S", "Contact", 20.0, 20.0, vec![avatar])]), + ], + &[("images/a.png", &a), ("images/b.png", &b), ("images/c.png", &c)], + ); + let (doc, _) = import(&z, "f").unwrap(); + let (inst, main) = instance_of(&doc, "I"); + let images = |n: &Node| -> Vec { + n.fills + .iter() + .filter_map(|p| match &p.kind { + figcraft_doc::color::PaintKind::Image(i) => Some(i.image.clone()), + _ => None, + }) + .collect() + }; + let (shown, master_images) = (images(inside(&doc, inst, "Avatar")), images(inside(&doc, main, "Avatar"))); + assert_eq!(shown.len(), 2); + assert_ne!(master_images[0], master_images[1]); + assert_eq!(shown[0], master_images[0], "the lower fill keeps its image"); + assert_ne!(shown[1], master_images[1], "the top fill takes the override"); +} + /// How Sketch lays out a resized instance's layers, measured on its renders: a master of 200×100 /// with a 100×40 rectangle at (20, 30), shown at 320×60. #[test] diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 8bad4c6c..5e2686e7 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -50,6 +50,9 @@ pub(crate) struct Cx { /// The file is older than format 110 (Sketch 52): Sketch scales a resized instance's layers /// instead of following their pins. pub legacy_resize: bool, + /// The file is newer than format 123 (Sketch 63): a group's or instance's plain fill can be a + /// tint (Sketch 64 added tints; older files' group fills draw nothing). + pub tints: bool, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -122,6 +125,7 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Op operand_fill: None, operand_border: false, legacy_resize: archive.version > 0 && archive.version < 110, + tints: archive.version == 0 || archive.version > 123, chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -226,6 +230,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: super::path::put_shape(cx, shape, &mut node); } mask(l, &mut node); + let mut bitmap = None; match class { "artboard" | "frame" => { let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; @@ -247,13 +252,21 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: if let Some(f) = super::style::color_controls(cx, l.get("style")) { paint["filters"] = f; } - node.insert("fills".into(), json!([paint])); + bitmap = Some(paint); } None => cx.warn("images stored inside the JSON were skipped"), }, _ => {} } styles(cx, l, w, h, &mut node); + // A bitmap's own image lies under its style's fills. + if let Some(paint) = bitmap { + let mut fills = vec![paint]; + if let Some(Value::Array(f)) = node.remove("fills") { + fills.extend(f); + } + node.insert("fills".into(), Value::Array(fills)); + } extras(cx, l, h, &mut node); style_corners(l, &mut node); if class == "MSImmutableHotspotLayer" || class == "hotspot" { @@ -263,7 +276,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: // 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 tint = if class == "group" { super::style::tint_of(l.get("style"), gb != Some(1)) } else { None }; + let tint = if class == "group" { super::style::tint_of(l.get("style"), gb != Some(1) && cx.tints) } else { None }; let l = match tint.filter(|_| spend_on_tint(cx, l)) { Some(t) => { // A plain group's legacy fill is all tint; a frame keeps its background fills. @@ -538,7 +551,8 @@ fn clip(s: &str) -> String { /// the end, the mask and what it clips are wrapped in a group of their own, since a FigCraft mask /// clips everything above it. pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), depth: usize, root: bool) -> Vec { - let built: Vec<(bool, Value)> = layers(l) + // (ends a mask chain, is a drawn copy of the mask after it, the node) + let built: Vec<(bool, bool, Value)> = layers(l) .iter() .filter_map(|c| { let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true); @@ -548,15 +562,21 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), // Sketch also draws a mask with its own style, under what it clips; a FigCraft mask // only clips, so a plain copy of it goes underneath. // The copy ends an earlier mask's chain as the mask itself would. - Some(copy) => vec![(breaks, copy), (breaks, n)], - None => vec![(breaks, n)], + Some(copy) => vec![(breaks, true, copy), (breaks, false, n)], + None => vec![(breaks, false, n)], }) .collect(); let mut out = Vec::new(); let mut i = 0; - while let Some((_, n)) = built.get(i) { + while let Some((_, _, n)) = built.get(i) { + // An outline mask's opacity fades the mask and what it clips together, as one group (an + // alpha mask's opacity already does, as alpha). + let fade = (n.get("isMask").and_then(Value::as_bool) == Some(true) && n.get("maskType").and_then(Value::as_str) != Some("ALPHA")) + .then(|| n.get("opacity").and_then(Value::as_f64)) + .flatten() + .filter(|o| o.is_finite() && *o < 1.0); let end = if n.get("isMask").and_then(Value::as_bool) == Some(true) { - built.iter().skip(i + 1).position(|(b, _)| *b).map(|k| i + 1 + k) + built.iter().skip(i + 1).position(|(b, _, _)| *b).map(|k| i + 1 + k).or_else(|| fade.map(|_| built.len())) } else { None }; @@ -565,7 +585,29 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), i += 1; continue; }; - let masked: Vec = built.get(i..end).unwrap_or_default().iter().map(|(_, n)| n.clone()).collect(); + // A faded mask's drawn copy goes into the group too, at full opacity. + let start = match (fade, i.checked_sub(1).and_then(|k| built.get(k))) { + (Some(_), Some((_, true, _))) => { + out.pop(); + i - 1 + } + _ => i, + }; + let masked: Vec = built + .get(start..end) + .unwrap_or_default() + .iter() + .map(|(_, copy, n)| { + let mut n = n.clone(); + if *copy + && fade.is_some() + && let Some(m) = n.as_object_mut() + { + m.remove("opacity"); + } + n + }) + .collect(); let mut g = JsonMap::new(); g.insert("type".into(), json!("GROUP")); g.insert("name".into(), json!("Mask group")); @@ -575,6 +617,9 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1)); g.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); } + if let Some(o) = fade { + g.insert("opacity".into(), json!(o.clamp(0.0, 1.0))); + } g.insert("children".into(), Value::Array(masked)); out.push(Value::Object(g)); i = end; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 48cacbbb..962ac750 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (`.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (`.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders; `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -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) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | 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 with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; 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 (hidden, as Sketch shows them only in its layout view). 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), fonts embedded with their data (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 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); 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 (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | +| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | 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, stretching filling every item across, 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; from Sketch 64, before which a group's fill draws nothing), 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); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer blurs (Sketch's radius is the standard deviation) and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; 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, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), 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 when the master includes them in both instances and exports, as on Sketch's canvas and renders; 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 (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (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 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); 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 (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` 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`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | @@ -166,6 +166,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | `.sketch` import checked rule by rule against Sketch's own renders of files built for each case and against real files (Sketch 50, 63 and 2026): instances show a master's background only when it is included in instances and exports, symbols clip only when set to, group fills are tints only from Sketch 64, an outline mask's opacity fades what it clips, a bitmap's image lies under its fills, image overrides reach shapes' image fills, stretching stacks fill items across, layer blur radii and colour adjustments follow Sketch's maths, OKLab and OKLCH gradients become sRGB stops, auto-width texts grow from their aligned edge, shape group operands lose their own borders. Known limitations listed in the row. Numbers unchanged | | 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged | | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged | | 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before |