M6: Sketch import (#1021 follow-up): match Sketch's renders rule by rule

Each rule was checked against Sketch's own renders (sketchtool) of files built
for the case, and on real Sketch 50, 63 and 2026 files:

- instances show a master's background only when it is included in both
  instances and exports
- symbols clip their layers only with clippingBehavior 1
- group and instance fills are tints only from Sketch 64 (format > 123)
- an outline mask's opacity fades the mask and what it clips together
- a bitmap's image lies under its style's fills
- image overrides reach a shape's top image fill
- stretching stacks fill their items across
- a layer blur's radius is Sketch's standard deviation (FigCraft radius x2)
- colour adjustments: contrast, then brightness added, mapped exactly onto
  exposure and contrast within FigCraft's ranges
- gradients mixed in OKLab or OKLCH become sRGB stops along the same colours
  (with an import note)
- auto-width texts an override made longer grow from their aligned edge
- shape group operands lose their own borders (since #1184 a stroke-only
  operand gives its stroke band, not its area)

Known limitations are listed in the file-format parity row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jindřich Šaršon
2026-10-26 17:43:10 +01:00
co-authored by Claude Opus 5.5
parent 63d926876f
commit 267fc624ad
12 changed files with 573 additions and 28 deletions
+16
View File
@@ -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);
}
+43
View File
@@ -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:?}");
}
+40 -1
View File
@@ -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);
}
+21
View File
@@ -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<Value> {
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));
+17
View File
@@ -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);
}
}
+9 -1
View File
@@ -91,7 +91,15 @@ fn item(src: &Value, m: &mut JsonMap<String, Value>, 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) };
+122 -4
View File
@@ -71,7 +71,12 @@ pub(crate) fn clear(node: &mut JsonMap<String, Value>) {
fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option<Value> {
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<Value> {
.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<Value> {
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<Value
if get("hue", 0.0).abs() > 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<f64> {
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<Value> {
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
}
}
+117
View File
@@ -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:?}");
}
+25 -7
View File
@@ -117,8 +117,9 @@ pub(crate) fn master_fills(m: &Value) -> Option<Value> {
/// 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<String, Value>) {
// `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)] {
+107 -5
View File
@@ -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<i64>, clipping: Option<i64>| {
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<bool> = (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<u8> { 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<String> {
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]
+53 -8
View File
@@ -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<String>,
pub warnings: Vec<String>,
@@ -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<Value> {
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<Value> = 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<Value> = 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;
File diff suppressed because one or more lines are too long