diff --git a/Cargo.lock b/Cargo.lock index 7106329ff..022df2744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1722,7 +1722,9 @@ dependencies = [ name = "craft-sketch" version = "0.1.0" dependencies = [ + "craft-figma-rest", "craft-zip", + "kurbo 0.13.1", "serde_json", "thiserror 2.0.21", "zip", diff --git a/crates/sketch/Cargo.toml b/crates/sketch/Cargo.toml index d4a7cf6ad..befeb19fb 100644 --- a/crates/sketch/Cargo.toml +++ b/crates/sketch/Cargo.toml @@ -11,6 +11,8 @@ repository.workspace = true workspace = true [dependencies] +craft-figma-rest = { workspace = true } +kurbo = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } craft-zip = { workspace = true } diff --git a/crates/sketch/README.md b/crates/sketch/README.md index 298280597..cb427c202 100644 --- a/crates/sketch/README.md +++ b/crates/sketch/README.md @@ -72,9 +72,45 @@ so a page list can't repeat an unpacking within the budget. The archive is read with the `zip` crate directly, with the bounds above applied to each member. A shared bounded ZIP reader is planned (craft-libs #126); this crate moves to it when it lands. +## The layers as REST file JSON (`rest`) + +`rest::to_rest` maps the pages and layers onto the REST API file JSON that +[`craft-figma-rest`](../figma-rest/README.md) models: artboards and frames, groups, shape paths +and shape groups with their boolean operations, text with its attributed runs, bitmaps, symbol +masters (components) and instances (expanded from their master with the instance's overrides, +including those in nested instances and swaps), stacks (auto layout), shared styles, swatches +(colour variables), prototype links, export options and layout grids. Paths come as vector +networks beside the JSON (`vectorNetworkRef`); stars and polygons whose points are still the +generated ones carry `pointCount` and `innerRadius`. What isn't understood is skipped with a +warning. + +```rust +let archive = craft_sketch::Archive::open(&bytes)?; +let mapped = craft_sketch::rest::to_rest(&archive, "Untitled", craft_sketch::rest::Options::default()); +if mapped.too_old { /* text stored the pre-48 way */ } +``` + +`Options::fonts` finds the installed face a PostScript name belongs to; without it, families +are read from the names. + +| Limit | Value | +|---|---| +| Layers (`Options::max_nodes`) | 200 000 by default | +| Layer nesting, symbol expansion included | 128 levels | +| Work spent expanding symbols (one unit per JSON value copied) | 60 million (15 million on 32-bit targets) | +| Points of one path | 100 000 | +| Distinct warnings | 200 | + +The walk recurses once per level of nesting, in small frames (each layer's own work is done in +functions that return before its children are mapped). An unoptimised build maps about 140 +levels of nested instances on a 512 KiB stack; the limit of 128 leaves twice the room on the +1 MiB stack of a WebAssembly build, and a test at the limit runs on a 1 MiB thread. FigCraft +followed 200 levels. + +The mapping was written in FigCraft (`crates/format/src/sketch`), mostly by Jindřich Šaršon, from +the public description of the format and files built for each case. + ## Not here yet -- A typed model of the document and the layers (`_class` objects: pages, artboards, groups, - shape paths, text with attributed strings, symbols and overrides, styles). FigCraft maps the - JSON straight onto its own import shape today. +- A typed model of the Sketch document itself (`_class` objects): the mapping reads the JSON. - Writing. diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 3d9e00b9d..7a971ab1e 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -17,6 +17,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod rest; #[cfg(test)] mod tests; diff --git a/crates/sketch/src/rest.rs b/crates/sketch/src/rest.rs new file mode 100644 index 000000000..2e5c78096 --- /dev/null +++ b/crates/sketch/src/rest.rs @@ -0,0 +1,74 @@ +//! A `.sketch` file's pages and layers mapped onto the REST API file JSON +//! ([`craft_figma_rest`]), the shape an application's importer takes. +//! +//! Every Sketch object carries its kind in `_class`. Pages become `CANVAS` nodes; artboards and +//! frames, groups, shape paths and shape groups (with boolean operations), text, bitmaps, symbol +//! masters (components) and instances (expanded from their master with the instance's overrides, +//! laid out at the master's size), stacks (auto layout), shared styles (style definitions) and +//! swatches (colour variables) map onto their REST counterparts; prototype links, hotspots, +//! export options and layout grids come along. Anything not understood is skipped with a +//! warning; malformed input never panics. +//! +//! Paths are handed over as vector networks ([`craft_figma_rest::ext::VectorNetwork`]) beside the +//! JSON, which names each by `vectorNetworkRef`. Stars and polygons whose points are still the +//! generated ones carry `pointCount` and `innerRadius` (the extensions craft-figma-rest documents). + +mod path; +mod proto; +mod shared; +mod stack; +mod style; +mod symbols; +mod text; +mod tree; + +#[cfg(test)] +mod fixture; +#[cfg(test)] +mod tests; + +use craft_figma_rest::ext::VectorNetwork; +use serde_json::Value; + +use crate::Archive; + +/// Finds the installed face a PostScript name belongs to: its family and style. +pub type FontLookup = Box Option<(String, String)>>; + +/// Most layers mapped when the caller has no budget of its own. +pub const MAX_NODES: usize = 200_000; + +/// How to map a file. +pub struct Options { + /// Most layers produced, symbol expansion included. + pub max_nodes: usize, + /// Finds the installed faces the file's fonts (PostScript names) belong to; families of fonts + /// it doesn't find are read from their names. + pub fonts: Option, +} + +impl Default for Options { + fn default() -> Self { + Options { max_nodes: MAX_NODES, fonts: None } + } +} + +/// What [`to_rest`] makes of a file. +#[derive(Debug)] +pub struct Mapped { + /// The file JSON, in the shape [`craft_figma_rest::File`] reads. + pub json: Value, + /// What was skipped or approximated, each once. + pub warnings: Vec, + /// The vector networks layers name by `vectorNetworkRef`. + pub networks: Vec>, + /// A text layer stored the way Sketch did before version 48 was met: the file is too old + /// ([`crate::TOO_OLD`]) even when `meta.json` didn't say so. + pub too_old: bool, +} + +/// The REST file JSON of `archive`, named `name`. +pub fn to_rest(archive: &Archive, name: &str, options: Options) -> Mapped { + let map = tree::to_rest(archive, name, options.max_nodes, options.fonts); + Mapped { json: map.json, warnings: map.warnings, networks: map.networks, too_old: map.too_old } +} diff --git a/crates/sketch/src/rest/fixture.rs b/crates/sketch/src/rest/fixture.rs new file mode 100644 index 000000000..19d23eca4 --- /dev/null +++ b/crates/sketch/src/rest/fixture.rs @@ -0,0 +1,206 @@ +//! Synthetic `.sketch` files for the tests: the JSON of a document, its pages and `meta.json`, +//! zipped the way Sketch writes them. Layers are built with small helpers and adjusted with +//! [`with`]. + +use std::io::Write; + +use serde_json::{Value, json}; + +/// A `.sketch` zip: `document.json`, `meta.json` saying `app_version` / `version`, one +/// `pages/.json` per page and any other `files` (images, fonts). +pub(crate) fn sketch_zip(app_version: &str, version: u64, document: Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let meta = json!({ + "app": "com.bohemiancoding.sketch3", + "appVersion": app_version, + "version": version, + "compatibilityVersion": 99, + "pagesAndArtboards": {}, + }); + raw_zip(&document, &meta, pages, files) +} + +/// A `.sketch` zip with the given `meta.json` as is. +pub(crate) fn raw_zip(document: &Value, meta: &Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("document.json", opts).unwrap(); + z.write_all(document.to_string().as_bytes()).unwrap(); + z.start_file("meta.json", opts).unwrap(); + z.write_all(meta.to_string().as_bytes()).unwrap(); + for p in pages { + let id = p["do_objectID"].as_str().unwrap_or("page"); + z.start_file(format!("pages/{id}.json"), opts).unwrap(); + z.write_all(p.to_string().as_bytes()).unwrap(); + } + for (name, bytes) in files { + z.start_file(*name, opts).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap(); + } + out +} + +/// `document.json` listing `pages` (ids) in order, with empty shared-style containers. +pub(crate) fn document(pages: &[&str]) -> Value { + json!({ + "_class": "document", + "do_objectID": "DOC", + "colorSpace": 1, + "currentPageIndex": 0, + "foreignLayerStyles": [], + "foreignSymbols": [], + "foreignTextStyles": [], + "layerStyles": {"_class": "sharedStyleContainer", "objects": []}, + "layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": []}, + "layerSymbols": {"_class": "symbolContainer", "objects": []}, + "pages": pages.iter().map(|p| json!({ + "_class": "MSJSONFileReference", + "_ref_class": "MSImmutablePage", + "_ref": format!("pages/{p}"), + })).collect::>(), + }) +} + +fn frame(x: f64, y: f64, w: f64, h: f64) -> Value { + json!({"_class": "rect", "constrainProportions": false, "x": x, "y": y, "width": w, "height": h}) +} + +fn layer(class: &str, id: &str, name: &str, x: f64, y: f64, w: f64, h: f64) -> Value { + json!({ + "_class": class, + "do_objectID": id, + "name": name, + "frame": frame(x, y, w, h), + "isVisible": true, + "isLocked": false, + "rotation": 0, + "isFlippedHorizontal": false, + "isFlippedVertical": false, + "resizingConstraint": 63, + "style": {"_class": "style", "fills": [], "borders": [], "shadows": [], "innerShadows": []}, + }) +} + +pub(crate) fn page(id: &str, name: &str, layers: Vec) -> Value { + with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers)) +} + +pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers)) +} + +pub(crate) fn group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("group", id, id, x, y, w, h), "layers", Value::Array(layers)) +} + +/// A rectangle with its four generated corner points. +pub(crate) fn rect(id: &str, x: f64, y: f64, w: f64, h: f64) -> Value { + let pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].map(|(px, py)| point(px, py)); + let mut l = with(layer("rectangle", id, id, x, y, w, h), "points", Value::Array(pts.to_vec())); + l["isClosed"] = json!(true); + l["fixedRadius"] = json!(0); + l +} + +/// A straight curve point at `(x, y)` in 0–1 of the layer's frame. +pub(crate) fn point(x: f64, y: f64) -> Value { + let p = format!("{{{x}, {y}}}"); + json!({"_class": "curvePoint", "cornerRadius": 0, "curveFrom": p, "curveTo": p, "curveMode": 1, "hasCurveFrom": false, "hasCurveTo": false, "point": p}) +} + +/// `l` with `key` set to `value`. +pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value { + if let Some(m) = l.as_object_mut() { + m.insert(key.to_string(), value); + } + l +} + +/// A `shapePath` through `points` (`(x, y)` in 0–1 of its frame), straight segments. +pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f64, f64)], closed: bool) -> Value { + let pts: Vec = points.iter().map(|(px, py)| point(*px, *py)).collect(); + let mut l = with(layer("shapePath", id, id, x, y, w, h), "points", Value::Array(pts)); + l["isClosed"] = json!(closed); + l["booleanOperation"] = json!(-1); + l +} + +/// A `shapeGroup` (Sketch 51 and older wrap every shape in one) holding `layers`. +pub(crate) fn shape_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers)) +} + +/// A Sketch colour. +pub(crate) fn color(r: f64, g: f64, b: f64, a: f64) -> Value { + json!({"_class": "color", "red": r, "green": g, "blue": b, "alpha": a}) +} + +/// An enabled solid fill. +pub(crate) fn solid_fill(r: f64, g: f64, b: f64, a: f64) -> Value { + json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color(r, g, b, a), + "contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}}) +} + +/// `l` with `style.` set to `value`. +pub(crate) fn styled(mut l: Value, key: &str, value: Value) -> Value { + l["style"][key] = value; + l +} + +/// A text layer showing `string`, one run per `(location, length, font, size, rgba)`; locations and +/// lengths count UTF-16 code units, as Sketch does. +pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: &[(usize, usize, &str, f64, [f64; 4])]) -> Value { + let attrs: Vec = runs + .iter() + .map(|(loc, len, font, size, [r, g, b, a])| { + json!({"_class": "stringAttribute", "location": loc, "length": len, "attributes": { + "MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": font, "size": size}}, + "MSAttributedStringColorAttribute": color(*r, *g, *b, *a), + "paragraphStyle": {"_class": "paragraphStyle", "alignment": 0}, + }}) + }) + .collect(); + let mut l = layer("text", id, id, x, y, w, h); + l["attributedString"] = json!({"_class": "attributedString", "string": string, "attributes": attrs}); + l["textBehaviour"] = json!(1); + l +} + +/// A symbol master `symbol_id` holding `layers`. +pub(crate) fn master(id: &str, symbol_id: &str, name: &str, w: f64, h: f64, layers: Vec) -> Value { + let mut l = artboard(id, name, 0.0, 0.0, w, h, layers); + l["_class"] = json!("symbolMaster"); + l["symbolID"] = json!(symbol_id); + l +} + +/// An instance of symbol `symbol_id` with `overrides` (`overrideName`, value). +pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64, overrides: &[(&str, Value)]) -> Value { + let mut l = layer("symbolInstance", id, id, x, y, w, h); + l["symbolID"] = json!(symbol_id); + l["scale"] = json!(1); + l["overrideValues"] = + Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect()); + l +} + +/// A Sketch 2025+ frame (a group that behaves as a frame) holding `layers`. +pub(crate) fn frame_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + let mut l = group(id, x, y, w, h, layers); + l["groupBehavior"] = json!(1); + l["clippingBehavior"] = json!(0); + l +} + +/// `l` as a stack (Sketch 2025+): `direction` 0 row, 1 column. +pub(crate) fn stack(mut l: Value, direction: i64, justify: i64, align: i64, gap: f64, padding: [f64; 4]) -> Value { + l["groupLayout"] = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": direction, "justifyContent": justify, "alignItems": align, + "allGuttersGap": gap, "crossAxisGutterGap": 0, "wrappingEnabled": false, "alignContent": 0, "stackingOrder": 0, "bordersAffectLayout": false}); + for (k, v) in ["leftPadding", "topPadding", "rightPadding", "bottomPadding"].iter().zip(padding) { + l[*k] = json!(v); + } + l +} diff --git a/crates/sketch/src/rest/path.rs b/crates/sketch/src/rest/path.rs new file mode 100644 index 000000000..a8e48dc31 --- /dev/null +++ b/crates/sketch/src/rest/path.rs @@ -0,0 +1,444 @@ +//! Sketch curve points → vector networks; shape groups → shapes and boolean operations. +//! +//! A path's `points` are `curvePoint`s whose `point`, `curveFrom` (the handle leaving the point) +//! 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` (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 craft_figma_rest::ext::{Region, Segment, VectorNetwork, Vertex, Winding as WindingRule}; +use kurbo::{Affine, Point}; +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, base, layer, layers, local, size}; + +/// Farthest a point or handle may be from its layer (the REST importer's own range). +const MAX_COORD: f64 = 1e7; +/// Most points read from one path. +const MAX_POINTS: usize = 100_000; + +/// What a path layer becomes. +pub(crate) enum Shape { + Vector(VectorNetwork), + Star { count: u32, ratio: f64 }, + Polygon { count: u32 }, +} + +/// `"{x, y}"` → `(x, y)`, both finite. +pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> { + let inner = s.trim().strip_prefix('{')?.strip_suffix('}')?; + let (x, y) = inner.split_once(',')?; + let n = |t: &str| t.trim().parse::().ok().filter(|v| v.is_finite()); + Some((n(x)?, n(y)?)) +} + +/// 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")); + } + // Far points stay within the importer's coordinate range (a product could overflow). + let fit = |v: f64| if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 }; + let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (fit(x * w), fit(y * h))); + let mut vertices = Vec::new(); + // Per vertex: its handles (relative to it) when it has them. + let mut handles: Vec<((f64, f64), (f64, f64))> = Vec::new(); + for p in pts.iter().take(MAX_POINTS) { + let Some((x, y)) = at(p, "point") else { + cx.warn("path points that couldn't be read were skipped"); + continue; + }; + let curved = p.get("curveMode").and_then(Value::as_i64).is_some_and(|m| m != 1); + let has = |flag: &str| p.get(flag).and_then(Value::as_bool).unwrap_or(curved); + let handle = |flag: &str, key: &str| if has(flag) { at(p, key).map_or((0.0, 0.0), |(hx, hy)| (hx - x, hy - y)) } else { (0.0, 0.0) }; + handles.push((handle("hasCurveFrom", "curveFrom"), handle("hasCurveTo", "curveTo"))); + let radius = p.get("cornerRadius").and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, 1e6)); + vertices.push(Vertex { x, y, radius, ..Vertex::default() }); + } + let n = vertices.len(); + if n == 0 { + return None; + } + 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; + // 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 = winding(l); + let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding, ..Default::default() }] } else { Vec::new() }; + Some(VectorNetwork { vertices, segments, regions, ..VectorNetwork::default() }) +} + +/// Rectangle `l` with angled (corner style 2), inside-square (3) or inside-arc (4) corners in its +/// style (Sketch 2025 and newer) as an edited rectangle whose points draw that outline, the +/// corners' `radii` (top left, top right, bottom right, bottom left; repeating) being how far +/// each cut reaches along both edges. `None` for any other layer. +pub(crate) fn cut_corners(l: &Value) -> Option { + if l.get("_class").and_then(Value::as_str) != Some("rectangle") || l.get("edited").and_then(Value::as_bool) == Some(true) { + return None; + } + let c = l.get("style")?.get("corners")?; + let style = c.get("style").and_then(Value::as_i64).filter(|s| (2..=4).contains(s))?; + let radii: Vec = c.get("radii")?.as_array()?.iter().filter_map(Value::as_f64).filter(|r| r.is_finite()).take(4).collect(); + if radii.iter().all(|r| *r <= 0.0) { + return None; + } + let (w, h) = size(l); + if w <= 0.0 || h <= 0.0 { + return None; + } + // Arc handles: a quarter circle's. + const K: f64 = 0.552_284_749_8; + let fmt = |(x, y): (f64, f64)| format!("{{{}, {}}}", x / w, y / h); + let curve = |p: (f64, f64), from: Option<(f64, f64)>, to: Option<(f64, f64)>| { + json!({"_class": "curvePoint", "cornerRadius": 0, "point": fmt(p), "curveMode": if from.or(to).is_some() { 4 } else { 1 }, + "hasCurveFrom": from.is_some(), "hasCurveTo": to.is_some(), "curveFrom": fmt(from.unwrap_or(p)), "curveTo": fmt(to.unwrap_or(p))}) + }; + // Corners clockwise from the top left, each with the directions to the edge before and after. + let corners = [ + ((0.0, 0.0), (0.0, 1.0), (1.0, 0.0)), + ((w, 0.0), (-1.0, 0.0), (0.0, 1.0)), + ((w, h), (0.0, -1.0), (-1.0, 0.0)), + ((0.0, h), (1.0, 0.0), (0.0, -1.0)), + ]; + let mut points = Vec::new(); + for (i, ((cx, cy), (px, py), (nx, ny))) in corners.into_iter().enumerate() { + let r = radii.get(i % radii.len()).copied().unwrap_or(0.0).clamp(0.0, w.min(h) / 2.0); + if r <= 0.0 { + points.push(curve((cx, cy), None, None)); + continue; + } + let a = (cx + r * px, cy + r * py); + let b = (cx + r * nx, cy + r * ny); + match style { + 2 => points.extend([curve(a, None, None), curve(b, None, None)]), + 3 => points.extend([curve(a, None, None), curve((cx + r * (px + nx), cy + r * (py + ny)), None, None), curve(b, None, None)]), + // A quarter circle about the corner, bulging into the shape. + _ => points + .extend([curve(a, Some((a.0 + K * r * nx, a.1 + K * r * ny)), None), curve(b, None, Some((b.0 + K * r * px, b.1 + K * r * py)))]), + } + } + let mut out = l.clone(); + out["edited"] = json!(true); + out["isClosed"] = json!(true); + out["points"] = Value::Array(points); + if let Some(st) = out.get_mut("style").and_then(Value::as_object_mut) { + st.remove("corners"); + } + Some(out) +} + +/// A path, star, polygon or triangle layer: a star or polygon while its points are still the +/// generated ones (the REST shape's star and polygon points, within half a percent), else an editable vector. +pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { + // 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)); + let matches = |pts: Vec| { + let tol = 0.005 * w.max(h) + 1e-6; + pts.len() == net.vertices.len() && pts.iter().zip(&net.vertices).all(|(p, v)| (p.x - v.x).abs() <= tol && (p.y - v.y).abs() <= tol) + }; + match (class, count) { + ("star", Some(count)) if plain => { + let ratio = l.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0).map_or(0.5, |r| r.min(1.0)); + if matches(star_points(w, h, count, ratio)) { + return Some(Shape::Star { count, ratio }); + } + } + ("polygon", Some(count)) if plain && matches(polygon_points(w, h, count)) => return Some(Shape::Polygon { count }), + _ => {} + } + Some(Shape::Vector(net)) +} + +/// Write `shape` into `node` (its type and geometry). +pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap) { + match shape { + Shape::Vector(net) => { + node.insert("type".into(), json!("VECTOR")); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + } + Shape::Star { count, ratio } => { + node.insert("type".into(), json!("STAR")); + node.insert("pointCount".into(), json!(count)); + node.insert("innerRadius".into(), json!(ratio)); + } + Shape::Polygon { count } => { + node.insert("type".into(), json!("REGULAR_POLYGON")); + node.insert("pointCount".into(), json!(count)); + } + } +} + +/// A `shapeGroup`: its only child carrying the group's place, name and style; its children joined +/// into one compound path when none of them combines; else boolean operations over runs of +/// children (a run: a child with an operation and the ones without that follow it), mixed +/// operations nesting from the left. +pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let kids = layers(l); + 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 +} + +/// `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)?; + 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); + } + Some(node) +} + +/// A shape group of several shapes: one compound path, or boolean operations over runs. +#[allow(clippy::too_many_arguments)] // the layer's place (`parent`, `world`, size) and walk state (`depth`, `root`), as `layer` takes them +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, 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; + } + // Sketch draws a shape group in the group's style only. Its operands' own borders go: a + // REST 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)); + chain.push(inner); + } + if i > 0 { + op = this; + } + chain.extend(operands); + } + if chain.is_empty() { + return None; + } + let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); + node.insert("booleanOperation".into(), json!(op)); + node.insert("children".into(), Value::Array(chain)); + super::tree::mask(l, &mut node); + super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); + 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 * 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, ..Default::default() }); + } + 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); + node.remove("id"); + let name = match op { + "SUBTRACT" => "Subtract", + "INTERSECT" => "Intersect", + "EXCLUDE" => "Exclude", + _ => "Union", + }; + node.insert("name".into(), json!(name)); + // Placed where the group is: inside it, that's no move at all. + node.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])); + node.insert("booleanOperation".into(), json!(op)); + node.insert("children".into(), Value::Array(children)); + Value::Object(node) +} + +fn boolean_op(l: &Value) -> &'static str { + match l.get("booleanOperation").and_then(Value::as_i64) { + Some(1) => "SUBTRACT", + Some(2) => "INTERSECT", + Some(3) => "EXCLUDE", + _ => "UNION", + } +} + +/// Largest coordinate the star and polygon points are computed within. +const MAX_SHAPE_COORD: f64 = 1e9; + +fn bounded(v: f64) -> f64 { + if v.is_finite() { v.clamp(-MAX_SHAPE_COORD, MAX_SHAPE_COORD) } else { 0.0 } +} + +/// The vertices of a regular polygon with `count` sides inscribed in the `width`×`height` box, +/// first vertex at the top centre (the REST shape's polygon). +fn polygon_points(width: f64, height: f64, count: u32) -> Vec { + use std::f64::consts::{FRAC_PI_2, TAU}; + let n = count.clamp(3, 120); + let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0); + (0..n) + .map(|i| { + let t = -FRAC_PI_2 + TAU * f64::from(i) / f64::from(n); + Point::new(rx + rx * t.cos(), ry + ry * t.sin()) + }) + .collect() +} + +/// The vertices of a star with `count` points and `inner_ratio` (0..1) inner radius. +fn star_points(width: f64, height: f64, count: u32, inner_ratio: f64) -> Vec { + use std::f64::consts::{FRAC_PI_2, PI}; + let n = count.clamp(3, 120); + let k = bounded(inner_ratio).clamp(0.001, 1.0); + let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0); + (0..n * 2) + .map(|i| { + let t = -FRAC_PI_2 + PI * f64::from(i) / f64::from(n); + let f = if i % 2 == 0 { 1.0 } else { k }; + Point::new(rx + rx * f * t.cos(), ry + ry * f * t.sin()) + }) + .collect() +} diff --git a/crates/sketch/src/rest/proto.rs b/crates/sketch/src/rest/proto.rs new file mode 100644 index 000000000..ed16e0fcf --- /dev/null +++ b/crates/sketch/src/rest/proto.rs @@ -0,0 +1,119 @@ +//! Sketch prototype links, hotspots, export options and layout grids → REST. +//! +//! A layer's `flow` links it to an artboard (`destinationArtboardID`, or `back`) with an +//! `animationType` (0 none, 1–4 slide from the right, left, bottom, top); hotspots are layers that +//! only carry a link; `isFlowHome` marks an artboard where a prototype starts. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, bool_field, str_field}; + +/// Most export settings read from one layer. +const MAX_EXPORTS: usize = 64; + +/// The click interaction of layer `l`'s `flow`, if it has one. +pub(crate) fn interactions(l: &Value, node: &mut JsonMap) { + let Some(flow) = l.get("flow") else { return }; + let Some(dest) = str_field(flow, "destinationArtboardID").filter(|d| !d.is_empty()) else { return }; + let action = if dest == "back" { + json!({"type": "BACK"}) + } else { + let direction = match flow.get("animationType").and_then(Value::as_i64) { + Some(1) => Some("LEFT"), + Some(2) => Some("RIGHT"), + Some(3) => Some("TOP"), + Some(4) => Some("BOTTOM"), + _ => None, + }; + let transition = + direction.map_or(Value::Null, |d| json!({"type": "SLIDE_IN", "direction": d, "duration": 0.3, "easing": {"type": "EASE_IN_AND_OUT"}})); + json!({"type": "NODE", "destinationId": dest, "navigation": "NAVIGATE", "transition": transition}) + }; + node.insert("interactions".into(), json!([{"trigger": {"type": "ON_CLICK"}, "actions": [action]}])); +} + +/// The artboards of a page marked as where a prototype starts, as REST `flowStartingPoints`. +pub(crate) fn starting_points(page: &Value) -> Vec { + super::tree::layers(page) + .iter() + .filter(|a| bool_field(a, "isFlowHome") == Some(true)) + .filter_map(|a| Some(json!({"nodeId": str_field(a, "do_objectID")?, "name": str_field(a, "name").unwrap_or("Flow")}))) + .collect() +} + +/// Layer `l`'s export options as REST `exportSettings`. +pub(crate) fn export_settings(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let formats = l.get("exportOptions").and_then(|o| o.get("exportFormats")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut out = Vec::new(); + for f in formats.iter().take(MAX_EXPORTS) { + let format = match str_field(f, "fileFormat").unwrap_or("png").to_ascii_lowercase().as_str() { + "png" => "PNG", + "jpg" | "jpeg" => "JPG", + "svg" => "SVG", + "pdf" => "PDF", + "webp" => "WEBP", + _ => { + cx.warn("export settings in formats the file JSON has no setting for (TIFF, EPS, HEIC) were skipped"); + continue; + } + }; + let num = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let constraint = match (f.get("visibleScaleType").and_then(Value::as_i64), num("absoluteSize")) { + (Some(1), Some(s)) => json!({"type": "WIDTH", "value": s.min(16_384.0)}), + (Some(2), Some(s)) => json!({"type": "HEIGHT", "value": s.min(16_384.0)}), + _ => json!({"type": "SCALE", "value": num("scale").unwrap_or(1.0).min(64.0)}), + }; + let name: String = str_field(f, "name").unwrap_or("").chars().take(200).collect(); + if f.get("namingScheme").and_then(Value::as_i64) == Some(1) && !name.is_empty() { + cx.warn("export names Sketch puts before the layer name come after it"); + } + out.push(json!({"format": format, "suffix": name, "constraint": constraint})); + } + if !out.is_empty() { + node.insert("exportSettings".into(), Value::Array(out)); + } +} + +/// An artboard's layout grid (columns, rows) and square grid as REST `layoutGrids`, hidden: Sketch +/// shows them only while its layout (or grid) view is turned on. +pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) { + let mut out = Vec::new(); + if let Some(g) = l.get("layout").filter(|g| bool_field(g, "isEnabled") != Some(false)) { + let num = |k: &str| g.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0); + if bool_field(g, "drawVertical") != Some(false) + && let (Some(count), Some(width)) = (num("numberOfColumns"), num("columnWidth")) + { + out.push(json!({"pattern": "COLUMNS", "alignment": "MIN", "count": count.min(1000.0), "sectionSize": width.min(1e5), + "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": false})); + } + if bool_field(g, "drawHorizontal") == Some(true) { + let gutter = num("gutterHeight").unwrap_or(0.0).min(1e5); + let section = (gutter * num("rowHeightMultiplication").unwrap_or(1.0)).min(1e5); + if section > 0.0 { + let count = (h / (section + gutter)).floor().clamp(1.0, 1000.0); + out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": false})); + } + } + } + if let Some(g) = l.get("grid").filter(|g| bool_field(g, "isEnabled") != Some(false)) + && let Some(size) = g.get("gridSize").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) + { + out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": false})); + } + if !out.is_empty() { + node.insert("layoutGrids".into(), Value::Array(out)); + } +} + +/// Notes about what the layer has that the REST shape keeps only as laid out: Smart Layout, +/// and overrides a master locks. +pub(crate) fn notes(cx: &mut Cx, l: &Value) { + // Stacks become auto layout; the older Smart Layout doesn't. + if l.get("groupLayout").and_then(|g| str_field(g, "_class")) == Some("MSImmutableInferredGroupLayout") { + cx.warn("Smart Layout was imported as laid out; it doesn't resize with its content"); + } + let locked = l.get("overrideProperties").and_then(Value::as_array).is_some_and(|p| p.iter().any(|o| bool_field(o, "canOverride") == Some(false))); + if locked { + cx.warn("overrides Sketch symbols locked can be changed after import"); + } +} diff --git a/crates/sketch/src/rest/shared.rs b/crates/sketch/src/rest/shared.rs new file mode 100644 index 000000000..fb435d28c --- /dev/null +++ b/crates/sketch/src/rest/shared.rs @@ -0,0 +1,149 @@ +//! Sketch shared styles and swatches → REST style definitions and colour variables. +//! +//! A Sketch layer style holds fills, borders and effects together; REST styles hold one kind +//! each, so a layer style becomes up to three styles named after it: its fills (a paint style), +//! its border colours (a paint style for strokes; widths stay on the layers) and its effects. A +//! text style becomes a text style; its colour stays on the layers, as REST text styles carry +//! none. Layers link to them by `style.sharedObjectID`. Swatches (Color Variables, Sketch 69+) +//! become colour variables in one collection; colours name theirs by `swatchID`. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, color, str_field}; + +/// Most shared styles or swatches read from one file. +const MAX_SHARED: usize = 20_000; +/// The collection swatches become variables in. +const COLLECTION: &str = "sketch-color-variables"; +const MODE: &str = "default"; + +/// A file's shared styles and swatches. +#[derive(Default)] +pub(crate) struct Shared { + /// `(id, name, style)` of layer styles, then text styles. + layer: Vec<(String, String, Value)>, + text: Vec<(String, String, Value)>, + /// Positions in `layer` and `text` by id. + layer_index: BTreeMap, + text_index: BTreeMap, + swatches: BTreeMap, +} + +impl Shared { + pub(crate) fn read(document: &Value) -> Shared { + let mut s = Shared::default(); + let objects = + |key: &str| -> Vec { document.get(key).and_then(|c| c.get("objects")).and_then(Value::as_array).cloned().unwrap_or_default() }; + let foreign = |key: &str, inner: &str| -> Vec { + document.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|f| f.get(inner).cloned()).collect() + }; + let entry = |o: &Value| { + Some(( + str_field(o, "do_objectID")?.to_string(), + str_field(o, "name").unwrap_or("Style").chars().take(200).collect(), + o.get("value")?.clone(), + )) + }; + for o in objects("layerStyles").iter().chain(&foreign("foreignLayerStyles", "localSharedStyle")).take(MAX_SHARED) { + if let Some(e) = entry(o).filter(|e| !s.layer_index.contains_key(&e.0)) { + s.layer_index.insert(e.0.clone(), s.layer.len()); + s.layer.push(e); + } + } + for o in objects("layerTextStyles").iter().chain(&foreign("foreignTextStyles", "localSharedStyle")).take(MAX_SHARED) { + if let Some(e) = entry(o).filter(|e| !s.text_index.contains_key(&e.0)) { + s.text_index.insert(e.0.clone(), s.text.len()); + s.text.push(e); + } + } + let foreign_swatches = [foreign("foreignSwatches", "localSwatch"), foreign("foreignSwatches", "localSharedSwatch")].concat(); + for o in objects("sharedSwatches").iter().chain(&foreign_swatches).take(MAX_SHARED) { + if let Some((id, name, value)) = entry(o) + && let Some(c) = color(&value) + { + s.swatches.entry(id).or_insert((name, c)); + } + } + s + } + + /// The style of shared text (`text`) or layer style `id`. + pub(crate) fn style(&self, text: bool, id: &str) -> Option<&Value> { + let (index, list) = if text { (&self.text_index, &self.text) } else { (&self.layer_index, &self.layer) }; + list.get(*index.get(id)?).map(|e| &e.2) + } + + pub(crate) fn has_swatch(&self, id: &str) -> bool { + self.swatches.contains_key(id) + } + + /// The REST `styleDefinitions` of every shared style. + pub(crate) fn definitions(cx: &mut Cx) -> Vec { + let layer = cx.shared.layer.clone(); + let text = cx.shared.text.clone(); + let mut out = Vec::new(); + for (id, name, style) in &layer { + let fills = super::style::fills(cx, style, 100.0, 100.0); + if !fills.is_empty() { + out.push(json!({"id": format!("{id}:fill"), "name": name, "styleType": "FILL", "node": {"fills": fills}})); + } + let strokes = super::style::border_paints(cx, style, 100.0, 100.0); + if !strokes.is_empty() { + out.push(json!({"id": format!("{id}:stroke"), "name": format!("{name} border"), "styleType": "FILL", "node": {"fills": strokes}})); + } + let effects = super::style::effects(cx, style); + if !effects.is_empty() { + out.push(json!({"id": format!("{id}:effect"), "name": name, "styleType": "EFFECT", "node": {"effects": effects}})); + } + } + for (id, name, style) in &text { + let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) else { continue }; + let mut ts = super::text::type_style(cx, enc); + if let Some(m) = ts.as_object_mut() { + m.remove("fills"); + } + out.push(json!({"id": id, "name": name, "styleType": "TEXT", "node": {"type": "TEXT", "characters": "", "style": ts}})); + } + out + } + + /// Link `node` (layer `l`) to the styles made from its shared style. + pub(crate) fn link(&self, cx_defs: &BTreeSet, l: &Value, node: &mut JsonMap) { + let Some(sid) = l.get("style").and_then(|s| str_field(s, "sharedObjectID")) else { return }; + let mut refs = JsonMap::new(); + let is_text = str_field(l, "_class") == Some("text"); + if is_text && self.text_index.contains_key(sid) { + refs.insert("text".into(), json!(sid)); + } else if self.layer_index.contains_key(sid) { + for slot in ["fill", "stroke", "effect"] { + let id = format!("{sid}:{slot}"); + if cx_defs.contains(&id) { + refs.insert(slot.into(), json!(id)); + } + } + } + if !refs.is_empty() { + node.insert("styles".into(), Value::Object(refs)); + } + } + + /// The REST `variableCollections` and `variables` for the swatches (none without swatches). + pub(crate) fn variables(&self) -> Option<(Value, Value)> { + if self.swatches.is_empty() { + return None; + } + let ids: Vec<&String> = self.swatches.keys().collect(); + let collections = json!({COLLECTION: {"id": COLLECTION, "name": "Color Variables", "modes": [{"modeId": MODE, "name": "Default"}], + "defaultModeId": MODE, "variableIds": ids}}); + let vars: JsonMap = self + .swatches + .iter() + .map(|(id, (name, c))| { + (id.clone(), json!({"id": id, "name": name, "resolvedType": "COLOR", "variableCollectionId": COLLECTION, "valuesByMode": {MODE: c}})) + }) + .collect(); + Some((collections, Value::Object(vars))) + } +} diff --git a/crates/sketch/src/rest/stack.rs b/crates/sketch/src/rest/stack.rs new file mode 100644 index 000000000..0466093ec --- /dev/null +++ b/crates/sketch/src/rest/stack.rs @@ -0,0 +1,112 @@ +//! Sketch 2025+ stacks (`groupLayout` `MSImmutableFlexGroupLayout`) → REST auto layout. +//! +//! The enums follow the order Sketch's plugin API documents them in (checked against files): +//! direction row, column; justify start, center, end, between, around, evenly; align items start, +//! center, end, stretch, none; stacking order first on top, last on top. Sizing (`horizontalSizing`, +//! `verticalSizing`) is fixed, fit (hug), fill, relative. Sketch lists a stack's first item last +//! (on top in the layer list), so the flow order is the reverse of its `layers`. + +use serde_json::{Map as JsonMap, Value, json}; + +/// Whether `l` is a stack. +pub(crate) fn is_stack(l: &Value) -> bool { + l.get("groupLayout").and_then(|g| g.get("_class")).and_then(Value::as_str) == Some("MSImmutableFlexGroupLayout") +} + +/// Make `node` (the REST node of frame `l`, its children already built in Sketch's order) an auto +/// layout frame when `l` is a stack. +pub(crate) fn apply(l: &Value, node: &mut JsonMap) { + if !is_stack(l) || !matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let Some(g) = l.get("groupLayout") else { return }; + let int = |k: &str| g.get(k).and_then(Value::as_i64).unwrap_or(0); + let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(0.0).clamp(0.0, 1e5); + let row = int("flexDirection") == 0; + node.insert("layoutMode".into(), json!(if row { "HORIZONTAL" } else { "VERTICAL" })); + node.insert("itemSpacing".into(), json!(num(g, "allGuttersGap"))); + node.insert( + "primaryAxisAlignItems".into(), + json!(match int("justifyContent") { + 1 => "CENTER", + 2 => "MAX", + 3 => "SPACE_BETWEEN", + 4 => "SPACE_AROUND", + 5 => "SPACE_EVENLY", + _ => "MIN", + }), + ); + let stretch = int("alignItems") == 3; + node.insert( + "counterAxisAlignItems".into(), + json!(match int("alignItems") { + 1 => "CENTER", + 2 => "MAX", + _ => "MIN", + }), + ); + if g.get("wrappingEnabled").and_then(Value::as_bool) == Some(true) { + node.insert("layoutWrap".into(), json!("WRAP")); + node.insert("counterAxisSpacing".into(), json!(num(g, "crossAxisGutterGap"))); + } + for (k, rest) in + [("leftPadding", "paddingLeft"), ("topPadding", "paddingTop"), ("rightPadding", "paddingRight"), ("bottomPadding", "paddingBottom")] + { + node.insert(rest.into(), json!(num(l, k))); + } + node.insert("itemReverseZIndex".into(), json!(int("stackingOrder") == 0)); + node.insert("strokesIncludedInLayout".into(), json!(g.get("bordersAffectLayout").and_then(Value::as_bool) == Some(true))); + // The stack's own sizing: fit hugs its content. + let (main, cross) = if row { ("horizontalSizing", "verticalSizing") } else { ("verticalSizing", "horizontalSizing") }; + let mode = |k: &str| if l.get(k).and_then(Value::as_i64) == Some(1) { "AUTO" } else { "FIXED" }; + node.insert("primaryAxisSizingMode".into(), json!(mode(main))); + node.insert("counterAxisSizingMode".into(), json!(mode(cross))); + // Items: flow order, sizing, absolute ones. + // Source layers by id (the first of a repeated id). + let mut sources: std::collections::BTreeMap<&str, &Value> = std::collections::BTreeMap::new(); + for src in super::tree::layers(l) { + if let Some(id) = src.get("do_objectID").and_then(Value::as_str) { + sources.entry(id).or_insert(src); + } + } + if let Some(kids) = node.get_mut("children").and_then(Value::as_array_mut) { + // Children were built one per source layer, skipped ones aside: match them by id. + for kid in kids.iter_mut() { + let Some(src) = kid.get("id").and_then(Value::as_str).and_then(|id| sources.get(id).copied()) else { continue }; + let Some(m) = kid.as_object_mut() else { continue }; + item(src, m, row, stretch); + } + kids.reverse(); + } +} + +/// An item's place in its stack: absolute, or sized fixed, hugging or filling. +fn item(src: &Value, m: &mut JsonMap, row: bool, stretch: bool) { + if src.get("flexItem").and_then(|f| f.get("ignoreLayout")).and_then(Value::as_bool) == Some(true) { + m.insert("layoutPositioning".into(), json!("ABSOLUTE")); + return; + } + let sizing = |k: &str| match src.get(k).and_then(Value::as_i64) { + Some(1) => "HUG", + Some(2) => "FILL", + _ => "FIXED", + }; + let (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) }; + if main == "FILL" { + m.insert("layoutGrow".into(), json!(1)); + } + if cross == "FILL" { + m.insert("layoutAlign".into(), json!("STRETCH")); + } +} diff --git a/crates/sketch/src/rest/style.rs b/crates/sketch/src/rest/style.rs new file mode 100644 index 000000000..80d694f44 --- /dev/null +++ b/crates/sketch/src/rest/style.rs @@ -0,0 +1,557 @@ +//! Sketch styles → REST paints, strokes, effects, opacity and blend modes. +//! +//! A layer's `style` lists `fills` and `borders` (each a colour, gradient or, for fills, an image +//! pattern, with its own `contextSettings` opacity and blend mode), `borderOptions` (dashes, caps, +//! joins), `shadows`, `innerShadows`, one `blur` and the layer's own `contextSettings`. Every +//! border has its own thickness and position; REST strokes share one, so the first enabled +//! border's is used. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::path::parse_point; +use super::tree::{Cx, color}; + +/// Most paints or effects read from one list. +const MAX_LIST: usize = 64; + +/// Write the paints, strokes, effects, opacity and blend mode of `style` into `node`, a layer of +/// `w`×`h`. Lists the style doesn't have are left as they are. +pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { + let fills = fills(cx, style, w, h); + if !fills.is_empty() { + node.insert("fills".into(), Value::Array(fills)); + } + borders(cx, style, w, h, node); + let effects = effects(cx, style); + if !effects.is_empty() { + node.insert("effects".into(), Value::Array(effects)); + } + if let Some(ctx) = style.get("contextSettings") { + if let Some(o) = num(ctx, "opacity") { + node.insert("opacity".into(), json!(o.clamp(0.0, 1.0))); + } + if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).filter(|b| *b != 0).and_then(blend) { + node.insert("blendMode".into(), json!(b)); + } + } +} + +/// A style's fills as REST paints. +pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + // Tints (`layeringType` 1) colour the layers inside; they aren't paints of the layer. + list(style, "fills").iter().filter(|f| f.get("layeringType").and_then(Value::as_i64) != Some(1)).filter_map(|f| paint(cx, f, w, h)).collect() +} + +/// A style's borders as REST paints. +pub(crate) fn border_paints(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + list(style, "borders").iter().filter_map(|b| paint(cx, b, w, h)).collect() +} + +/// Remove what [`apply`] writes, so another style can take its place. +pub(crate) fn clear(node: &mut JsonMap) { + for key in [ + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "strokeJoin", + "effects", + "opacity", + "blendMode", + ] { + node.remove(key); + } +} + +/// One fill or border as a REST paint; `None` for kinds the REST shape doesn't have (with a warning). +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 => { + let (g, resampled) = gradient(f.get("gradient")?, w, h)?; + if resampled { + cx.warn("gradients mixed in OKLab or OKLCH became sRGB gradients with stops along the same colours"); + } + g + } + 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"); + return None; + }; + let mut p = json!({"type": "IMAGE", "imageRef": r}); + match f.get("patternFillType").and_then(Value::as_i64).unwrap_or(1) { + 0 => { + p["scaleMode"] = json!("TILE"); + p["scalingFactor"] = json!(num(f, "patternTileScale").filter(|s| *s > 0.0).unwrap_or(1.0)); + } + 2 => { + p["scaleMode"] = json!("STRETCH"); + p["imageTransform"] = json!([[1, 0, 0], [0, 1, 0]]); + } + 3 => p["scaleMode"] = json!("FIT"), + _ => p["scaleMode"] = json!("FILL"), + } + p + } + 5 => { + cx.warn("noise fills were skipped"); + return None; + } + other => { + cx.warn(format!("fills of type {other} were skipped")); + return None; + } + }; + p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); + // A colour variable binds a plain colour (a gradient's or image's `color` is unused). + if f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0 + && let Some(var) = swatch(cx, f.get("color")) + { + p["boundVariables"] = var; + } + if let Some(ctx) = f.get("contextSettings") { + if let Some(o) = num(ctx, "opacity") { + p["opacity"] = json!(o.clamp(0.0, 1.0)); + } + if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).and_then(blend) { + p["blendMode"] = json!(b); + } + } + Some(p) +} + +/// A gradient fill: `from` and `to` in 0–1 of the layer; a radial gradient's `elipseLength` is its +/// width across the main axis as a share of the main axis, measured in pixels. +/// Also whether its stops were resampled from OKLab or OKLCH. +fn gradient(g: &Value, w: f64, h: f64) -> Option<(Value, bool)> { + let point = |k: &str| g.get(k).and_then(Value::as_str).and_then(parse_point); + let (from, to) = (point("from").unwrap_or((0.5, 0.0)), point("to").unwrap_or((0.5, 1.0))); + let stops: Vec = list(g, "stops") + .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); REST + // gradients mix in sRGB, so stops in between carry the curve. + let (stops, resampled) = match g.get("colorInterpolation").and_then(Value::as_i64) { + Some(mode @ (1 | 2)) => (oklab::resample(&stops, mode == 2), true), + _ => (stops, false), + }; + 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); + let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h); + let across = if w > 0.0 && h > 0.0 { (from.0 - dy * e / w, from.1 + dx * e / h) } else { from }; + ("GRADIENT_RADIAL", json!([pt(from), pt(to), pt(across)])) + } + // Sketch starts on the right and turns clockwise; from/to don't change it. + 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((1.0, 0.5)), pt((0.5, 1.0))])), + // Square to the line in pixels, as Sketch draws it (the REST default is square in 0–1). + _ => { + let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h); + let across = if w > 0.0 && h > 0.0 { (from.0 - dy / w, from.1 + dx / h) } else { (from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)) }; + ("GRADIENT_LINEAR", json!([pt(from), pt(to), pt(across)])) + } + }; + Some((json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops}), resampled)) +} + +fn pt((x, y): (f64, f64)) -> Value { + json!({"x": x, "y": y}) +} + +/// Borders → stroke paints, and the first enabled border's thickness and position. +fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { + let list = list(style, "borders"); + let strokes = border_paints(cx, style, w, h); + if strokes.is_empty() { + return; + } + let enabled: Vec<&Value> = list.iter().filter(|b| b.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)).collect(); + let first = enabled.first().copied().or(list.first()); + let shape = |b: &Value| (num(b, "thickness").unwrap_or(1.0), b.get("position").and_then(Value::as_i64).unwrap_or(0)); + if let Some(b) = first { + let (t, pos) = shape(b); + if enabled.iter().any(|o| shape(o) != (t, pos)) { + cx.warn("layers with several borders of different widths or positions use the first one's"); + } + node.insert("strokeWeight".into(), json!(t.clamp(0.0, 1e6))); + node.insert( + "strokeAlign".into(), + json!(match pos { + 1 => "INSIDE", + 2 => "OUTSIDE", + _ => "CENTER", + }), + ); + } + node.insert("strokes".into(), Value::Array(strokes)); + // Per-side thickness (Sketch 2025+): a side is as thick as the border less its inset. + let first = list.iter().find(|b| b.get("isEnabled").and_then(Value::as_bool) != Some(false)); + if let Some(b) = first + && let Some(sides) = b.get("sides") + { + let t = num(b, "thickness").unwrap_or(1.0).clamp(0.0, 1e6); + let side = |k: &str| (t - num(sides, k).unwrap_or(0.0)).clamp(0.0, 1e6); + node.insert( + "individualStrokeWeights".into(), + json!({"top": side("topInset"), "right": side("rightInset"), "bottom": side("bottomInset"), "left": side("leftInset")}), + ); + } + if let Some(o) = style.get("borderOptions") { + let dashes: Vec = o + .get("dashPattern") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|d| d.is_finite() && *d >= 0.0) + .take(MAX_LIST) + .collect(); + if !dashes.is_empty() { + node.insert("strokeDashes".into(), json!(dashes)); + } + node.insert( + "strokeCap".into(), + json!(match o.get("lineCapStyle").and_then(Value::as_i64) { + Some(1) => "ROUND", + Some(2) => "SQUARE", + _ => "NONE", + }), + ); + node.insert( + "strokeJoin".into(), + json!(match o.get("lineJoinStyle").and_then(Value::as_i64) { + Some(1) => "ROUND", + Some(2) => "BEVEL", + _ => "MITER", + }), + ); + } + for (key, field) in [("startMarkerType", "strokeCapStart"), ("endMarkerType", "strokeCapEnd")] { + let cap = match style.get(key).and_then(Value::as_i64).unwrap_or(0) { + 0 => continue, + 1 => "LINE_ARROW", + 2 => "TRIANGLE_ARROW", + 5 => "CIRCLE_FILLED", + _ => { + cx.warn("line ends other than arrows and filled circles were drawn without an end"); + continue; + } + }; + node.insert(field.into(), json!(cap)); + } +} + +/// Shadows, inner shadows and the blur as REST effects. +pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { + let mut out = Vec::new(); + for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { + for s in list(style, key) { + // Sketch 2025 and newer keep inner shadows in the one list, marked. + let ty = if s.get("isInnerShadow").and_then(Value::as_bool) == Some(true) { "INNER_SHADOW" } else { ty }; + let mut c = s.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5})); + let o = s.get("contextSettings").and_then(|c| num(c, "opacity")).unwrap_or(1.0).clamp(0.0, 1.0); + c["a"] = json!(c["a"].as_f64().unwrap_or(1.0) * o); + out.push(json!({ + "type": ty, + "visible": s.get("isEnabled").and_then(Value::as_bool).unwrap_or(true), + "radius": num(s, "blurRadius").unwrap_or(0.0).clamp(0.0, 1e6), + "spread": num(s, "spread").unwrap_or(0.0), + "offset": {"x": num(s, "offsetX").unwrap_or(0.0), "y": num(s, "offsetY").unwrap_or(0.0)}, + "color": c, + })); + } + } + // One `blur` (older files) or a list of `blurs` (Sketch 2025 and newer). + let one = style.get("blur").into_iter(); + let many = style.get("blurs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().take(MAX_LIST); + for b in one.chain(many).filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { + let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6); + // Sketch's blur radius is the blur's standard deviation (Gaussian, background and motion + // blurs alike); the REST shape's is twice it. + let deviation = radius * 2.0; + match b.get("type").and_then(Value::as_i64).unwrap_or(0) { + 0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": deviation})), + 3 => out.push(background_blur(b, deviation)), + 4 => out.push(json!({ + "type": "GLASS", + "visible": true, + "radius": radius, + "refraction": num(b, "distortion").unwrap_or(0.5).clamp(0.0, 1.0), + "depth": num(b, "depth").unwrap_or(0.5).clamp(0.0, 1.0), + })), + kind => { + // A motion blur softens an edge across it as much as a Gaussian one of the same + // radius; a zoom blur about as much as a plain blur of half its radius. + cx.warn("motion and zoom blurs were drawn as plain blurs"); + out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": if kind == 1 { deviation } else { radius / 2.0 }})); + } + } + } + out.truncate(MAX_LIST); + out +} + +/// A background blur; a progressive one (`isProgressive`) ramps along its gradient, whose stops' +/// opacity scales the radius at each end. +fn background_blur(b: &Value, radius: f64) -> Value { + let g = b.get("gradient").filter(|_| b.get("isProgressive").and_then(Value::as_bool) == Some(true)); + let Some(g) = g else { return json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius}) }; + let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let at = |s: Option<&Value>| s.and_then(|s| s.get("color")).and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0) * radius; + let end = |k: &str, d: (f64, f64)| { + let (x, y) = g.get(k).and_then(Value::as_str).and_then(super::path::parse_point).unwrap_or(d); + json!({"x": x.clamp(-1e3, 1e3), "y": y.clamp(-1e3, 1e3)}) + }; + json!({ + "type": "BACKGROUND_BLUR", + "visible": true, + "blurType": "PROGRESSIVE", + "startRadius": at(stops.first()), + "radius": at(stops.last()), + "startOffset": end("from", (0.5, 0.0)), + "endOffset": end("to", (0.5, 1.0)), + }) +} + +/// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral, +/// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note). +pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option { + 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"); + } + // Sketch scales about mid-grey by the contrast and then adds the brightness, + // (v - 0.5)·C + 0.5 + b; REST image filters multiply by the exposure E first, (v·E - 0.5)·c + 0.5. + // The two agree for c = C - 2b and E = C / c within the filters' 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 c = (contrast - 2.0 * b).clamp(1e-6, 2.0); + let e = (contrast / c).clamp(0.25, 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": (c - 1.0).clamp(-1.0, 1.0), + "exposure": (e.log2() / 2.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))?; + Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}})) +} + +/// The tint a style gives the layers inside: its first enabled plain fill with `layeringType` 1 +/// (Sketch 2025 and newer), or with no `layeringType` when `legacy` (older files: a group's or +/// instance's fill was its tint). Sketch draws a tint as the colour of everything inside. +pub(crate) fn tint_of(style: Option<&Value>, legacy: bool) -> Option { + let fills = style?.get("fills")?.as_array()?; + let f = fills.iter().filter(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false)).find(|f| { + match f.get("layeringType").and_then(Value::as_i64) { + Some(t) => t == 1, + None => legacy, + } + })?; + (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").filter(|c| c.is_object()).cloned()).flatten() +} + +/// Recolour `layers` (Sketch JSON) with tint `color`: every enabled fill and border takes it, +/// text takes it, nested instances take it as their own tint. Opacity stays as it was. +pub(crate) fn tint_layers(layers: &mut [Value], color: &Value, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + if let Some(st) = l.get_mut("style").and_then(Value::as_object_mut) { + for key in ["fills", "borders"] { + for p in st.get_mut(key).and_then(Value::as_array_mut).into_iter().flatten() { + if let Some(m) = p.as_object_mut() + && m.get("isEnabled").and_then(Value::as_bool) != Some(false) + && !matches!(m.get("fillType").and_then(Value::as_i64), Some(4)) + { + let c = tinted(m.get("color"), color); + m.insert("fillType".into(), json!(0)); + m.insert("color".into(), c); + } + } + } + if let Some(enc) = st.get_mut("textStyle").and_then(|t| t.get_mut("encodedAttributes")).and_then(Value::as_object_mut) { + let c = tinted(enc.get("MSAttributedStringColorAttribute"), color); + enc.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) { + for r in runs.iter_mut().filter_map(|r| r.get_mut("attributes")).filter_map(Value::as_object_mut) { + let c = tinted(r.get("MSAttributedStringColorAttribute"), color); + r.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if l.get("_class").and_then(Value::as_str) == Some("symbolInstance") { + set_tint(l, color); + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + tint_layers(kids, color, depth + 1); + } + } +} + +/// Colour `old` (Sketch JSON) tinted with `tint`: the tint's colour, with both opacities. +fn tinted(old: Option<&Value>, tint: &Value) -> Value { + let alpha = |c: Option<&Value>| c.and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0); + let mut c = tint.clone(); + if let Some(m) = c.as_object_mut() { + m.insert("alpha".into(), json!(alpha(Some(tint)) * alpha(old))); + } + c +} + +/// Give layer `l` (Sketch JSON) tint `color`: as its one fill. +pub(crate) fn set_tint(l: &mut Value, color: &Value) { + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color}); + if let Some(m) = l.as_object_mut() { + let st = m.entry("style").or_insert_with(|| json!({"_class": "style"})); + if let Some(st) = st.as_object_mut() { + st.insert("fills".into(), json!([fill])); + } + } +} + +/// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. +fn blend(mode: i64) -> Option<&'static str> { + const MODES: [&str; 18] = [ + "NORMAL", + "DARKEN", + "MULTIPLY", + "COLOR_BURN", + "LIGHTEN", + "SCREEN", + "COLOR_DODGE", + "OVERLAY", + "SOFT_LIGHT", + "HARD_LIGHT", + "DIFFERENCE", + "EXCLUSION", + "HUE", + "SATURATION", + "COLOR", + "LUMINOSITY", + "LINEAR_BURN", + "LINEAR_DODGE", + ]; + MODES.get(usize::try_from(mode).ok()?).copied() +} + +fn list<'v>(v: &'v Value, key: &str) -> &'v [Value] { + let l = v.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + l.get(..MAX_LIST).unwrap_or(l) +} + +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/sketch/src/rest/symbols.rs b/crates/sketch/src/rest/symbols.rs new file mode 100644 index 000000000..0f21efee3 --- /dev/null +++ b/crates/sketch/src/rest/symbols.rs @@ -0,0 +1,568 @@ +//! Sketch symbols → REST components and instances. +//! +//! A `symbolMaster` (on any page, or copied from a library into `document.foreignSymbols`) is a +//! component; a `symbolInstance` names it by `symbolID`. REST instances carry their layers, so an +//! instance is its master's layers with the instance's `overrideValues` applied, laid out at the +//! master's size (the import then resizes them to the instance by their constraints). An override +//! is named `_`: `path` is the `do_objectID` of a layer of the master, or of +//! nested instances and then a layer of theirs, joined by `/`; properties: `stringValue` (text), +//! `symbolID` (swap a nested instance; empty hides it), `image`, `layerStyle` and `textStyle` +//! (shared style ids). + +use std::borrow::Cow; +use std::collections::BTreeMap; + +use kurbo::Affine; +use serde_json::{Map as JsonMap, Value, json}; + +use super::shared::Shared; +use super::tree::{Cx, base, bool_field, children, local, size, str_field}; + +/// Work units an override handed on to a nested instance costs (the JSON values it adds). +const OVERRIDE_COST: u64 = 8; +/// Most masters read from one file. +const MAX_MASTERS: usize = 100_000; + +/// Every symbol master of a file by `symbolID`, and the library ones no page holds. +#[derive(Default)] +pub(crate) struct Symbols { + masters: BTreeMap, + /// What expanding each master costs (see [`weight`]). + weights: BTreeMap, + /// Masters copied from libraries that no page holds (they get a page of their own), with the + /// libraries' names. + pub foreign: Vec, + pub libraries: Vec, +} + +impl Symbols { + /// The masters of `pages` and of the document's library copies. + pub(crate) fn read(document: &Value, pages: &[(String, Value)]) -> Symbols { + let mut s = Symbols::default(); + let mut found = Vec::new(); + for (_, p) in pages { + masters_in(p, &mut found, 0); + } + for m in found { + s.add(m.clone()); + } + for f in document.get("foreignSymbols").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() { + let Some(m) = f.get("symbolMaster") else { continue }; + let Some(id) = str_field(m, "symbolID") else { continue }; + if s.masters.contains_key(id) { + continue; + } + if let Some(lib) = str_field(f, "sourceLibraryName") + && !s.libraries.iter().any(|l| l == lib) + { + s.libraries.push(lib.chars().take(64).collect()); + } + s.add(m.clone()); + s.foreign.push(m.clone()); + } + s + } + + fn add(&mut self, m: Value) { + if self.masters.len() >= MAX_MASTERS { + return; + } + if let Some(id) = str_field(&m, "symbolID") + && !self.masters.contains_key(id) + { + self.weights.insert(id.to_string(), weight(&m, 0)); + self.masters.insert(id.to_string(), m); + } + } + + fn master(&self, symbol_id: &str) -> Option<&Value> { + self.masters.get(symbol_id) + } +} + +/// What copying and mapping `v` costs, in work units: one per JSON value, one per 8 bytes of text. +pub(crate) fn weight(v: &Value, depth: usize) -> u64 { + if depth > 256 { + return 1; + } + match v { + Value::String(s) => 1 + s.len() as u64 / 8, + Value::Array(a) => 1 + a.iter().map(|x| weight(x, depth + 1)).sum::(), + Value::Object(o) => 1 + o.iter().map(|(k, x)| k.len() as u64 / 8 + weight(x, depth + 1)).sum::(), + _ => 1, + } +} + +fn masters_in<'v>(l: &'v Value, out: &mut Vec<&'v Value>, depth: usize) { + if depth > 64 { + return; + } + for c in super::tree::layers(l) { + if str_field(c, "_class") == Some("symbolMaster") { + out.push(c); + } else { + masters_in(c, out, depth + 1); + } + } +} + +/// A master's background as REST fills (masters show one only when they have it on). +pub(crate) fn master_fills(m: &Value) -> Option { + if bool_field(m, "hasBackgroundColor") != Some(true) { + return None; + } + let c = m.get("backgroundColor").and_then(super::tree::color)?; + Some(json!([{"type": "SOLID", "color": c}])) +} + +/// What a master shows as a frame (its instances the same): clipping, and in Sketch 2025 and newer +/// its style (fills, borders, effects) and corners. +fn frame_look(cx: &mut Cx, m: &Value, w: f64, h: f64, node: &mut JsonMap) { + // 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") + { + // Tint fills aren't painted (see `style::fills`). + super::style::apply(cx, st, w, h, node); + } + super::tree::style_corners(m, node); +} + +/// A symbol master as a COMPONENT node. Its own fields and its tail are mapped in functions that +/// return before its layers are (see [`super::tree::layer`]). +pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (node, l, world, (w, h), sid) = component_head(cx, l, parent, root); + cx.chain.push(sid); + let kids = children(cx, &l, world, (w, h), depth, false); + cx.chain.pop(); + Some(component_tail(node, &l, kids)) +} + +type Prepared<'v> = (JsonMap, Cow<'v, Value>, Affine, (f64, f64), String); + +#[inline(never)] +fn component_head<'v>(cx: &mut Cx, l: &'v Value, parent: Affine, root: bool) -> Prepared<'v> { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let mut node = base(l, "COMPONENT", parent, world, w, h, root); + frame_look(cx, l, w, h, &mut node); + if let Some(f) = master_fills(l) { + node.insert("fills".into(), f); + } + super::tree::extras(cx, l, h, &mut node); + let sid = str_field(l, "symbolID").unwrap_or("").to_string(); + // A graphic's fill tints its layers. + let l = match super::style::tint_of(l.get("style"), false).filter(|_| super::tree::spend_on_tint(cx, l)) { + Some(t) => { + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + Cow::Owned(c) + } + _ => Cow::Borrowed(l), + }; + (node, l, world, (w, h), sid) +} + +#[inline(never)] +fn component_tail(mut node: JsonMap, l: &Value, kids: Vec) -> Value { + node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); + Value::Object(node) +} + +/// A symbol instance as an INSTANCE node holding its master's layers with its overrides, or an +/// empty frame when its master isn't in the file. Its own fields and its tail are mapped in +/// functions that return before its layers are (see [`super::tree::layer`]). +pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (node, copy, world, (w, h), sid) = match instance_head(cx, l, parent, root) { + Ok(prepared) => prepared, + Err(done) => return done, + }; + cx.chain.push(sid); + let kids = children(cx, ©, world, (w, h), depth, false); + cx.chain.pop(); + Some(instance_tail(l, node, copy.into_owned(), kids)) +} + +#[inline(never)] +fn instance_head<'v>(cx: &mut Cx, l: &Value, parent: Affine, root: bool) -> Result, Option> { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let sid = str_field(l, "symbolID").unwrap_or(""); + let cost = cx.symbols.weights.get(sid).copied().unwrap_or(0); + if cx.symbols.master(sid).is_some() && !cx.spend(cost) { + let mut node = base(l, "FRAME", parent, world, w, h, root); + node.insert("children".into(), json!([])); + return Err(Some(Value::Object(node))); + } + let Some(m) = cx.symbols.master(sid).cloned() else { + cx.warn("instances of symbols that aren't in the file were imported as empty frames"); + let mut node = base(l, "FRAME", parent, world, w, h, root); + node.insert("children".into(), json!([])); + return Err(Some(Value::Object(node))); + }; + if cx.chain.iter().any(|c| c == sid) { + let name: String = str_field(&m, "name").unwrap_or("").chars().take(64).collect(); + cx.warn(format!("symbol {name} contains itself; the inner copy was left out")); + return Err(None); + } + // 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); + node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); + frame_look(cx, &m, w, h, &mut node); + // 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. + if let Some(st) = l.get("style") { + let mut st = st.clone(); + if let Some(o) = st.as_object_mut() { + o.remove("fills"); + } + super::style::apply(cx, &st, w, h, &mut node); + } + super::tree::extras(cx, l, h, &mut node); + // An instance shows its master's grids (else they'd differ, and count as an override). + super::proto::layout_grids(&m, h, &mut node); + let overrides = overrides(l); + let nested = nested(&overrides); + let mut copy = m; + let mut budget = cx.work_left(); + if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { + apply(layers, &overrides, &nested, &cx.shared, &cx.symbols, 0, &mut budget); + } + let used = cx.work_left().saturating_sub(budget); + cx.spend(used); + if budget == 0 { + // The budget ran out handing overrides on: noted. + cx.spend(1); + } + // The tint: the instance's own (a colour override on it, or its fill), else a graphic master's. + let own = str_field(l, "do_objectID").map(|id| format!("{id}_fillColor")); + let tint = own + .and_then(|k| overrides.get(&k).filter(|c| c.is_object()).cloned()) + .or_else(|| super::style::tint_of(l.get("style"), 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) + { + super::style::tint_layers(layers, &t, 0); + } + // The master's layers laid out for the instance's size, as Sketch does it. + let (mw, mh) = size(©); + // A graphic's layers resize in proportion, whatever their pins. + let graphic = copy.get("groupBehavior").and_then(Value::as_i64) == Some(2); + // A stack's layers are placed by its auto layout (when the file is opened), not by their pins. + let stacked = !graphic && super::stack::is_stack(©); + if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + && !stacked + && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) + { + resize(layers, (mw, mh), (w, h), cx.legacy_resize || graphic, 0); + } + Ok((node, Cow::Owned(copy), world, (w, h), sid.to_string())) +} + +#[inline(never)] +fn instance_tail(l: &Value, mut node: JsonMap, mut copy: Value, kids: Vec) -> Value { + node.insert("children".into(), Value::Array(kids)); + // The master's stack lays the instance out too (matched by the master's layer ids), sized as + // the instance says. + for k in ["horizontalSizing", "verticalSizing"] { + if let Some(v) = l.get(k) { + copy[k] = v.clone(); + } + } + super::stack::apply(©, &mut node); + let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); + for k in node.get_mut("children").and_then(Value::as_array_mut).into_iter().flatten() { + prefix_ids(k, &prefix, 0); + } + Value::Object(node) +} + +/// Lay `layers` (the children of a box of `old` size) out for the box at `new` size, the way Sketch +/// resizes a symbol instance's layers: groups pass the change on to their own layers. Files older +/// than format 110 scale everything; newer ones follow each layer's `resizingConstraint`. +fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + let mask = if legacy { 63 } else { super::tree::pin_mask(l) }; + let Some(f) = l.get_mut("frame").and_then(Value::as_object_mut) else { continue }; + let n = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let (x, y, w, h) = (n("x"), n("y"), n("width").max(0.0), n("height").max(0.0)); + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + let pinned = |bit: i64| mask & bit == 0; + let (nx, nw) = resize_axis(x, w, old.0, new.0, (pinned(4), pinned(2), pinned(1))); + let (ny, nh) = resize_axis(y, h, old.1, new.1, (pinned(32), pinned(16), pinned(8))); + for (k, v) in [("x", nx), ("y", ny), ("width", nw), ("height", nh)] { + f.insert(k.into(), json!(v)); + } + // A graphic's layers resize in proportion, whatever their pins. + let graphic = l.get("groupBehavior").and_then(Value::as_i64) == Some(2); + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + resize(kids, (w, h), (nw, nh), legacy || graphic, depth + 1); + } + } +} + +/// One axis of [`resize`]: a layer at `pos` of `size` in a parent going from `old` to `new`, with +/// its start, size and end pinned or not. Nothing pinned (or everything): it scales. One side +/// pinned: the size and the free side share the change, in proportion to their lengths. Only the +/// size fixed: its centre scales. Both sides pinned: the size takes the change. +fn resize_axis(pos: f64, size: f64, old: f64, new: f64, (start, fixed, end): (bool, bool, bool)) -> (f64, f64) { + if old <= 1e-9 || !new.is_finite() { + return (pos, size); + } + let k = new / old; + let d = new - old; + let far = old - pos - size; + let share = |part: f64, other: f64| if part + other > 1e-9 { d * part / (part + other) } else { d }; + let (p, s) = match (start, fixed, end) { + (true, false, false) => (pos, size + share(size, far)), + (false, false, true) => { + let s = size + share(size, pos); + (new - far - s, s) + } + (false, true, false) => ((pos + size / 2.0) * k - size / 2.0, size), + (true, false, true) => (pos, size + d), + (true, true, false) => (pos, size), + (false, true, true) => (new - far - size, size), + _ => (pos * k, size * k), + }; + (if p.is_finite() { p } else { pos }, if s.is_finite() { s.max(0.0) } else { size }) +} + +/// An instance's overrides by name (`_` → value). +fn overrides(l: &Value) -> BTreeMap { + l.get("overrideValues") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .take(100_000) + .filter_map(|o| Some((str_field(o, "overrideName")?.to_string(), o.get("value")?.clone()))) + .collect() +} + +/// The overrides of layers inside nested instances, by the nested instance's id: `X/rest_prop` → +/// `X` → [(`rest_prop`, value)]. +fn nested(ov: &BTreeMap) -> BTreeMap<&str, Vec<(&str, &Value)>> { + let mut out: BTreeMap<&str, Vec<(&str, &Value)>> = BTreeMap::new(); + for (k, v) in ov { + if let Some((first, rest)) = k.split_once('/') { + out.entry(first).or_default().push((rest, v)); + } + } + out +} + +/// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested +/// instances for the layers inside them (an outer override wins over the nested instance's own). +/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget` plus its +/// value's [`weight`]; once it is spent, the rest aren't handed on. +fn apply( + layers: &mut [Value], + ov: &BTreeMap, + nested: &BTreeMap<&str, Vec<(&str, &Value)>>, + shared: &Shared, + symbols: &Symbols, + depth: usize, + budget: &mut u64, +) { + if depth > 64 || ov.is_empty() { + return; + } + for l in layers { + let Some(id) = str_field(l, "do_objectID").map(str::to_string) else { continue }; + let get = |prop: &str| ov.get(&format!("{id}_{prop}")); + match str_field(l, "_class") { + Some("symbolInstance") => { + match get("symbolID").map(|v| v.as_str().unwrap_or("")) { + Some("") => l["isVisible"] = json!(false), + // A swap to a symbol the file doesn't have keeps the original, as in Sketch. + Some(other) if symbols.master(other).is_some() => l["symbolID"] = json!(other), + _ => {} + } + let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default(); + // Each costs its copy's size: deep chains of large overrides use up the budget. + let mut take = 0; + for (_, v) in inner { + let cost = OVERRIDE_COST + weight(v, 0); + if cost > *budget { + break; + } + *budget -= cost; + take += 1; + } + if take > 0 { + let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); + own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); + l["overrideValues"] = Value::Array(own); + } + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on an instance is its tint. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) { + super::style::set_tint(l, c); + } + continue; + } + Some("text") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + if let Some(s) = get("stringValue").and_then(Value::as_str) { + set_string(l, s); + } + if let Some(c) = get("textColor").filter(|c| c.is_object()) { + super::style::tint_layers(std::slice::from_mut(l), c, 0); + } + } + Some("bitmap") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) { + l["image"] = img.clone(); + } + } + _ => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on a shape or group: its first fill's colour. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) + && let Some(f) = l + .get_mut("style") + .and_then(|s| s.get_mut("fills")) + .and_then(Value::as_array_mut) + .and_then(|f| f.first_mut()) + .and_then(Value::as_object_mut) + { + f.insert("fillType".into(), json!(0)); + f.insert("color".into(), c.clone()); + } + } + } + for (prop, text) in [("layerStyle", false), ("textStyle", true)] { + let sid = get(prop).and_then(Value::as_str); + // Shared styles that aren't objects are junk: left alone. + let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared.style(text, sid).filter(|s| s.is_object())?))) else { continue }; + let mut style = style.clone(); + if let Some(m) = style.as_object_mut() { + // The layer now uses that shared style: it links to it. + m.insert("sharedObjectID".into(), json!(sid)); + } + if text + && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) + && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) + { + for r in runs.iter_mut().filter_map(Value::as_object_mut) { + r.insert("attributes".into(), enc.clone()); + } + } + if let Some(m) = l.as_object_mut() { + m.insert("style".into(), style); + } + } + // An image override on a shape: its top image fill's image (after a layer style override, + // which would replace the fills). + if str_field(l, "_class") != Some("bitmap") + && 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()); + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + apply(kids, ov, nested, shared, symbols, depth + 1, budget); + } + } +} + +/// A visibility override's value: "0", 0 or false hide. +fn visible(v: &Value) -> bool { + !matches!(v, Value::Bool(false)) && v.as_str() != Some("0") && v.as_i64() != Some(0) +} + +/// A text layer's string replaced, in the style of its first run. +fn set_string(l: &mut Value, s: &str) { + let units = s.encode_utf16().count(); + let first = l.get("attributedString").and_then(|a| a.get("attributes")).and_then(Value::as_array).and_then(|a| a.first()).cloned(); + let Some(a) = l.get_mut("attributedString").and_then(Value::as_object_mut) else { return }; + a.insert("string".into(), json!(s)); + let runs = match first { + Some(Value::Object(mut r)) => { + r.insert("location".into(), json!(0)); + r.insert("length".into(), json!(units)); + vec![Value::Object(r)] + } + _ => Vec::new(), + }; + a.insert("attributes".into(), Value::Array(runs)); +} + +/// Make the ids of an instance's layers its own (`;`): the master's layers keep +/// theirs. +fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) { + if depth > 256 { + return; + } + let Some(m) = v.as_object_mut() else { return }; + if let Some(id) = m.get("id").and_then(Value::as_str).map(str::to_string) { + m.insert("id".into(), json!(format!("{prefix};{id}"))); + } + if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) { + for k in kids { + prefix_ids(k, prefix, depth + 1); + } + } +} + +/// Library masters no page holds: a page of their own, side by side. +pub(crate) fn library_page(cx: &mut Cx, masters: &[Value]) -> Value { + let mut x = 0.0; + let mut kids = Vec::new(); + for m in masters { + let mut placed = m.clone(); + let (w, _) = size(m); + if let Some(f) = placed.get_mut("frame").and_then(Value::as_object_mut) { + f.insert("x".into(), json!(x)); + f.insert("y".into(), json!(0.0)); + } + x += w + 100.0; + if let Some(node) = component(cx, &placed, Affine::IDENTITY, 0, true) { + kids.push(node); + } + } + let mut page = JsonMap::new(); + page.insert("type".into(), json!("CANVAS")); + page.insert("id".into(), json!("library-symbols")); + page.insert("name".into(), json!("Library symbols")); + page.insert("children".into(), Value::Array(kids)); + Value::Object(page) +} diff --git a/crates/sketch/src/rest/tests.rs b/crates/sketch/src/rest/tests.rs new file mode 100644 index 000000000..6c134facd --- /dev/null +++ b/crates/sketch/src/rest/tests.rs @@ -0,0 +1,313 @@ +//! The mapping on files built in code. +#![allow(clippy::unwrap_used, clippy::panic)] + +use serde_json::{Value, json}; + +use super::fixture::*; +use super::*; + +fn map(z: &[u8]) -> Mapped { + to_rest(&Archive::open(z).unwrap(), "f", Options::default()) +} + +/// The fixture file: an artboard holding a filled rectangle, a triangle path, a star, a text with +/// two runs, a stack of two, and an instance of a symbol with its text overridden. +fn fixture_file() -> Vec { + let red = styled(rect("R", 10.0, 10.0, 40.0, 20.0), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)])); + let tri = shape_path("T", 60.0, 10.0, 20.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], true); + let mut star = shape_path("S", 0.0, 50.0, 20.0, 20.0, &[], true); + star["_class"] = json!("star"); + star["numberOfPoints"] = json!(5); + star["radius"] = json!(0.5); + let pts: Vec<(f64, f64)> = (0..10) + .map(|i| { + let t = -std::f64::consts::FRAC_PI_2 + std::f64::consts::PI * f64::from(i) / 5.0; + let f = if i % 2 == 0 { 1.0 } else { 0.5 }; + (0.5 + 0.5 * f * t.cos(), 0.5 + 0.5 * f * t.sin()) + }) + .collect(); + star["points"] = Value::Array(pts.iter().map(|(x, y)| point(*x, *y)).collect()); + let label = text( + "L", + 0.0, + 80.0, + 100.0, + 20.0, + "Hello there", + &[(0, 6, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0]), (6, 5, "Helvetica-Bold", 14.0, [1.0, 0.0, 0.0, 1.0])], + ); + let row = stack( + frame_group("K", 0.0, 120.0, 100.0, 30.0, vec![rect("K1", 0.0, 0.0, 10.0, 10.0), rect("K2", 20.0, 0.0, 10.0, 10.0)]), + 0, + 0, + 1, + 8.0, + [4.0, 2.0, 4.0, 2.0], + ); + let inst = instance("I", "SYM", 0.0, 160.0, 80.0, 20.0, &[("MT_stringValue", json!("Changed"))]); + let board = artboard("A", "Board", 0.0, 0.0, 400.0, 300.0, vec![red, tri, star, label, row, inst]); + let master_text = text("MT", 0.0, 0.0, 80.0, 20.0, "Button", &[(0, 6, "Helvetica", 12.0, [0.0, 0.0, 0.0, 1.0])]); + let sym = master("M", "SYM", "Button", 80.0, 20.0, vec![master_text]); + sketch_zip("2026.3.1", 196, document(&["P", "Q"]), &[page("P", "Page 1", vec![board]), page("Q", "Symbols", vec![sym])], &[]) +} + +/// A node of the mapped document by id. +fn by_id<'v>(json: &'v Value, id: &str) -> &'v Value { + let mut pending = vec![&json["document"]]; + while let Some(n) = pending.pop() { + if n["id"] == id { + return n; + } + pending.extend(n["children"].as_array().into_iter().flatten()); + } + panic!("no layer {id}") +} + +#[test] +fn the_fixture_maps_onto_the_rest_shape() { + let out = map(&fixture_file()); + let json = &out.json; + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let pages: Vec<&str> = json["document"]["children"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(pages, ["Page 1", "Symbols"]); + let board = by_id(json, "A"); + assert_eq!((board["type"].as_str(), board["size"]["x"].as_f64()), (Some("FRAME"), Some(400.0))); + // A rectangle with its fill, placed in the artboard. + let red = by_id(json, "R"); + assert_eq!(red["type"], "RECTANGLE"); + assert_eq!(red["fills"][0]["color"], json!({"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0})); + assert_eq!(red["relativeTransform"], json!([[1.0, 0.0, 10.0], [0.0, 1.0, 10.0]])); + // A path: a vector whose network is handed over beside the JSON. + let tri = by_id(json, "T"); + assert_eq!((tri["type"].as_str(), tri["vectorNetworkRef"].as_u64()), (Some("VECTOR"), Some(0))); + let net = out.networks[0].as_ref().unwrap(); + assert_eq!(net.vertices.iter().map(|v| (v.x, v.y)).collect::>(), [(0.0, 0.0), (20.0, 0.0), (10.0, 10.0)]); + assert_eq!(net.regions[0].loops, [vec![0, 1, 2]]); + assert!(net.is_consistent()); + // A star whose points are still the generated ones: a star with the extensions. + let star = by_id(json, "S"); + assert_eq!((star["type"].as_str(), star["pointCount"].as_u64(), star["innerRadius"].as_f64()), (Some("STAR"), Some(5), Some(0.5))); + // Text: the second run bold and red, by UTF-16 offsets. + let label = by_id(json, "L"); + assert_eq!(label["characters"], "Hello there"); + assert_eq!(label["characterStyleOverrides"], json!([0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1])); + assert_eq!(label["style"]["fontFamily"], "Helvetica"); + assert_eq!(label["styleOverrideTable"]["1"]["fontStyle"], "Bold"); + // A stack: auto layout with its gap and padding; the first item is listed last. + let row = by_id(json, "K"); + assert_eq!((row["layoutMode"].as_str(), row["itemSpacing"].as_f64(), row["paddingLeft"].as_f64()), (Some("HORIZONTAL"), Some(8.0), Some(4.0))); + // A symbol master and an instance of it, its text overridden. + assert_eq!(by_id(json, "M")["type"], "COMPONENT"); + let inst = by_id(json, "I"); + assert_eq!((inst["type"].as_str(), inst["componentId"].as_str()), (Some("INSTANCE"), Some("M"))); + assert_eq!(inst["children"][0]["characters"], "Changed"); +} + +#[test] +fn the_result_reads_as_the_typed_model() { + let out = map(&fixture_file()); + let file = craft_figma_rest::File::from_value(out.json.clone(), 1000).unwrap(); + let doc = file.document.as_ref().unwrap(); + let label = doc.walk().find(|n| n.id.as_deref() == Some("L")).unwrap(); + assert_eq!(label.kind, Some(craft_figma_rest::NodeType::Text)); + assert_eq!(label.character_style_overrides.as_ref().map(Vec::len), Some(11)); + let star = doc.walk().find(|n| n.id.as_deref() == Some("S")).unwrap(); + assert_eq!(star.extra["pointCount"], 5); + assert_eq!(craft_figma_rest::File::from_value(file.to_value().unwrap(), 1000).unwrap(), file); +} + +#[test] +fn text_stored_the_old_way_marks_the_file_too_old() { + let old = json!({"_class": "text", "do_objectID": "T", "name": "T", "frame": {"_class": "rect", "x": 0, "y": 0, "width": 10, "height": 10}, + "attributedString": {"_class": "MSAttributedString", "archivedAttributedString": {"_archive": "YnBsaXN0MDA="}}}); + let z = raw_zip(&document(&["P"]), &json!({"app": "com.bohemiancoding.sketch3"}), &[page("P", "P", vec![old])], &[]); + assert!(map(&z).too_old); + assert!(!map(&fixture_file()).too_old); +} + +#[test] +fn deep_nesting_stops_with_a_warning_within_a_small_stack() { + // 70 masters, each a group holding an instance of the next: expanded, the layers nest 140 + // deep, past the limit, while no page's JSON is deep. Nested instances take the most stack + // per level; the mapping runs on a 1 MiB thread, the stack of a WebAssembly build. + let run = || { + let n = 70; + let masters: Vec = (0..n) + .map(|i| { + let inner = if i < n - 1 { + instance(&format!("I{i}"), &format!("S{}", i + 1), 0.0, 0.0, 1.0, 1.0, &[]) + } else { + rect("R", 0.0, 0.0, 1.0, 1.0) + }; + master(&format!("M{i}"), &format!("S{i}"), "Nest", 1.0, 1.0, vec![group(&format!("G{i}"), 0.0, 0.0, 1.0, 1.0, vec![inner])]) + }) + .collect(); + let top = instance("TOP", "S0", 0.0, 0.0, 1.0, 1.0, &[]); + let z = sketch_zip("50.2", 103, document(&["P", "Q"]), &[page("P", "P", vec![top]), page("Q", "Symbols", masters)], &[]); + let out = map(&z); + assert!( + out.warnings.iter().any(|w| *w == format!("layers nested deeper than {} levels were skipped", tree::MAX_DEPTH)), + "{:?}", + out.warnings + ); + assert!(craft_figma_rest::check(&out.json, usize::MAX).is_ok()); + // Within the limit, nothing is skipped. + let shallow: Vec = (0..20) + .map(|i| { + let inner = + if i < 19 { instance(&format!("I{i}"), &format!("S{}", i + 1), 0.0, 0.0, 1.0, 1.0, &[]) } else { rect("R", 0.0, 0.0, 1.0, 1.0) }; + master(&format!("M{i}"), &format!("S{i}"), "Nest", 1.0, 1.0, vec![group(&format!("G{i}"), 0.0, 0.0, 1.0, 1.0, vec![inner])]) + }) + .collect(); + let top = instance("TOP", "S0", 0.0, 0.0, 1.0, 1.0, &[]); + let z = sketch_zip("50.2", 103, document(&["P", "Q"]), &[page("P", "P", vec![top]), page("Q", "Symbols", shallow)], &[]); + assert!(!map(&z).warnings.iter().any(|w| w.contains("nested deeper"))); + }; + std::thread::Builder::new().stack_size(1 << 20).spawn(run).unwrap().join().unwrap(); +} + +#[test] +fn the_layer_cap_holds() { + let rects: Vec = (0..10).map(|i| rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0)).collect(); + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", rects)], &[]); + let out = to_rest(&Archive::open(&z).unwrap(), "f", Options { max_nodes: 4, ..Options::default() }); + assert_eq!(out.json["document"]["children"][0]["children"].as_array().map(Vec::len), Some(4)); + assert!(out.warnings.iter().any(|w| w.contains("more than 4 layers")), "{:?}", out.warnings); +} + +#[test] +fn a_path_past_the_point_cap_is_cut() { + let pts: Vec<(f64, f64)> = (0..100_005).map(|i| (f64::from(i % 2), f64::from(i) / 100_005.0)).collect(); + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![shape_path("L", 0.0, 0.0, 10.0, 10.0, &pts, false)])], &[]); + let out = map(&z); + assert!(out.warnings.iter().any(|w| w == "paths with more than 100000 points were cut"), "{:?}", out.warnings); + assert_eq!(out.networks[0].as_ref().map(|n| n.vertices.len()), Some(100_000)); +} + +#[test] +fn notes_are_kept_once_each_and_capped() { + // Layers of 250 unknown kinds: one note each, and past 200 one saying the rest were left out. + let layers: Vec = (0..250) + .map(|i| json!({"_class": format!("mystery{i}"), "do_objectID": format!("X{i}"), "frame": {"x": 0, "y": 0, "width": 1, "height": 1}})) + .collect(); + let mut twice = layers.clone(); + twice.extend(layers); + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", twice)], &[]); + let out = map(&z); + assert!(out.warnings.len() <= 202, "{}", out.warnings.len()); + assert_eq!(out.warnings.iter().filter(|w| *w == "more notes were left out").count(), 1, "{:?}", &out.warnings[out.warnings.len() - 3..]); + let distinct: std::collections::BTreeSet<&String> = out.warnings.iter().collect(); + assert_eq!(distinct.len(), out.warnings.len()); +} + +#[test] +fn symbol_expansion_stops_at_its_work_budget() { + // A master of 100 layers and 50 instances of it: each expansion copies the master's values. + let leaves: Vec = (0..100).map(|i| rect(&format!("L{i}"), 0.0, 0.0, 1.0, 1.0)).collect(); + let insts: Vec = (0..50).map(|i| instance(&format!("I{i}"), "S0", 0.0, 0.0, 10.0, 10.0, &[])).collect(); + let z = sketch_zip( + "50.2", + 103, + document(&["P", "Q"]), + &[page("P", "P", insts), page("Q", "Symbols", vec![master("M0", "S0", "Leaf", 10.0, 10.0, leaves)])], + &[], + ); + let archive = Archive::open(&z).unwrap(); + let note = "the file's symbols expand to more than the import handles; the instances past that were imported as empty frames"; + let full = tree::to_rest_within(&archive, "f", usize::MAX, None, 60_000_000); + assert!(!full.warnings.iter().any(|w| w == note), "{:?}", full.warnings); + let capped = tree::to_rest_within(&archive, "f", usize::MAX, None, 20_000); + assert!(capped.warnings.iter().any(|w| w == note), "{:?}", capped.warnings); + let kept = |m: &tree::Map| { + m.json["document"]["children"][0]["children"] + .as_array() + .unwrap() + .iter() + .filter(|i| i["children"].as_array().is_some_and(|c| !c.is_empty())) + .count() + }; + assert_eq!(kept(&full), 50); + assert!(kept(&capped) < 50); +} + +/// A file using most of what the mapping reads, with each of its values in turn replaced by a +/// value of another type: every mapping ends without a panic. +#[test] +fn junk_values_never_panic() { + fn paths(v: &Value, at: &mut Vec, out: &mut Vec>) { + out.push(at.clone()); + match v { + Value::Object(m) => { + for (k, c) in m { + at.push(k.clone()); + paths(c, at, out); + at.pop(); + } + } + Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + at.push(i.to_string()); + paths(c, at, out); + at.pop(); + } + } + _ => {} + } + } + let archive_bytes = fixture_file(); + let archive = Archive::open(&archive_bytes).unwrap(); + let pages: Vec = archive.pages.iter().map(|(_, p)| p.clone()).collect(); + let junk = [json!(null), json!(5), json!(-1e300), json!("x"), json!(true), json!([]), json!({}), json!([1, "a"])]; + for (pi, p) in pages.iter().enumerate() { + let mut all = Vec::new(); + paths(p, &mut Vec::new(), &mut all); + for (n, at) in all.iter().enumerate().skip(1).step_by(3) { + let mut changed = pages.clone(); + let mut v = &mut changed[pi]; + for k in at { + v = match v { + Value::Object(m) => m.get_mut(k).unwrap(), + Value::Array(a) => &mut a[k.parse::().unwrap()], + _ => panic!("a path only runs through objects and arrays"), + }; + } + *v = junk[n % junk.len()].clone(); + let z = sketch_zip("2026.3.1", 196, document(&["P", "Q"]), &changed, &[]); + if let Ok(a) = Archive::open(&z) { + let _ = to_rest(&a, "f", Options::default()); + } + } + } +} + +#[test] +fn installed_fonts_are_asked_by_postscript_name() { + let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let seen = std::rc::Rc::clone(&asked); + let fonts: FontLookup = Box::new(move |ps: &str| { + seen.borrow_mut().push(ps.to_string()); + (ps == "Helvetica-Bold").then(|| ("Helvetica Neue".to_string(), "Heavy".to_string())) + }); + let out = to_rest(&Archive::open(&fixture_file()).unwrap(), "f", Options { fonts: Some(fonts), ..Options::default() }); + assert!(asked.borrow().iter().any(|p| p == "Helvetica-Bold")); + let label = by_id(&out.json, "L"); + assert_eq!(label["styleOverrideTable"]["1"]["fontFamily"], "Helvetica Neue"); + assert_eq!(label["styleOverrideTable"]["1"]["fontStyle"], "Heavy"); +} + +#[test] +fn a_shape_group_combines_its_paths() { + // Sketch 51 and older: a shape group in its own style; a second path subtracted from the first. + let a = shape_path("A", 0.0, 0.0, 20.0, 20.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], true); + let mut b = shape_path("B", 5.0, 5.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], true); + b["booleanOperation"] = json!(1); + let g = styled(shape_group("G", 0.0, 0.0, 20.0, 20.0, vec![a, b]), "fills", json!([solid_fill(0.0, 0.0, 1.0, 1.0)])); + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![g])], &[]); + let out = map(&z); + let group = by_id(&out.json, "G"); + let ops: Vec<&str> = std::iter::successors(Some(group), |n| n["children"].as_array().and_then(|c| c.first())) + .filter_map(|n| n["booleanOperation"].as_str()) + .collect(); + assert!(ops.contains(&"SUBTRACT"), "{group}"); + assert!(out.networks.iter().flatten().all(craft_figma_rest::ext::VectorNetwork::is_consistent)); +} diff --git a/crates/sketch/src/rest/text.rs b/crates/sketch/src/rest/text.rs new file mode 100644 index 000000000..82c103f7b --- /dev/null +++ b/crates/sketch/src/rest/text.rs @@ -0,0 +1,245 @@ +//! Sketch text layers → REST text nodes. +//! +//! A text layer's `attributedString` holds the `string` and `attributes`: runs given by `location` +//! and `length` in UTF-16 code units (like the REST importer's per-character style ids), each with +//! a font (PostScript name and size), colour, kerning, paragraph style, decorations and text +//! transform. `textBehaviour` sizes the box (0 auto width, 1 fixed width, 2 fixed); the style's +//! `textStyle` gives the vertical alignment. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, color}; + +/// Most characters read from one text layer (the REST importer's own limit). +const MAX_CHARS: usize = 1 << 20; +/// Most runs read from one text layer. +const MAX_RUNS: usize = 10_000; +/// Most characters that styled runs may write in all, overlaps counted again (a run's range is +/// rewritten in full, even where later runs overwrite it). +const MAX_RUN_WORK: usize = 16 * MAX_CHARS; + +/// Fill `node` (a TEXT node) from text layer `l`. +pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let a = l.get("attributedString"); + let string: String = a.and_then(|a| a.get("string")).and_then(Value::as_str).unwrap_or("").chars().take(MAX_CHARS).collect(); + let units = string.encode_utf16().count(); + let runs: &[Value] = a.and_then(|a| a.get("attributes")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + if runs.len() > MAX_RUNS { + cx.warn(format!("text with more than {MAX_RUNS} styled runs keeps the first ones")); + } + let fallback = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("encodedAttributes")); + let styles: Vec<(usize, usize, Value)> = runs + .iter() + .take(MAX_RUNS) + .filter_map(|r| { + let start = usize::try_from(r.get("location")?.as_u64()?).ok()?.min(units); + let len = usize::try_from(r.get("length")?.as_u64()?).unwrap_or(usize::MAX); + Some((start, start.saturating_add(len).min(units), type_style(cx, r.get("attributes")?))) + }) + .collect(); + let base = styles.first().map(|s| s.2.clone()).or_else(|| fallback.map(|f| type_style(cx, f))).unwrap_or_else(|| type_style(cx, &json!({}))); + let mut style = base.clone(); + let mut fills = None; + if let Some(m) = style.as_object_mut() { + fills = m.remove("fills"); + // Paragraph settings are the first run's. + let paragraph = runs.first().and_then(|r| r.get("attributes")).or(fallback).and_then(|a| a.get("paragraphStyle")); + let align = match paragraph.and_then(|p| p.get("alignment")).and_then(Value::as_i64) { + Some(1) => "RIGHT", + Some(2) => "CENTER", + Some(3) => "JUSTIFIED", + _ => "LEFT", + }; + m.insert("textAlignHorizontal".into(), json!(align)); + if let Some(sp) = paragraph.and_then(|p| p.get("paragraphSpacing")).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) { + m.insert("paragraphSpacing".into(), json!(sp.min(1e4))); + } + let vertical = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("verticalAlignment")).and_then(Value::as_i64); + m.insert( + "textAlignVertical".into(), + json!(match vertical { + Some(1) => "CENTER", + Some(2) => "BOTTOM", + _ => "TOP", + }), + ); + // Sketch 2025 and newer size text by `horizontalSizing`/`verticalSizing` (1 fits). + let fits = |k: &str| l.get(k).and_then(Value::as_i64) == Some(1); + let resize = match l.get("textBehaviour").and_then(Value::as_i64) { + Some(1) => "HEIGHT", + Some(2) => "NONE", + Some(_) => "WIDTH_AND_HEIGHT", + None if l.get("horizontalSizing").is_none() || fits("horizontalSizing") => "WIDTH_AND_HEIGHT", + None if fits("verticalSizing") => "HEIGHT", + None => "NONE", + }; + m.insert("textAutoResize".into(), json!(resize)); + } + node.insert("characters".into(), json!(string)); + node.insert("style".into(), style); + if let Some(f) = fills { + node.insert("fills".into(), f); + } + // Runs that differ from the first: a style id per UTF-16 unit. + let mut ids = vec![0u64; units]; + let mut table = JsonMap::new(); + let mut work = 0usize; + for (start, end, s) in &styles { + if *s == base { + continue; + } + work = work.saturating_add(end.saturating_sub(*start)); + if work > MAX_RUN_WORK { + cx.warn("text whose styled runs overlap a great deal keeps the first runs' styles"); + break; + } + let id = table.len() as u64 + 1; + table.insert(id.to_string(), s.clone()); + for slot in ids.get_mut(*start..*end).into_iter().flatten() { + *slot = id; + } + } + if !table.is_empty() { + node.insert("characterStyleOverrides".into(), json!(ids)); + node.insert("styleOverrideTable".into(), Value::Object(table)); + } +} + +/// 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); the REST shape, 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")); + let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or(""); + let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0); + let (family, style) = installed(cx, ps).unwrap_or_else(|| font_name(ps)); + if ps.is_empty() { + cx.warn("text without a font uses Helvetica"); + } + let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)}); + // Every field is written, neutral when the run has none: a run's style starts from the text's + // (its first run's), and an unwritten field would keep that one's value. + s["fontVariations"] = font.and_then(|f| f.get("variation")).and_then(variations).unwrap_or_else(|| json!({})); + let p = a.get("paragraphStyle"); + let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) { + s["lineHeightPx"] = json!(lh.min(1e4)); + s["lineHeightUnit"] = json!("PIXELS"); + } else { + s["lineHeightUnit"] = json!("INTRINSIC_%"); + } + s["letterSpacing"] = json!(a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()).unwrap_or(0.0).clamp(-1e4, 1e4)); + let set = |k: &str| a.get(k).and_then(Value::as_i64).is_some_and(|v| v != 0); + s["textDecoration"] = json!(if set("underlineStyle") { + "UNDERLINE" + } else if set("strikethroughStyle") { + "STRIKETHROUGH" + } else { + "NONE" + }); + s["textCase"] = json!(match a.get("MSAttributedStringTextTransformAttribute").and_then(Value::as_i64) { + Some(1) => "UPPER", + Some(2) => "LOWER", + _ => "ORIGINAL", + }); + let c = a.get("MSAttributedStringColorAttribute").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0})); + let mut paint = json!({"type": "SOLID", "color": c}); + if let Some(var) = super::style::swatch(cx, a.get("MSAttributedStringColorAttribute")) { + paint["boundVariables"] = var; + } + s["fills"] = json!([paint]); + s +} + +/// A font's `variation` (axis values keyed by the axis tag read as a big-endian number, as Apple's +/// text engine keys them: 2003265652 is `wght`) as `{tag: value}`; `None` when it has none. +fn variations(v: &Value) -> Option { + let axes: JsonMap = v + .as_object()? + .iter() + .take(64) + .filter_map(|(k, val)| { + let tag = u32::try_from(k.parse::().ok()?).ok()?.to_be_bytes(); + let tag = std::str::from_utf8(&tag).ok().filter(|t| t.chars().all(|c| c.is_ascii_graphic()))?.to_string(); + let val = val.as_f64().filter(|x| x.is_finite())?; + Some((tag, json!(val.clamp(-1e5, 1e5)))) + }) + .collect(); + (!axes.is_empty()).then_some(Value::Object(axes)) +} + +/// The installed family and style of PostScript name `ps`, when the import has a font lookup and +/// the font is installed. +fn installed(cx: &mut Cx, ps: &str) -> Option<(String, String)> { + if ps.is_empty() { + return None; + } + if let Some(found) = cx.font_cache.get(ps) { + return found.clone(); + } + let found = cx.fonts.as_ref().and_then(|f| f(ps)); + // A file names a few hundred fonts at most; junk ones don't grow the cache past that. + if cx.font_cache.len() < 4096 { + cx.font_cache.insert(ps.to_string(), found.clone()); + } + found +} + +/// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", +/// "Bold"). Words of the family are split at case changes, which can't always match the real name +/// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed +/// fonts ignoring spaces. Apple's system fonts take the SF Pro names, PingFang its own. +pub(crate) fn font_name(ps: &str) -> (String, String) { + let (family, style) = ps.split_once('-').unwrap_or((ps, "")); + let style = if style.is_empty() { "Regular".to_string() } else { craft_figma_rest::style_name(style) }; + let family = match family { + "" => "Helvetica".to_string(), + ".SFNSText" | ".SFUIText" | "SanFranciscoText" | ".SFProText" => "SF Pro Text".into(), + ".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(), + // Apple's CJK families whose names don't split at case changes. + "PingFangSC" | "PingFangTC" | "PingFangHK" => format!("PingFang {}", family.get(8..).unwrap_or("")), + // Apple's SF families: "SF" and then words ("SFUIText" → "SF UI Text"). + f if f.starts_with("SF") && f.chars().nth(2).is_some_and(char::is_uppercase) => format!("SF {}", words(f.get(2..).unwrap_or(""))), + f => words(f.trim_start_matches('.')), + }; + (family, style) +} + +/// "IBMPlexSans" → "IBM Plex Sans": a space before a capital that follows a small letter, and +/// before the last capital of a run of capitals followed by a small letter. +fn words(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len() + 4); + for (i, c) in chars.iter().enumerate() { + let prev = i.checked_sub(1).and_then(|p| chars.get(p)); + let next = chars.get(i + 1); + let split = c.is_uppercase() + && (prev.is_some_and(|p| p.is_lowercase()) || (prev.is_some_and(|p| p.is_uppercase()) && next.is_some_and(|n| n.is_lowercase()))); + if split { + out.push(' '); + } + out.push(*c); + } + out +} diff --git a/crates/sketch/src/rest/tree.rs b/crates/sketch/src/rest/tree.rs new file mode 100644 index 000000000..cb82a6091 --- /dev/null +++ b/crates/sketch/src/rest/tree.rs @@ -0,0 +1,735 @@ +//! Sketch pages and layers → REST API file JSON nodes. + +use craft_figma_rest::ext::VectorNetwork; +use std::borrow::Cow; + +use kurbo::{Affine, Rect}; +use serde_json::{Map as JsonMap, Value, json}; + +use crate::Archive; + +/// Most distinct notes kept. +const MAX_NOTES: usize = 200; +/// Work units symbol expansion may spend on one file (one per JSON value copied, see +/// `symbols::weight`): about three times what the largest design kit checked spends (21M, with +/// 2 GB at its peak), well below what runs out of memory; less on 32-bit targets (the web), where +/// such a kit keeps its first instances and notes the rest. +#[cfg(not(target_pointer_width = "32"))] +const MAX_WORK: u64 = 60_000_000; +#[cfg(target_pointer_width = "32")] +const MAX_WORK: u64 = 15_000_000; +/// Deepest layer nesting followed, symbol expansion included (deeper layers are skipped with a +/// warning). The walk recurses once per level: an unoptimised build maps about 140 levels of +/// nested instances on a 512 KiB stack, so this leaves twice the room on the 1 MiB stack of a +/// WebAssembly build. (FigCraft followed 200, which a debug build could not hold in 1 MiB.) +pub(crate) const MAX_DEPTH: usize = 128; +/// Largest size and farthest position kept (the REST importer's own bounds). +const MAX_SIZE: f64 = 1e6; +const MAX_COORD: f64 = 1e7; + +/// The REST JSON of a Sketch file, with what the mapping noticed. +pub(crate) struct Map { + pub json: Value, + pub warnings: Vec, + /// Vector networks the JSON references by `vectorNetworkRef`. + pub networks: Vec>, + /// A text layer stored the pre-48 way was met: the file is refused. + pub too_old: bool, +} + +/// The mapping's state while walking the file. +pub(crate) struct Cx { + pub symbols: super::symbols::Symbols, + pub shared: super::shared::Shared, + /// Ids of the styles made from the shared styles. + pub style_defs: std::collections::BTreeSet, + /// Work spent expanding symbols, and the most the file may take (see `symbols::weight`). + pub work: u64, + 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 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, + pub networks: Vec>, + pub too_old: bool, + /// Layers mapped so far, and the most the file may give. + pub nodes: usize, + pub max_nodes: usize, + /// Finds installed faces by PostScript name, and what it found (or didn't) so far. + pub fonts: Option, + pub font_cache: std::collections::BTreeMap>, +} + +impl Cx { + pub(crate) fn warn(&mut self, msg: impl Into) { + let msg = msg.into(); + if self.seen.len() > MAX_NOTES || !self.seen.insert(msg.clone()) { + return; + } + if self.seen.len() > MAX_NOTES { + self.warnings.push("more notes were left out".into()); + } else { + self.warnings.push(msg); + } + } + + /// Spend `n` units of the work budget; false (with a note, once) when it would run out. + pub(crate) fn spend(&mut self, n: u64) -> bool { + if self.work.saturating_add(n) > self.max_work { + self.work = self.max_work; + self.warn("the file's symbols expand to more than the import handles; the instances past that were imported as empty frames"); + return false; + } + self.work += n; + true + } + + /// Spend `n` units of the work budget on tinting; false (with a note, once) when it would run + /// out. + pub(crate) fn spend_on_tint(&mut self, n: u64) -> bool { + if self.work.saturating_add(n) > self.max_work { + self.warn("tints past what the import handles were skipped; those layers keep their own colours"); + return false; + } + self.work += n; + true + } + + pub(crate) fn work_left(&self) -> u64 { + self.max_work.saturating_sub(self.work) + } +} + +/// Charge the copy tinting layer `l` makes against the work budget: whether it may be tinted. +pub(crate) fn spend_on_tint(cx: &mut Cx, l: &Value) -> bool { + cx.spend_on_tint(super::symbols::weight(l, 0)) +} + +/// Map the pages of `archive` to a REST file JSON named `name`. +pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Option) -> Map { + to_rest_within(archive, name, max_nodes, fonts, MAX_WORK) +} + +/// [`to_rest`] with a work budget of `max_work`. +pub(crate) fn to_rest_within(archive: &Archive, name: &str, max_nodes: usize, fonts: Option, max_work: u64) -> Map { + let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); + let shared = super::shared::Shared::read(&archive.document); + let mut cx = Cx { + symbols, + shared, + style_defs: std::collections::BTreeSet::new(), + work: 0, + max_work, + seen: std::collections::BTreeSet::new(), + 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(), + too_old: false, + nodes: 0, + max_nodes, + fonts, + font_cache: std::collections::BTreeMap::new(), + }; + let defs = super::shared::Shared::definitions(&mut cx); + cx.style_defs = defs.iter().filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_string)).collect(); + let mut canvases = Vec::new(); + for (id, page) in &archive.pages { + let mut node = JsonMap::new(); + node.insert("type".into(), json!("CANVAS")); + node.insert("id".into(), json!(id)); + node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); + let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true); + node.insert("children".into(), Value::Array(kids)); + let starts = super::proto::starting_points(page); + if !starts.is_empty() { + node.insert("flowStartingPoints".into(), Value::Array(starts)); + } + canvases.push(Value::Object(node)); + } + let foreign = std::mem::take(&mut cx.symbols.foreign); + if !foreign.is_empty() { + canvases.push(super::symbols::library_page(&mut cx, &foreign)); + let libraries = cx.symbols.libraries.join(", "); + cx.warn(format!("symbols from the libraries {libraries} became local components on the page Library symbols")); + } + let mut json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}, "styleDefinitions": defs}); + if let Some((collections, variables)) = cx.shared.variables() { + json["variableCollections"] = collections; + json["variables"] = variables; + } + Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } +} + +/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. `parent`: +/// the parent layer's transform to the page; `root`: the layer sits on the page itself. +/// +/// The recursion through the layer tree runs through this function and [`children`] only, and +/// both keep their own frames small: the work on one layer is done in functions that return +/// before its children are mapped, so a deep file needs little stack per level. +pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + match layer_head(cx, l, parent, depth, root) { + Head::Done(node) => node, + Head::Group(_, _) if !count(cx) => None, + Head::Group(l, Nested::ShapeGroup) => super::path::shape_group(cx, &l, parent, depth, root), + Head::Group(l, Nested::Component) => super::symbols::component(cx, &l, parent, depth, root), + Head::Group(l, Nested::Instance) => super::symbols::instance(cx, &l, parent, depth, root), + Head::Layer(head) => { + let kids = if head.container { Some(children(cx, &head.l, head.world, (head.w, head.h), depth, false)) } else { None }; + Some(layer_tail(cx, *head, kids)) + } + } +} + +/// What [`layer_head`] made of a layer. +enum Head<'v> { + /// Mapped, or skipped (`None`). + Done(Option), + /// A layer of a kind with its own mapping. + Group(Cow<'v, Value>, Nested), + /// A layer mapped but for its children (when it is a `container`) and its fade. + Layer(Box>), +} + +/// A layer mapped but for its children and its fade. +struct Mapped<'v> { + l: Cow<'v, Value>, + node: JsonMap, + world: Affine, + w: f64, + h: f64, + container: bool, +} + +enum Nested { + ShapeGroup, + Component, + Instance, +} + +#[inline(never)] +fn layer_tail(cx: &mut Cx, head: Mapped, kids: Option>) -> Value { + let Mapped { l, mut node, world, w, h, .. } = head; + if let Some(kids) = kids { + node.insert("children".into(), Value::Array(kids)); + super::stack::apply(&l, &mut node); + } + fade(cx, &l, node, world, w, h) +} + +/// A layer's own fields ([`layer`] without its children). +#[inline(never)] +fn layer_head<'v>(cx: &mut Cx, l: &'v Value, parent: Affine, depth: usize, root: bool) -> Head<'v> { + if depth > MAX_DEPTH { + cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); + return Head::Done(None); + } + if is_archived_text(l) { + cx.too_old = true; + return Head::Done(None); + } + // A rectangle with angled or inside corners is a path of that outline. + let l: Cow<'v, Value> = match super::path::cut_corners(l) { + Some(p) => Cow::Owned(p), + None => Cow::Borrowed(l), + }; + let Some(class) = str_field(&l, "_class") else { return Head::Done(None) }; + let class = class.to_string(); + let class = class.as_str(); + if class == "shapeGroup" { + return Head::Group(l, Nested::ShapeGroup); + } + if class == "symbolMaster" { + return Head::Group(l, Nested::Component); + } + if class == "symbolInstance" { + return Head::Group(l, Nested::Instance); + } + let ty = match class { + "artboard" | "frame" => "FRAME", + // Sketch 2025 and newer: a group that behaves as a frame (1) or a graphic (2), or clips. + "group" if is_frame(&l) => "FRAME", + "group" | "graphic" => "GROUP", + // Shapes whose points were moved are paths. + "rectangle" | "oval" if bool_field(&l, "edited") == Some(true) => "VECTOR", + "rectangle" | "bitmap" => "RECTANGLE", + "oval" => "ELLIPSE", + "slice" => "SLICE", + "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", + "text" => "TEXT", + "MSImmutableHotspotLayer" | "hotspot" => "FRAME", + other => { + cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); + return Head::Done(None); + } + }; + if !count(cx) { + return Head::Done(None); + } + let (w, h) = size(&l); + 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); + } + if ty == "VECTOR" { + let Some(shape) = super::path::shape(cx, &l, w, h) else { return Head::Done(None) }; + 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) }; + node.insert("clipsContent".into(), json!(clips)); + let background = (bool_field(&l, "hasBackgroundColor") == Some(true)).then(|| l.get("backgroundColor").and_then(color)).flatten(); + if class == "artboard" || background.is_some() { + let c = background.unwrap_or_else(|| json!({"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0})); + node.insert("fills".into(), json!([{"type": "SOLID", "color": c}])); + } + } + "group" if ty == "FRAME" => { + // `clippingBehavior`: 0 the default (frames clip), 1 clip, 2 don't. + node.insert("clipsContent".into(), json!(l.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + } + "rectangle" => corner_radii(&l, &mut node), + "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { + Some(r) => { + 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; + } + 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; its fills then aren't a shared style's. + 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)); + if let Some(styles) = node.get_mut("styles").and_then(Value::as_object_mut) { + styles.remove("fill"); + } + } + extras(cx, &l, h, &mut node); + style_corners(&l, &mut node); + if class == "MSImmutableHotspotLayer" || class == "hotspot" { + node.insert("clipsContent".into(), json!(false)); + node.remove("fills"); + } + // A group's or graphic's fill tints its layers (a frame's is its background). + let gb = l.get("groupBehavior").and_then(Value::as_i64); + 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. + if gb != Some(1) { + node.remove("fills"); + } + let mut c = l.into_owned(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + Cow::Owned(c) + } + _ => l, + }; + let container = matches!(ty, "FRAME" | "GROUP"); + Head::Layer(Box::new(Mapped { l, node, world, w, h, container })) +} +/// Layer `node` with a progressive opacity (Fade: the style's `contextSettings` with +/// `isProgressive` and a gradient whose stops' opacity is the layer's) as a group in its place +/// holding an alpha mask of that gradient over it; the REST shape has no progressive opacity. Other +/// layers as they are. +fn fade(cx: &mut Cx, l: &Value, node: JsonMap, world: Affine, w: f64, h: f64) -> Value { + let ctx = l.get("style").and_then(|s| s.get("contextSettings")); + let gradient = ctx.filter(|c| c.get("isProgressive").and_then(Value::as_bool) == Some(true)).and_then(|c| c.get("gradient")); + // A mask stays as it is: its group would stop masking the layers above it. + let Some(gradient) = gradient.filter(|_| node.get("isMask").and_then(Value::as_bool) != Some(true)) else { return Value::Object(node) }; + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "gradient": gradient}); + let fills = super::style::fills(cx, &json!({"fills": [fill]}), w, h); + if fills.is_empty() { + return Value::Object(node); + } + let id = str_field(l, "do_objectID").unwrap_or("layer"); + let identity = json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + let abs = json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}); + // The group takes the layer's id (stacks, links and overrides find it), the layer a new one. + let mut inner = node; + let mut group = JsonMap::new(); + for k in ["relativeTransform", "absoluteBoundingBox", "constraints", "visible", "locked"] { + if let Some(v) = inner.get(k) { + group.insert(k.into(), v.clone()); + } + } + group.insert("type".into(), json!("GROUP")); + group.insert("id".into(), json!(id)); + group.insert("name".into(), inner.get("name").cloned().unwrap_or_else(|| json!("Fade"))); + group.insert("size".into(), json!({"x": w, "y": h})); + group.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("id".into(), json!(format!("{id}-faded"))); + inner.insert("relativeTransform".into(), identity.clone()); + inner.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("visible".into(), json!(true)); + let mask = json!({"type": "RECTANGLE", "id": format!("{id}-fade-mask"), "name": "Fade", "visible": true, "size": {"x": w, "y": h}, + "relativeTransform": identity, "absoluteBoundingBox": abs, "fills": fills, "isMask": true, "maskType": "ALPHA"}); + group.insert("children".into(), json!([mask, Value::Object(inner)])); + Value::Object(group) +} + +/// A Sketch 2025+ group that is a frame: it behaves as a frame or a graphic, or clips. +pub(crate) fn is_frame(l: &Value) -> bool { + matches!(l.get("groupBehavior").and_then(Value::as_i64), Some(1 | 2)) || l.get("clippingBehavior").and_then(Value::as_i64) == Some(1) +} + +/// Corners kept in the style (Sketch 2025+: one radius for all, or one per corner from the top +/// left, clockwise) on shapes and frames that have corners; smooth corners keep their smoothing. +pub(crate) fn style_corners(l: &Value, node: &mut JsonMap) { + let Some(c) = l.get("style").and_then(|s| s.get("corners")) else { return }; + if !matches!(node.get("type").and_then(Value::as_str), Some("RECTANGLE" | "FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let radii: Vec = c + .get("radii") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|r| r.is_finite()) + .map(|r| r.clamp(0.0, MAX_SIZE)) + .take(4) + .collect(); + match radii.as_slice() { + [] => return, + [r] => { + node.insert("cornerRadius".into(), json!(r)); + node.remove("rectangleCornerRadii"); + } + more => { + // Values repeat to fill the four corners. + let all: Vec = (0..4).map(|i| more.get(i % more.len()).copied().unwrap_or(0.0)).collect(); + node.insert("rectangleCornerRadii".into(), json!(all)); + } + } + // Style 1 is smooth: its smoothing applies. + if c.get("style").and_then(Value::as_i64) == Some(1) + && let Some(sm) = c.get("smoothing").and_then(Value::as_f64).filter(|v| v.is_finite()) + { + node.insert("cornerSmoothing".into(), json!(sm.clamp(0.0, 1.0))); + } +} + +/// Layer `l`'s style into `node`, and its links to the styles made from its shared style. +pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { + // An artboard shows its background colour only; Sketch doesn't draw a style it carries. + if str_field(l, "_class") == Some("artboard") { + return; + } + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, node); + } + cx.shared.link(&cx.style_defs, l, node); +} + +/// Layer `l`'s prototype link, export settings, layout grids (frames) and notes. +pub(crate) fn extras(cx: &mut Cx, l: &Value, h: f64, node: &mut JsonMap) { + super::proto::interactions(l, node); + super::proto::export_settings(cx, l, node); + if matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + super::proto::layout_grids(l, h, node); + } + super::proto::notes(cx, l); +} + +/// A clipping mask's flag and kind (outline or alpha). +pub(crate) fn mask(l: &Value, node: &mut JsonMap) { + if bool_field(l, "hasClippingMask") == Some(true) { + node.insert("isMask".into(), json!(true)); + node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); + } +} + +/// Count one more layer; false (with a warning) once the file's budget is spent. +fn count(cx: &mut Cx) -> bool { + if cx.nodes >= cx.max_nodes { + cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + return false; + } + cx.nodes += 1; + true +} + +/// The properties every REST node of layer `l` has: its type, id, name, visibility, lock, size, +/// place (`world`, relative to `parent`) and constraints. +pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h: f64, root: bool) -> JsonMap { + let mut node = JsonMap::new(); + node.insert("type".into(), json!(ty)); + if let Some(id) = str_field(l, "do_objectID") { + node.insert("id".into(), json!(id)); + } + let class = str_field(l, "_class").unwrap_or("Layer"); + node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); + node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); + node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); + node.insert("size".into(), json!({"x": w, "y": h})); + let [a, b, c, d, e, f] = (parent.inverse() * world).as_coeffs(); + node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); + // The page places top-level layers by their transform; deeper ones also say where they end up, + // which tells the importer their transform is relative to their group. + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + node.insert("constraints".into(), constraints(pin_mask(l))); + node +} + +/// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]). +pub(crate) fn size(l: &Value) -> (f64, f64) { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE); + (n("width"), n("height")) +} + +/// A layer's transform within its parent: its frame's position, then its rotation (degrees, +/// counter-clockwise on screen) and flips about its centre. 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); + let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0; + let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 }; + let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 }; + Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0)) + * Affine::scale_non_uniform(sx, sy) + * Affine::rotate(-rot.to_radians()) + * Affine::translate((-w / 2.0, -h / 2.0)) +} + +/// A layer's pins as a `resizingConstraint` mask. Sketch 2025 and newer keep them in +/// `horizontalPins`/`verticalPins` (bits: 1 the start, 4 the end) and `horizontalSizing`/ +/// `verticalSizing` (fixed and fit keep the size; fill and relative don't), with +/// `resizingConstraint` left at 63. +pub(crate) fn pin_mask(l: &Value) -> i64 { + if l.get("horizontalPins").is_none() && l.get("horizontalSizing").is_none() { + return l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63); + } + let int = |k: &str| l.get(k).and_then(Value::as_i64).unwrap_or(0); + let fixed = |k: &str| matches!(int(k), 0 | 1); + let mut mask = 63; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + for (on, bit) in [ + (int("horizontalPins") & 1 != 0, 4), + (int("horizontalPins") & 4 != 0, 1), + (fixed("horizontalSizing"), 2), + (int("verticalPins") & 1 != 0, 32), + (int("verticalPins") & 4 != 0, 8), + (fixed("verticalSizing"), 16), + ] { + if on { + mask &= !bit; + } + } + mask +} + +/// REST constraints from Sketch's `resizingConstraint`: six bits, a cleared bit pins that side or +/// fixes that size (right 1, width 2, left 4, bottom 8, height 16, top 32). A layer pinned on +/// neither side keeps its place in proportion (scale) unless its size is fixed (centre). +fn constraints(mask: i64) -> Value { + let pinned = |bit: i64| mask & bit == 0; + let axis = |low: i64, size: i64, high: i64, low_name: &str, high_name: &str, both: &str| match (pinned(low), pinned(high)) { + // Both sides and the size pinned can't all hold: Sketch scales such a layer. + (true, true) if pinned(size) => "SCALE".into(), + (true, true) => both.to_string(), + (true, false) => low_name.to_string(), + (false, true) => high_name.to_string(), + (false, false) if pinned(size) => "CENTER".into(), + (false, false) => "SCALE".into(), + }; + json!({ + "horizontal": axis(4, 2, 1, "LEFT", "RIGHT", "LEFT_RIGHT"), + "vertical": axis(32, 16, 8, "TOP", "BOTTOM", "TOP_BOTTOM"), + }) +} + +/// A rectangle's corner radii: per corner from its points (top left, top right, bottom right, +/// bottom left), else its `fixedRadius`. +fn corner_radii(l: &Value, node: &mut JsonMap) { + let radius = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, MAX_SIZE)); + let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let each: Vec = pts.iter().map(|p| radius(p.get("cornerRadius"))).collect(); + if each.len() == 4 && each.iter().any(|r| *r > 0.0) { + node.insert("rectangleCornerRadii".into(), json!(each)); + } else if radius(l.get("fixedRadius")) > 0.0 { + node.insert("cornerRadius".into(), json!(radius(l.get("fixedRadius")))); + } +} + +/// A Sketch colour (`red`, `green`, `blue`, `alpha` in 0–1) as a REST colour. +pub(crate) fn color(c: &Value) -> Option { + let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); + Some(json!({"r": n("red")?, "g": n("green")?, "b": n("blue")?, "a": n("alpha").unwrap_or(1.0)})) +} + +pub(crate) fn bool_field(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_bool) +} + +/// At most 64 characters of a name read from the file, for warnings. +fn clip(s: &str) -> String { + s.chars().take(64).collect() +} + +/// The child layers of `l` (placed at `world`, `size`) as REST nodes. A clipping mask clips the +/// layers after it up to one that breaks the chain (or the next mask); when that happens before +/// the end, the mask and what it clips are wrapped in a group of their own, since a REST mask +/// clips everything above it. +pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), depth: usize, root: bool) -> Vec { + // (ends a mask chain, is a drawn copy of the mask after it, the node) + let mut built: Vec<(bool, bool, Value)> = Vec::new(); + for c in layers(l) { + if let Some(n) = layer(cx, c, world, depth + 1, root) { + built_push(&mut built, c, n); + } + } + mask_groups(built, world, size, root) +} + +/// `n` (and before it, a drawn copy of it when it is a mask with a style) onto `built`. +#[inline(never)] +fn built_push(built: &mut Vec<(bool, bool, Value)>, c: &Value, n: Value) { + let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true); + // Sketch also draws a mask with its own style, under what it clips; a REST mask only clips, + // so a plain copy of it goes underneath. The copy ends an earlier mask's chain as the mask + // itself would. + if let Some(copy) = drawn_mask(&n) { + built.push((breaks, true, copy)); + } + built.push((breaks, false, n)); +} + +/// The children `built`, each mask and what it clips wrapped in a group where the chain ends +/// before the last. +#[inline(never)] +fn mask_groups(built: Vec<(bool, bool, Value)>, world: Affine, size: (f64, f64), root: bool) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + 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).or_else(|| fade.map(|_| built.len())) + } else { + None + }; + let Some(end) = end else { + out.push(n.clone()); + i += 1; + continue; + }; + // 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")); + g.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])); + g.insert("size".into(), json!({"x": size.0, "y": size.1})); + if !root { + 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; + } + out +} + +/// A plain copy of outline mask `n` when its style shows something (fills, borders or effects). +/// An alpha mask only masks. +fn drawn_mask(n: &Value) -> Option { + if n.get("isMask").and_then(Value::as_bool) != Some(true) || n.get("maskType").and_then(Value::as_str) == Some("ALPHA") { + return None; + } + let shows = + |key: &str| n.get(key).and_then(Value::as_array).is_some_and(|l| l.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); + if !(shows("fills") || shows("strokes") || shows("effects")) { + return None; + } + let mut copy = n.clone(); + let m = copy.as_object_mut()?; + m.remove("isMask"); + m.remove("maskType"); + m.remove("id"); + let name = m.get("name").and_then(Value::as_str).unwrap_or("Mask").to_string(); + m.insert("name".into(), json!(format!("{name} (drawn)"))); + Some(copy) +} + +/// A text layer whose string is a binary property list (Sketch 43–47). +fn is_archived_text(l: &Value) -> bool { + l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some()) +} + +/// The child layers of a page or layer. +pub(crate) fn layers(l: &Value) -> &[Value] { + l.get("layers").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() +} + +pub(crate) fn str_field<'v>(v: &'v Value, key: &str) -> Option<&'v str> { + v.get(key).and_then(Value::as_str) +}