mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-sketch: map the layers onto the REST file JSON (#1012)
* Add craft-figma-rest: a typed model of the Figma REST file JSON
The neutral model for design files: the .fig and .sketch readers map onto
it and an application's importer takes it. Typed records for the document
tree, paints, effects, text styles, layout grids, constraints, export
settings and the component and style metadata; every record keeps the
fields it has no name for, so a file read and written loses nothing.
The vectorNetwork extension is typed and documented with the others.
The layer tree is converted level by level in both directions, and the
depth and node limits are checked on the JSON before any typed value is
built.
* craft-figma-rest: Warnings and style_name for the readers that build this shape
Moved from FigCraft's REST importer and .fig reader (crates/format/src/rest.rs
Warnings, fig.rs style_name), which the .fig and Sketch mappings share.
* craft-sketch: map the layers onto the REST file JSON
FigCraft's Sketch mapping (crates/format/src/sketch/{tree,style,text,
symbols,path,shared,stack,proto}.rs at e8b53e33) moved onto
craft-figma-rest: rest::to_rest turns an Archive's pages and layers into
the REST API file JSON, with the vector networks of its paths handed over
beside it as craft_figma_rest::ext::VectorNetwork.
The star and polygon point generators it compares a path with are copied
from FigCraft's geometry; comments and two warnings no longer name the
app.
* craft-sketch: reach the nesting limit with nested groups, not 210 masters
The same limit and warning, in 1 second instead of 58 in a debug build.
* craft-sketch: a nesting limit that fits a 1 MiB stack, in small frames
The mapping recurses once per level of layer nesting, symbol expansion
included. Nested symbol instances reach its limit with shallow page JSON,
and a debug build overflowed a 2 MiB stack at about 140 levels: a crash
on hostile input.
The work on one layer, master or instance now happens in functions that
return before its children are mapped, which takes the stack per level
from about 4.5 KB to about 2 KB for an instance and its group in a debug
build. That maps about 140 levels of nested instances on 512 KiB, so the
limit is now 128 (was 200), leaving twice the room on the 1 MiB stack of
a WebAssembly build. The test at the limit runs on a 1 MiB thread.
* Cargo.lock: craft-sketch's craft-figma-rest and kurbo after the merge with main
This commit is contained in:
1 parent
227cfee7e0
commit
dc14024a19
15 files changed
+3566
-3
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+39
-3
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<dyn Fn(&str) -> 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<FontLookup>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// The vector networks layers name by `vectorNetworkRef`.
|
||||
pub networks: Vec<Option<VectorNetwork>>,
|
||||
/// 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 }
|
||||
}
|
||||
@@ -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/<id>.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<u8> {
|
||||
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<u8> {
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
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>) -> 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>) -> 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>) -> 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<Value> = 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>) -> 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.<key>` 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<Value> = 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>) -> 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>) -> 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
|
||||
}
|
||||
@@ -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::<f64>().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<VectorNetwork> {
|
||||
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<Segment> = (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<Value> {
|
||||
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<f64> = 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<Shape> {
|
||||
// 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<Point>| {
|
||||
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<String, Value>) {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
// Runs: a child with an operation starts one, children without (−1) join it.
|
||||
let mut runs: Vec<Vec<&Value>> = 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<Value> = Vec::new();
|
||||
let mut op = "UNION";
|
||||
for (i, run) in runs.iter().enumerate() {
|
||||
let operands: Vec<Value> = 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<VectorNetwork> {
|
||||
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::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
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>) -> 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<Point> {
|
||||
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<Point> {
|
||||
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()
|
||||
}
|
||||
@@ -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<String, Value>) {
|
||||
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<Value> {
|
||||
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<String, Value>) {
|
||||
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<String, Value>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<String, usize>,
|
||||
text_index: BTreeMap<String, usize>,
|
||||
swatches: BTreeMap<String, (String, Value)>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub(crate) fn read(document: &Value) -> Shared {
|
||||
let mut s = Shared::default();
|
||||
let objects =
|
||||
|key: &str| -> Vec<Value> { 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<Value> {
|
||||
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<Value> {
|
||||
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<String>, l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
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<String, Value> = 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)))
|
||||
}
|
||||
}
|
||||
@@ -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<String, Value>) {
|
||||
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<String, Value>, 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String, Value>) {
|
||||
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<Value> {
|
||||
// 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<Value> {
|
||||
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<String, Value>) {
|
||||
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<Value> {
|
||||
let mut p = match f.get("fillType").and_then(Value::as_i64).unwrap_or(0) {
|
||||
0 => json!({"type": "SOLID", "color": f.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}))}),
|
||||
1 => {
|
||||
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<Value> = 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<String, Value>) {
|
||||
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<f64> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<f64> {
|
||||
v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// Gradient stops mixed in OKLab or OKLCH, as sRGB stops close enough to follow the curve.
|
||||
mod oklab {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Stops added between each pair.
|
||||
const STEPS: usize = 16;
|
||||
/// Most stops given back.
|
||||
const MAX_STOPS: usize = 1024;
|
||||
|
||||
type Lab = [f64; 3];
|
||||
|
||||
fn lin(v: f64) -> f64 {
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
fn enc(v: f64) -> f64 {
|
||||
let v = v.max(0.0);
|
||||
if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
fn to_lab([r, g, b]: [f64; 3]) -> Lab {
|
||||
let (r, g, b) = (lin(r), lin(g), lin(b));
|
||||
let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt();
|
||||
let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt();
|
||||
let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt();
|
||||
[
|
||||
0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s,
|
||||
1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s,
|
||||
0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s,
|
||||
]
|
||||
}
|
||||
|
||||
fn from_lab([l, a, b]: Lab) -> [f64; 3] {
|
||||
let l_ = (l + 0.396_337_777_4 * a + 0.215_803_757_3 * b).powi(3);
|
||||
let m_ = (l - 0.105_561_345_8 * a - 0.063_854_172_8 * b).powi(3);
|
||||
let s_ = (l - 0.089_484_177_5 * a - 1.291_485_548_0 * b).powi(3);
|
||||
[
|
||||
enc(4.076_741_662_1 * l_ - 3.307_711_591_3 * m_ + 0.230_969_929_2 * s_),
|
||||
enc(-1.268_438_004_6 * l_ + 2.609_757_401_1 * m_ - 0.341_319_396_5 * s_),
|
||||
enc(-0.004_196_086_3 * l_ - 0.703_418_614_7 * m_ + 1.707_614_701_0 * s_),
|
||||
]
|
||||
.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// The colour `t` of the way from `a` to `b`; in OKLCH the hue turns the shorter way (a grey
|
||||
/// end takes the other's hue).
|
||||
fn mix(a: Lab, b: Lab, t: f64, lch: bool) -> Lab {
|
||||
let lerp = |x: f64, y: f64| x + (y - x) * t;
|
||||
if !lch {
|
||||
return [lerp(a[0], b[0]), lerp(a[1], b[1]), lerp(a[2], b[2])];
|
||||
}
|
||||
let (ca, cb) = (a[1].hypot(a[2]), b[1].hypot(b[2]));
|
||||
let (mut ha, mut hb) = (a[2].atan2(a[1]), b[2].atan2(b[1]));
|
||||
if ca < 1e-4 {
|
||||
ha = hb;
|
||||
}
|
||||
if cb < 1e-4 {
|
||||
hb = ha;
|
||||
}
|
||||
let mut dh = hb - ha;
|
||||
if dh > std::f64::consts::PI {
|
||||
dh -= std::f64::consts::TAU;
|
||||
} else if dh < -std::f64::consts::PI {
|
||||
dh += std::f64::consts::TAU;
|
||||
}
|
||||
let (c, h) = (lerp(ca, cb), ha + dh * t);
|
||||
[lerp(a[0], b[0]), c * h.cos(), c * h.sin()]
|
||||
}
|
||||
|
||||
fn rgba(stop: &Value) -> Option<(f64, [f64; 3], f64)> {
|
||||
let c = stop.get("color")?;
|
||||
let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
Some((stop.get("position").and_then(Value::as_f64)?, [n("r")?, n("g")?, n("b")?], n("a").unwrap_or(1.0)))
|
||||
}
|
||||
|
||||
pub(super) fn resample(stops: &[Value], lch: bool) -> Vec<Value> {
|
||||
let mut parsed: Vec<(f64, [f64; 3], f64)> = stops.iter().filter_map(rgba).collect();
|
||||
if parsed.len() != stops.len() || parsed.len() < 2 || parsed.len() * STEPS > MAX_STOPS {
|
||||
return stops.to_vec();
|
||||
}
|
||||
parsed.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let stop = |pos: f64, [r, g, b]: [f64; 3], a: f64| json!({"position": pos, "color": {"r": r, "g": g, "b": b, "a": a}});
|
||||
let mut out = Vec::new();
|
||||
for pair in parsed.windows(2) {
|
||||
let [(p0, c0, a0), (p1, c1, a1)] = pair else { continue };
|
||||
let (l0, l1) = (to_lab(*c0), to_lab(*c1));
|
||||
for k in 0..STEPS {
|
||||
let t = k as f64 / STEPS as f64;
|
||||
out.push(stop(p0 + (p1 - p0) * t, from_lab(mix(l0, l1, t, lch)), a0 + (a1 - a0) * t));
|
||||
}
|
||||
}
|
||||
if let Some((p, c, a)) = parsed.last() {
|
||||
out.push(stop(*p, *c, *a));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -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>_<property>`: `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<String, Value>,
|
||||
/// What expanding each master costs (see [`weight`]).
|
||||
weights: BTreeMap<String, u64>,
|
||||
/// Masters copied from libraries that no page holds (they get a page of their own), with the
|
||||
/// libraries' names.
|
||||
pub foreign: Vec<Value>,
|
||||
pub libraries: Vec<String>,
|
||||
}
|
||||
|
||||
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::<u64>(),
|
||||
Value::Object(o) => 1 + o.iter().map(|(k, x)| k.len() as u64 / 8 + weight(x, depth + 1)).sum::<u64>(),
|
||||
_ => 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<Value> {
|
||||
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<String, Value>) {
|
||||
// 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<Value> {
|
||||
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<String, Value>, 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<String, Value>, l: &Value, kids: Vec<Value>) -> 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<Value> {
|
||||
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<Prepared<'v>, Option<Value>> {
|
||||
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<String, Value>, mut copy: Value, kids: Vec<Value>) -> 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 (`<path>_<property>` → value).
|
||||
fn overrides(l: &Value) -> BTreeMap<String, Value> {
|
||||
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<String, Value>) -> 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<String, Value>,
|
||||
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 (`<instance>;<layer>`): 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)
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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::<Vec<_>>(), [(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<Value> = (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<Value> = (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<Value> = (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<Value> = (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<Value> = (0..100).map(|i| rect(&format!("L{i}"), 0.0, 0.0, 1.0, 1.0)).collect();
|
||||
let insts: Vec<Value> = (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<String>, out: &mut Vec<Vec<String>>) {
|
||||
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<Value> = 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::<usize>().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));
|
||||
}
|
||||
@@ -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<String, Value>) {
|
||||
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<Value> {
|
||||
let axes: JsonMap<String, Value> = v
|
||||
.as_object()?
|
||||
.iter()
|
||||
.take(64)
|
||||
.filter_map(|(k, val)| {
|
||||
let tag = u32::try_from(k.parse::<u64>().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<char> = 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
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Vector networks the JSON references by `vectorNetworkRef`.
|
||||
pub networks: Vec<Option<VectorNetwork>>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<bool>,
|
||||
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<String>,
|
||||
pub warnings: Vec<String>,
|
||||
pub networks: Vec<Option<VectorNetwork>>,
|
||||
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<super::FontLookup>,
|
||||
pub font_cache: std::collections::BTreeMap<String, Option<(String, String)>>,
|
||||
}
|
||||
|
||||
impl Cx {
|
||||
pub(crate) fn warn(&mut self, msg: impl Into<String>) {
|
||||
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<super::FontLookup>) -> 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<super::FontLookup>, 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<Value> {
|
||||
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<Value>),
|
||||
/// 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<Mapped<'v>>),
|
||||
}
|
||||
|
||||
/// A layer mapped but for its children and its fade.
|
||||
struct Mapped<'v> {
|
||||
l: Cow<'v, Value>,
|
||||
node: JsonMap<String, Value>,
|
||||
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<Vec<Value>>) -> 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<String, Value>, 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<String, Value>) {
|
||||
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<f64> = 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<f64> = (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<String, Value>) {
|
||||
// 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<String, Value>) {
|
||||
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<String, Value>) {
|
||||
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<String, Value> {
|
||||
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<String, Value>) {
|
||||
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<f64> = 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<Value> {
|
||||
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<bool> {
|
||||
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<Value> {
|
||||
// (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<Value> {
|
||||
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<Value> = built
|
||||
.get(start..end)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|(_, copy, n)| {
|
||||
let mut n = n.clone();
|
||||
if *copy
|
||||
&& fade.is_some()
|
||||
&& let Some(m) = n.as_object_mut()
|
||||
{
|
||||
m.remove("opacity");
|
||||
}
|
||||
n
|
||||
})
|
||||
.collect();
|
||||
let mut g = JsonMap::new();
|
||||
g.insert("type".into(), json!("GROUP"));
|
||||
g.insert("name".into(), json!("Mask group"));
|
||||
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<Value> {
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user