diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index c1ac6a59..cee351b8 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -219,3 +219,88 @@ fn local_sketch_samples_open() { } } } + +/// Local only (`FIGCRAFT_SKETCH_SAMPLES` and `FIGCRAFT_SKETCHTOOL`, the path of Sketch's +/// `sketchtool`): every artboard of each sample (Sketch 48 and newer) drawn by FigCraft is compared +/// with Sketch's own export of it. Prints the share of pixels that differ per artboard (a channel +/// off by more than 40, on white); fails past 10% (a broken artboard, not antialiasing or fonts). +#[test] +fn local_sketch_samples_match_sketch() { + let (Ok(dir), Ok(tool)) = (std::env::var("FIGCRAFT_SKETCH_SAMPLES"), std::env::var("FIGCRAFT_SKETCHTOOL")) else { return }; + // Text draws in the fonts Sketch uses only once the system fonts are known. + figcraft_text::fontdb::scan_system_fonts(); + let mut files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect(); + files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch"))); + files.sort(); + let mut worst: Vec<(f64, String)> = Vec::new(); + for path in files { + let mut s = Session::new(); + if s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err() { + continue; + } + let out = std::env::temp_dir().join(format!("figcraft-sketch-ref-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let status = std::process::Command::new(&tool) + .args(["export", "artboards"]) + .arg(&path) + .arg(format!("--output={}", out.display())) + .args(["--formats=png", "--scales=1", "--use-id-for-name=YES", "--include-symbols=NO"]) + .output() + .unwrap(); + assert!(status.status.success(), "sketchtool failed on {}", path.display()); + // Artboards by page and order, from the file itself. + let archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap(); + let mut archive = archive; + let mut read = |name: &str| -> Value { + let mut s = String::new(); + std::io::Read::read_to_string(&mut archive.by_name(name).unwrap(), &mut s).unwrap(); + serde_json::from_str(&s).unwrap() + }; + let document = read("document.json"); + let doc = s.doc().unwrap(); + let refs: Vec = document["pages"].as_array().unwrap().iter().map(|r| r["_ref"].as_str().unwrap().to_string()).collect(); + for (page_ref, page_id) in refs.iter().zip(doc.pages.iter()) { + let page = read(&format!("{page_ref}.json")); + let boards: Vec = page["layers"] + .as_array() + .unwrap() + .iter() + .filter(|l| l["_class"] == "artboard") + .map(|l| l["do_objectID"].as_str().unwrap().to_string()) + .collect(); + let frames: Vec = + doc.node(*page_id).unwrap().children.iter().copied().filter(|c| matches!(doc.node(*c).unwrap().kind, NodeKind::Frame)).collect(); + assert_eq!(boards.len(), frames.len(), "{}: artboards of {page_ref}", path.display()); + for (id, frame) in boards.iter().zip(frames) { + let Ok(reference) = image::open(out.join(format!("{id}.png"))) else { continue }; + let reference = reference.to_rgba8(); + let ours = figcraft_render::render_node(doc, frame, 1.0).unwrap(); + let (w, h) = (reference.width().min(ours.width()), reference.height().min(ours.height())); + let ours_rgba = figcraft_render::to_rgba8(&ours); + // Both on white, straight alpha. + let on_white = |c: &[u8], i: usize| { + let a = f64::from(c[3]) / 255.0; + f64::from(c[i]) * a + 255.0 * (1.0 - a) + }; + let mut bad = 0u64; + for y in 0..h { + for x in 0..w { + let r = reference.get_pixel(x, y).0; + let at = ((y * ours.width() + x) * 4) as usize; + let Some(o) = ours_rgba.get(at..at + 4) else { continue }; + if (0..3).any(|i| (on_white(&r, i) - on_white(o, i)).abs() > 40.0) { + bad += 1; + } + } + } + let share = bad as f64 * 100.0 / (f64::from(w) * f64::from(h)).max(1.0); + let name = doc.node(frame).unwrap().name.clone(); + eprintln!("{share:6.2}% {} {name}", path.file_name().unwrap().to_string_lossy()); + worst.push((share, format!("{}: {name}", path.display()))); + } + } + let _ = std::fs::remove_dir_all(&out); + } + worst.sort_by(|a, b| b.0.total_cmp(&a.0)); + assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}"); +} diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 30a9f256..149b4870 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -4,7 +4,10 @@ //! and `curveTo` (the handle arriving at it) are `"{x, y}"` strings in 0–1 of the layer's frame; //! values outside that range are allowed. Sketch 51 and older wrap every shape in a `shapeGroup` //! that carries the style; its children combine left to right, each with its own -//! `booleanOperation` (−1 none and 0 union, 1 subtract, 2 intersect, 3 difference). +//! `booleanOperation` (0 union, 1 subtract, 2 intersect, 3 difference). Children with −1 (none) +//! don't combine: they join the path before them into one compound path, filled by the winding +//! rule (`windingRule` on the layer, 1 even-odd: inner paths are holes). Sketch fills an open path +//! as if it were closed. use figcraft_doc::geom::{Affine, polygon_points, star_points}; use figcraft_doc::{Region, Segment, VectorNetwork, Vertex, WindingRule}; @@ -32,8 +35,26 @@ pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> { Some((n(x)?, n(y)?)) } -/// The network of a path layer of `w`×`h` (`None` when it has no usable points). -pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { +/// A layer's winding rule: on the layer (Sketch 51 and older), else in its style. +pub(crate) fn winding(l: &Value) -> WindingRule { + let rule = l.get("windingRule").or_else(|| l.get("style").and_then(|s| s.get("windingRule"))); + match rule.and_then(Value::as_i64) { + Some(1) => WindingRule::EvenOdd, + _ => WindingRule::NonZero, + } +} + +/// Whether a style shows any fill or border. +fn shows(style: Option<&Value>, key: &str) -> bool { + style + .and_then(|s| s.get(key)) + .and_then(Value::as_array) + .is_some_and(|l| l.iter().any(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false))) +} + +/// The network of a path layer of `w`×`h` (`None` when it has no usable points). `fill_open`: an +/// open path gets a closing segment and a fill region, as Sketch fills it. +pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -> Option { let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); if pts.len() > MAX_POINTS { cx.warn(format!("paths with more than {MAX_POINTS} points were cut")); @@ -61,20 +82,20 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option 1; + let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false); + let closed = n > 1 && (!open || (fill_open && n > 2)); let count = if closed { n } else { n - 1 }; let segments: Vec = (0..count) .map(|i| { let j = (i + 1) % n; - let ta = handles.get(i).map_or((0.0, 0.0), |h| h.0); - let tb = handles.get(j).map_or((0.0, 0.0), |h| h.1); + // The edge that closes an open path for its fill is straight. + let closing = open && j == 0; + let ta = if closing { (0.0, 0.0) } else { handles.get(i).map_or((0.0, 0.0), |h| h.0) }; + let tb = if closing { (0.0, 0.0) } else { handles.get(j).map_or((0.0, 0.0), |h| h.1) }; Segment { a: i, b: j, ta, tb } }) .collect(); - let winding = match l.get("style").and_then(|s| s.get("windingRule")).and_then(Value::as_i64) { - Some(1) => WindingRule::EvenOdd, - _ => WindingRule::NonZero, - }; + let winding = winding(l); let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() }; Some(VectorNetwork { vertices, segments, regions }) } @@ -82,7 +103,14 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option Option { - let net = network(cx, l, w, h)?; + // Inside a shape group the group's fill decides; a shape of its own, its style. + let style = l.get("style"); + let filled = cx.operand_fill.unwrap_or_else(|| shows(style, "fills")); + let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false); + if open && filled && (shows(style, "borders") || cx.operand_border) { + cx.warn("open paths with both a fill and a border were closed; the border runs along the closing edge too"); + } + let net = network(cx, l, w, h, filled)?; let class = l.get("_class").and_then(Value::as_str).unwrap_or(""); let count = l.get("numberOfPoints").and_then(Value::as_u64).filter(|c| (3..=120).contains(c)).and_then(|c| u32::try_from(c).ok()); let plain = net.vertices.iter().all(|v| v.radius == 0.0) && net.segments.iter().all(|s| s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0)); @@ -123,41 +151,98 @@ pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap Option { let (w, h) = size(l); let world = parent * local(l, w, h); let kids = layers(l); - if let [only] = kids { - let mut node = layer(cx, only, world, depth + 1, false)?; - let (cw, ch) = size(only); - let child_world = world * local(only, cw, ch); - let group = base(l, "GROUP", parent, child_world, cw, ch, root); - if let Some(m) = node.as_object_mut() { - for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { - match group.get(key) { - Some(v) => m.insert(key.into(), v.clone()), - None => m.remove(key), - }; - } - let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); - m.insert("visible".into(), json!(visible)); - if l.get("style").is_some() { - super::style::clear(m); - m.remove("styles"); - super::tree::styles(cx, l, cw, ch, m); - super::tree::extras(cx, l, ch, m); - } - super::tree::mask(l, m); + let saved = (cx.operand_fill, cx.operand_border); + cx.operand_border = shows(l.get("style"), "borders"); + let out = if let [only] = kids { + cx.operand_fill = Some(shows(l.get("style"), "fills")); + single(cx, l, only, parent, world, depth, root) + } else { + // Operands of a boolean operation (or a compound path) give area: open ones close. + cx.operand_fill = Some(true); + combined(cx, l, kids, parent, world, (w, h), depth, root) + }; + (cx.operand_fill, cx.operand_border) = saved; + out +} + +/// 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)?; + let (cw, ch) = size(only); + let child_world = world * local(only, cw, ch); + let group = base(l, "GROUP", parent, child_world, cw, ch, root); + if let Some(m) = node.as_object_mut() { + for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { + match group.get(key) { + Some(v) => m.insert(key.into(), v.clone()), + None => m.remove(key), + }; } - return Some(node); + let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); + m.insert("visible".into(), json!(visible)); + if l.get("style").is_some() { + super::style::clear(m); + m.remove("styles"); + super::tree::styles(cx, l, cw, ch, m); + super::tree::extras(cx, l, ch, m); + } + super::tree::mask(l, m); + } + Some(node) +} + +/// A shape group of several shapes: one compound path, or boolean operations over runs. +fn combined(cx: &mut Cx, l: &Value, kids: &[Value], parent: Affine, world: Affine, (w, h): (f64, f64), depth: usize, root: bool) -> Option { + // Runs: a child with an operation starts one, children without (−1) join it. + let mut runs: Vec> = Vec::new(); + for (i, c) in kids.iter().enumerate() { + match runs.last_mut() { + Some(run) if i > 0 && c.get("booleanOperation").and_then(Value::as_i64).unwrap_or(-1) < 0 => run.push(c), + _ => runs.push(vec![c]), + } + } + let winding = winding(l); + if let [run] = runs.as_slice() + && let Some(net) = compound(cx, run, winding) + { + // No operations at all: the group is one path. + let mut node = base(l, "VECTOR", parent, world, w, h, root); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + super::tree::mask(l, &mut node); + super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); + return Some(Value::Object(node)); } let mut chain: Vec = Vec::new(); let mut op = "UNION"; - for (i, c) in kids.iter().enumerate() { - let Some(child) = layer(cx, c, world, depth + 2, false) else { continue }; - let this = boolean_op(c); + for (i, run) in runs.iter().enumerate() { + let operands: Vec = match run.as_slice() { + [one] => layer(cx, one, world, depth + 2, false).into_iter().collect(), + more => match compound(cx, more, winding) { + Some(net) => { + let mut node = base(more.first().copied().unwrap_or(l), "VECTOR", world, world, w, h, false); + node.remove("id"); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + vec![Value::Object(node)] + } + // Not all paths: each one an operand of its own. + None => more.iter().filter_map(|c| layer(cx, c, world, depth + 2, false)).collect(), + }, + }; + if operands.is_empty() { + continue; + } + 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)); chain.push(inner); @@ -165,7 +250,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, if i > 0 { op = this; } - chain.push(child); + chain.extend(operands); } if chain.is_empty() { return None; @@ -179,6 +264,42 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, Some(Value::Object(node)) } +/// The paths `run` as one network in their group's space, filled by `winding`; `None` when one of +/// them isn't a path (a group, text, …). +fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option { + const PATHS: [&str; 6] = ["shapePath", "rectangle", "oval", "star", "polygon", "triangle"]; + if run.iter().any(|c| !c.get("_class").and_then(Value::as_str).is_some_and(|k| PATHS.contains(&k))) { + return None; + } + let mut out = VectorNetwork::default(); + let mut loops = Vec::new(); + for c in run { + let (cw, ch) = size(c); + let Some(net) = network(cx, c, cw, ch, true) else { continue }; + let xf = local(c, cw, ch); + let [a, b, cc, d, _, _] = xf.as_coeffs(); + let lin = |(x, y): (f64, f64)| (a * x + cc * y, b * x + d * y); + let (v0, s0) = (out.vertices.len(), out.segments.len()); + for v in net.vertices { + let p = xf * figcraft_doc::geom::Point::new(v.x, v.y); + out.vertices.push(Vertex { x: p.x, y: p.y, ..v }); + } + for sg in net.segments { + out.segments.push(Segment { a: sg.a + v0, b: sg.b + v0, ta: lin(sg.ta), tb: lin(sg.tb) }); + } + for r in net.regions { + loops.extend(r.loops.into_iter().map(|lp| lp.into_iter().map(|i| i + s0).collect::>())); + } + } + if out.vertices.is_empty() { + return None; + } + if !loops.is_empty() { + out.regions.push(Region { loops, winding }); + } + Some(out) +} + /// A boolean operation the shape group doesn't have as a layer of its own: placed like the group. fn synthetic(l: &Value, op: &str, parent: Affine, world: Affine, w: f64, h: f64, children: Vec) -> Value { let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, false); diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs index fb03d74c..70ad9527 100644 --- a/crates/format/src/sketch/path_tests.rs +++ b/crates/format/src/sketch/path_tests.rs @@ -165,3 +165,71 @@ fn huge_points_stay_finite() { assert!(network.segments.iter().all(|s| [s.ta.0, s.ta.1, s.tb.0, s.tb.1].iter().all(|c| c.is_finite()))); } } + +/// Sketch 51 and older: a shape group whose children have no operation (−1) is one compound path, +/// filled by the group's winding rule (stored on the layer itself). +#[test] +fn compound_shape_group_has_holes() { + let outer = with_op(rect("Outer", 0.0, 0.0, 100.0, 100.0), -1); + let inner = with_op(rect("Inner", 25.0, 25.0, 50.0, 50.0), -1); + let mut g = shape_group("Ring", 10.0, 10.0, 100.0, 100.0, vec![outer, inner]); + g["windingRule"] = json!(1); + let doc = open(vec![g]); + let n = node(&doc, "Ring"); + let NodeKind::Vector { network } = &n.kind else { panic!("not one vector: {:?}", n.kind) }; + assert_eq!(network.regions.len(), 1); + assert_eq!(network.regions[0].loops.len(), 2); + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); + let b = doc.world_bounds(n.id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 10.0, 100.0, 100.0)); +} + +#[test] +fn compound_runs_are_boolean_operands() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 60.0, 0.0, 20.0, 20.0), -1); + let c = with_op(rect("C", 10.0, 10.0, 10.0, 10.0), 1); + let doc = open(vec![shape_group("Mixed", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]); + let n = node(&doc, "Mixed"); + assert!(matches!(n.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }), "{:?}", n.kind); + let first = doc.node(n.children[0]).unwrap(); + let NodeKind::Vector { network } = &first.kind else { panic!("first operand isn't the compound path") }; + assert_eq!(network.regions[0].loops.len(), 2); +} + +#[test] +fn layer_winding_rule_counts() { + let mut p = shape_path("Wound", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["windingRule"] = json!(1); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Wound").kind else { panic!("not a vector") }; + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); +} + +/// Sketch fills an open path as if it were closed. +#[test] +fn filled_open_paths_are_closed() { + let open_path = shape_path("Lid", 0.0, 0.0, 40.0, 20.0, &[(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)], false); + let filled = styled(shape_group("Filled", 0.0, 0.0, 40.0, 20.0, vec![open_path.clone()]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let stroked = styled( + shape_group("Stroked", 0.0, 50.0, 40.0, 20.0, vec![open_path]), + "borders", + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 0, "thickness": 1}]), + ); + let doc = open(vec![filled, stroked]); + let NodeKind::Vector { network } = &node(&doc, "Filled").kind else { panic!("not a vector") }; + assert_eq!(network.regions.len(), 1); + assert_eq!(network.segments.len(), 3); + let NodeKind::Vector { network } = &node(&doc, "Stroked").kind else { panic!("not a vector") }; + assert!(network.regions.is_empty()); + assert_eq!(network.segments.len(), 2); +} + +#[test] +fn open_operands_of_booleans_are_closed() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(shape_path("Snout", 40.0, 10.0, 30.0, 20.0, &[(0.0, 0.0), (1.0, 0.5), (0.0, 1.0)], false), 0); + let doc = open(vec![styled(shape_group("Head", 0.0, 0.0, 70.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]))]); + let NodeKind::Vector { network } = &node(&doc, "Snout").kind else { panic!("not a vector") }; + assert_eq!(network.regions.len(), 1); +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index de2529aa..f13515a6 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -243,6 +243,21 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { out } +/// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral, +/// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note). +pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option { + let c = style?.get("colorControls").filter(|c| c.get("isEnabled").and_then(Value::as_bool) == Some(true))?; + let get = |k: &str, neutral: f64| num(c, k).unwrap_or(neutral); + if get("hue", 0.0).abs() > 1e-6 { + cx.warn("hue changes of bitmaps were skipped"); + } + 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), + })) +} + /// The variable binding of a colour that names a swatch the file has (`{color: alias}`). pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { let id = c?.get("swatchID").and_then(Value::as_str).filter(|id| cx.shared.has_swatch(id))?; diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index b1b2801b..242b0478 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -295,3 +295,22 @@ fn two_styled_masks_each_draw() { let first_group = node(&doc, "Mask1").parent.unwrap(); assert_ne!(copy.parent, Some(first_group)); } + +#[test] +fn bitmap_color_controls_become_filters() { + 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 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": 0, "contrast": 1, "hue": 0.5, "saturation": 0}); + let (doc, warnings) = open_doc(document(&["P"]), vec![bitmap], &[("images/a.png", &png)]); + let PaintKind::Image(img) = &node(&doc, "Photo").fills[0].kind else { panic!("no image") }; + assert_eq!(img.filters.saturation, -1.0); + assert_eq!(img.filters.contrast, 0.0); + assert!(warnings.iter().any(|w| w.contains("hue")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index b87b7138..cabc2fd4 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -155,7 +155,8 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo cx.warn(format!("symbol {name} contains itself; the inner copy was left out")); return None; } - if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && (s - 1.0).abs() > 1e-9) { + // Sketch 50 stores "not scaled" as 0. + if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && s != 0.0 && (s - 1.0).abs() > 1e-9) { cx.warn("scaled symbol instances were imported at their size, unscaled"); } let mut node = base(l, "INSTANCE", parent, world, w, h, root); diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs index 4c98ebe2..64c7ae7f 100644 --- a/crates/format/src/sketch/symbols_tests.rs +++ b/crates/format/src/sketch/symbols_tests.rs @@ -217,3 +217,12 @@ fn override_fan_out_is_bounded() { let (_, warnings) = import(&z, "f").unwrap(); assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); } + +/// Sketch 50 stores "not scaled" as `scale: 0`. +#[test] +fn scale_zero_is_unscaled() { + let mut i = instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[]); + i["scale"] = json!(0); + let (_, warnings) = open(vec![i], vec![button()]); + assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 3cdb2afc..14357cfc 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -93,6 +93,29 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { } } +/// How far a text layer moves so its lines sit where Sketch draws them. With a fixed line height, +/// Sketch puts a line's extra height (line height − the font's own, taken as 1.2 × its size) above +/// the text (`lineSpacingBehaviour` 0) or below it (1); FigCraft, like Sketch's default (2), puts +/// half on each side. Every line gets the same extra, so the whole layer moves. +pub(crate) fn baseline_shift(l: &Value) -> f64 { + let first = l + .get("attributedString") + .and_then(|a| a.get("attributes")) + .and_then(Value::as_array) + .and_then(|r| r.first()) + .and_then(|r| r.get("attributes")); + let Some(a) = first else { return 0.0 }; + let size = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")).and_then(|f| f.get("size")).and_then(Value::as_f64); + let line = a.get("paragraphStyle").and_then(|p| p.get("maximumLineHeight").or_else(|| p.get("minimumLineHeight"))).and_then(Value::as_f64); + let (Some(size), Some(line)) = (size.filter(|s| s.is_finite() && *s > 0.0), line.filter(|v| v.is_finite() && *v > 0.0)) else { return 0.0 }; + let extra = (line - size * 1.2).clamp(-1e4, 1e4); + match l.get("lineSpacingBehaviour").and_then(Value::as_i64) { + Some(0) => extra / 2.0, + Some(1) => -extra / 2.0, + _ => 0.0, + } +} + /// A run's attributes as a REST type style (with its colour as `fills`). pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index a7ee96b0..f6b96738 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -187,3 +187,23 @@ fn embedded_fonts_are_reported() { let w = warnings.iter().find(|w| w.contains("embedded")).expect("a note about embedded fonts"); assert!(w.contains("IBM Plex Mono") && w.contains("IBM Plex Sans"), "{w}"); } + +/// Where Sketch puts a line's extra height: above the text (`lineSpacingBehaviour` 0), below it +/// (1) or around it (2, like FigCraft); the text layer moves by the difference. +#[test] +fn line_spacing_behaviour_moves_text() { + let mk = |name: &str, beh: i64| { + let mut t = text(name, 0.0, 100.0, 100.0, 30.0, "Hg", &[(0, 2, "Helvetica", 14.0, BLACK)]); + t["lineSpacingBehaviour"] = json!(beh); + let a = &mut t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]; + a["maximumLineHeight"] = json!(30); + a["minimumLineHeight"] = json!(30); + t + }; + let (doc, _) = open(vec![mk("B0", 0), mk("B1", 1), mk("B2", 2)]); + let y = |n: &str| doc.world_transform(doc.nodes().find(|x| x.name == n).unwrap().id).as_coeffs()[5]; + // Extra height: 30 − 14 × 1.2 = 13.2; half of it either way. + assert!((y("B2") - 100.0).abs() < 1e-6, "{}", y("B2")); + assert!((y("B0") - 106.6).abs() < 1e-6, "{}", y("B0")); + assert!((y("B1") - 93.4).abs() < 1e-6, "{}", y("B1")); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index c1bf0432..6642eb9f 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -43,6 +43,10 @@ pub(crate) struct Cx { pub max_work: u64, /// Notes given so far (each once). pub seen: std::collections::BTreeSet, + /// Inside a shape group: whether its shapes are filled (open ones then close) and whether the + /// group has a border. Outside one: `None` / false (a shape's own style decides). + pub operand_fill: Option, + pub operand_border: bool, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -93,6 +97,8 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { work: 0, max_work: MAX_WORK, seen: std::collections::BTreeSet::new(), + operand_fill: None, + operand_border: false, chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -154,6 +160,8 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let ty = match class { "artboard" | "frame" => "FRAME", "group" | "graphic" => "GROUP", + // Shapes whose points were moved are paths. + "rectangle" | "oval" if bool_field(l, "edited") == Some(true) => "VECTOR", "rectangle" | "bitmap" => "RECTANGLE", "oval" => "ELLIPSE", "slice" => "SLICE", @@ -169,7 +177,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: return None; } let (w, h) = size(l); - let world = parent * local(l, w, h); + let mut world = parent * local(l, w, h); + if ty == "TEXT" { + world *= Affine::translate((0.0, super::text::baseline_shift(l))); + } let mut node = base(l, ty, parent, world, w, h, root); if ty == "TEXT" { super::text::node(cx, l, &mut node); @@ -192,7 +203,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: "rectangle" => corner_radii(l, &mut node), "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { Some(r) => { - let paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + let mut paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + if let Some(f) = super::style::color_controls(cx, l.get("style")) { + paint["filters"] = f; + } node.insert("fills".into(), json!([paint])); } None => cx.warn("images stored inside the JSON were skipped"), @@ -281,7 +295,8 @@ pub(crate) fn size(l: &Value) -> (f64, f64) { } /// A layer's transform within its parent: its frame's position, then its rotation (degrees, -/// counter-clockwise on screen) and flips about its centre. +/// counter-clockwise on screen) and flips about its centre. Sketch flips on screen, after rotating +/// (a quarter-turned layer flipped left to right mirrors its own up and down). pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { let f = l.get("frame"); let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); @@ -289,8 +304,8 @@ pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 }; let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 }; Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0)) - * Affine::rotate(-rot.to_radians()) * Affine::scale_non_uniform(sx, sy) + * Affine::rotate(-rot.to_radians()) * Affine::translate((-w / 2.0, -h / 2.0)) } diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs index 91aeba3a..4b0ab511 100644 --- a/crates/format/src/sketch/tree_tests.rs +++ b/crates/format/src/sketch/tree_tests.rs @@ -231,3 +231,28 @@ fn deep_document_json_still_opens_the_pages() { assert!(doc.pages.iter().any(|p| doc.node(*p).unwrap().name == "Kept")); assert!(!warnings.is_empty()); } + +/// Sketch flips a rotated layer on screen, after rotating it. +#[test] +fn flip_applies_after_rotation() { + let r = with(with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)), "isFlippedHorizontal", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![r])]); + let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs(); + // Rotated a quarter turn counter-clockwise (x → up, y → right), then mirrored left to right. + assert!(t[0].abs() < 1e-9 && (t[1] + 1.0).abs() < 1e-9 && (t[2] + 1.0).abs() < 1e-9 && t[3].abs() < 1e-9, "{t:?}"); +} + +#[test] +fn edited_shapes_keep_their_points() { + let mut drop = rect("Drop", 0.0, 0.0, 20.0, 30.0); + drop["_class"] = json!("oval"); + drop["edited"] = json!(true); + drop["points"] = json!([point(0.5, 0.0), point(1.0, 0.7), point(0.5, 1.0), point(0.0, 0.7)]); + let mut box_ = rect("Slanted", 50.0, 0.0, 20.0, 20.0); + box_["edited"] = json!(true); + box_["points"][1]["point"] = json!("{1, 0.3}"); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![drop, box_])]); + let NodeKind::Vector { network } = &node(&doc, "Drop").kind else { panic!("not a vector") }; + assert_eq!((network.vertices[0].x, network.vertices[0].y), (10.0, 0.0)); + assert!(matches!(node(&doc, "Slanted").kind, NodeKind::Vector { .. })); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ed2d3157..508cd12a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape or as boolean operations (mixed operations nested from the left), bitmaps, slices, hotspots; transforms (rotation, flips), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (80,000 layers open in ~4 s, debug build); Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |