M6: Sketch import: fills, borders, effects, opacity, masks

Solid, gradient (linear, radial with its ellipse, angular) and image
pattern fills (tile, fill, stretch, fit); borders as strokes with the
first enabled border's width and position, dashes, caps, joins and arrow
ends; shadows, inner shadows, layer and background blurs; layer and paint
opacity and blend modes; clipping masks, wrapped in a group of their own
where Sketch breaks the mask chain; Display P3 documents. Shape groups
pass their style to the shape or boolean operation they become. Noise
fills, motion and zoom blurs are listed in the import report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jindřich ŠaršonandClaude Opus 5.5 committed 2026-10-22 00:27:20 +02:00
1 parent 72c7eae868
commit d77d57912b
6 files changed
+585 -5

No files matched your search

+17
View File
@@ -132,3 +132,20 @@ pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f6
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
}
+10 -3
View File
@@ -16,6 +16,9 @@ mod fixture;
mod path;
#[cfg(test)]
mod path_tests;
mod style;
#[cfg(test)]
mod style_tests;
#[cfg(test)]
mod tests;
mod tree;
@@ -79,7 +82,10 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result
let mut warnings = archive.warnings.clone();
warnings.extend(map.warnings);
let images = |r: &str| archive.image(r);
let (doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?;
let (mut doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?;
if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) {
doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3;
}
warnings.extend(more);
Ok((doc, warnings))
}
@@ -88,8 +94,9 @@ fn corrupt(msg: impl Into<String>) -> FormatError {
FormatError::Corrupt(format!("Sketch file: {}", msg.into()))
}
/// The parts of a `.sketch` archive: the pages in document order and the images.
/// The parts of a `.sketch` archive: the document, the pages in document order and the images.
pub(crate) struct Archive<'b> {
pub document: Value,
/// `(id, page JSON)` in the document's order.
pub pages: Vec<(String, Value)>,
/// Problems met while reading (pages skipped).
@@ -132,7 +139,7 @@ impl<'b> Archive<'b> {
if refs.len() > MAX_PAGES {
warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped"));
}
Ok(Archive { pages, warnings, zip: z })
Ok(Archive { document, pages, warnings, zip: z })
}
/// The bytes of the image a layer references (`images/<name>`).
+7
View File
@@ -139,6 +139,10 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize,
}
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 let Some(st) = l.get("style") {
super::style::clear(m);
super::style::apply(cx, st, cw, ch, m);
}
}
return Some(node);
}
@@ -162,6 +166,9 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize,
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));
if let Some(st) = l.get("style") {
super::style::apply(cx, st, w, h, &mut node);
}
Some(Value::Object(node))
}
+265
View File
@@ -0,0 +1,265 @@
//! 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; FigCraft 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: Vec<Value> = list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect();
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));
}
}
}
/// 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 FigCraft 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 => gradient(f.get("gradient")?, w, h)?,
4 => {
let Some(r) = f.get("image").and_then(|i| i.get("_ref")).and_then(Value::as_str) else {
cx.warn("image fills stored inside the JSON were skipped");
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));
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.
fn gradient(g: &Value, w: f64, h: f64) -> Option<Value> {
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();
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)]))
}
2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((0.5, 0.0))])),
_ => ("GRADIENT_LINEAR", json!([pt(from), pt(to)])),
};
Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops}))
}
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: Vec<Value> = list.iter().filter_map(|b| paint(cx, b, w, h)).collect();
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));
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.
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) {
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,
}));
}
}
if let Some(b) = style.get("blur").filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) {
let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6);
match b.get("type").and_then(Value::as_i64).unwrap_or(0) {
0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})),
3 => out.push(json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius})),
_ => cx.warn("motion and zoom blurs were skipped"),
}
}
out.truncate(MAX_LIST);
out
}
/// 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())
}
+235
View File
@@ -0,0 +1,235 @@
//! Sketch styles → paints, strokes, effects, opacity, blend modes and masks.
use figcraft_doc::color::{BlendMode, ColorProfile, GradientKind, ImageScaleMode, PaintKind};
use figcraft_doc::{Document, EffectKind, NodeKind, StrokeAlign, StrokeCap, StrokeJoin};
use serde_json::{Value, json};
use super::fixture::*;
use super::import;
fn open_doc(document_json: Value, layers: Vec<Value>, files: &[(&str, &[u8])]) -> (Document, Vec<String>) {
let z = sketch_zip("50.2", 103, document_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], files);
import(&z, "f").unwrap()
}
fn open(layers: Vec<Value>) -> (Document, Vec<String>) {
open_doc(document(&["P"]), layers, &[])
}
fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node {
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}"))
}
fn r(name: &str) -> Value {
rect(name, 0.0, 0.0, 100.0, 50.0)
}
#[test]
fn solid_and_disabled_fills() {
let mut second = solid_fill(0.0, 0.0, 1.0, 1.0);
second["isEnabled"] = json!(false);
let mut half = solid_fill(1.0, 0.0, 0.0, 1.0);
half["contextSettings"]["opacity"] = json!(0.5);
let (doc, _) = open(vec![styled(r("Two"), "fills", json!([half, second]))]);
let fills = &node(&doc, "Two").fills;
assert_eq!(fills.len(), 2);
assert!(matches!(fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.b == 0.0));
assert!((fills[0].opacity - 0.5).abs() < 1e-6 && fills[0].visible);
assert!(!fills[1].visible);
}
#[test]
fn collapsed_shape_group_takes_the_groups_style() {
let inner = with(r("Inner"), "booleanOperation", json!(-1));
let g = styled(shape_group("Button", 0.0, 0.0, 100.0, 50.0, vec![inner]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)]));
let (doc, _) = open(vec![g]);
assert!(matches!(node(&doc, "Button").fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0));
}
#[test]
fn boolean_shape_group_takes_the_groups_style() {
let a = with(rect("A", 0.0, 0.0, 50.0, 50.0), "booleanOperation", json!(-1));
let b = with(rect("B", 10.0, 10.0, 20.0, 20.0), "booleanOperation", json!(1));
let g = styled(shape_group("Ring", 0.0, 0.0, 50.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 1.0, 0.0, 1.0)]));
let (doc, _) = open(vec![g]);
assert_eq!(node(&doc, "Ring").fills.len(), 1);
}
#[test]
fn gradients() {
let stops = json!([{"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)},
{"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]);
let grad = |ty: i64, ellipse: f64| {
json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0),
"gradient": {"_class": "gradient", "gradientType": ty, "from": "{0.5, 0}", "to": "{0.5, 1}", "elipseLength": ellipse, "stops": stops.clone()}})
};
let (doc, _) = open(vec![
styled(r("Linear"), "fills", json!([grad(0, 0.0)])),
styled(r("Radial"), "fills", json!([grad(1, 0.5)])),
styled(r("Angular"), "fills", json!([grad(2, 0.0)])),
]);
let g = |name: &str| match &node(&doc, name).fills[0].kind {
PaintKind::Gradient(g) => g.clone(),
other => panic!("{name}: {other:?}"),
};
let lin = g("Linear");
assert_eq!(lin.kind, GradientKind::Linear);
assert_eq!((lin.from, lin.to), ((0.5, 0.0), (0.5, 1.0)));
assert_eq!(lin.stops.len(), 2);
assert_eq!(lin.stops[1].color.b, 1.0);
assert_eq!(g("Radial").kind, GradientKind::Radial);
assert_eq!(g("Angular").kind, GradientKind::Angular);
// The radial gradient's width handle: half the length, across the main axis (in pixels).
let rad = g("Radial");
let (dx, dy) = ((rad.width.0 - rad.from.0) * 100.0, (rad.width.1 - rad.from.1) * 50.0);
assert!(((dx * dx + dy * dy).sqrt() - 25.0).abs() < 1e-6, "{rad:?}");
}
#[test]
fn pattern_fill_modes() {
let png = {
let mut out = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
};
let pat = |mode: i64| {
json!({"_class": "fill", "isEnabled": true, "fillType": 4, "patternFillType": mode, "patternTileScale": 1,
"image": {"_class": "MSJSONFileReference", "_ref": "images/p1.png"}})
};
let layers = (0..4).map(|m| styled(r(&format!("M{m}")), "fills", json!([pat(m)]))).collect();
let (doc, _) = open_doc(document(&["P"]), layers, &[("images/p1.png", &png)]);
let mode = |name: &str| match &node(&doc, name).fills[0].kind {
PaintKind::Image(i) => i.scale_mode,
other => panic!("{other:?}"),
};
assert_eq!(mode("M0"), ImageScaleMode::Tile);
assert_eq!(mode("M1"), ImageScaleMode::Fill);
assert_eq!(mode("M2"), ImageScaleMode::Crop);
assert_eq!(mode("M3"), ImageScaleMode::Fit);
assert_eq!(doc.images.len(), 1);
}
#[test]
fn noise_fill_warns() {
let noise = json!({"_class": "fill", "isEnabled": true, "fillType": 5, "noiseIntensity": 0.3});
let (_, warnings) = open(vec![styled(r("Noisy"), "fills", json!([noise]))]);
assert!(warnings.iter().any(|w| w.contains("noise")), "{warnings:?}");
}
#[test]
fn borders() {
let border = |pos: i64, t: f64| json!({"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": pos, "thickness": t});
let opts = json!({"_class": "borderOptions", "isEnabled": true, "dashPattern": [4, 2], "lineCapStyle": 1, "lineJoinStyle": 2});
let (doc, warnings) = open(vec![
styled(r("Center"), "borders", json!([border(0, 2.0)])),
styled(styled(r("Inside"), "borders", json!([border(1, 3.0)])), "borderOptions", opts),
styled(r("Outside"), "borders", json!([border(2, 1.0)])),
styled(r("Two"), "borders", json!([border(1, 1.0), border(1, 5.0)])),
]);
let s = |n: &str| node(&doc, n).stroke.clone();
assert_eq!((s("Center").align, s("Center").weight), (StrokeAlign::Center, 2.0));
assert_eq!((s("Inside").align, s("Inside").weight), (StrokeAlign::Inside, 3.0));
assert_eq!(s("Inside").dashes, vec![4.0, 2.0]);
assert_eq!((s("Inside").cap_start, s("Inside").join), (StrokeCap::Round, StrokeJoin::Bevel));
assert_eq!(s("Outside").align, StrokeAlign::Outside);
assert_eq!(node(&doc, "Two").strokes.len(), 2);
assert_eq!(s("Two").weight, 1.0);
assert!(warnings.iter().any(|w| w.contains("borders")), "{warnings:?}");
}
#[test]
fn shadows_and_blurs() {
let shadow = json!({"_class": "shadow", "isEnabled": true, "blurRadius": 8, "offsetX": 2, "offsetY": 4, "spread": 1, "color": color(0.0, 0.0, 0.0, 0.5),
"contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}});
let mut off = shadow.clone();
off["isEnabled"] = json!(false);
let blur = |ty: i64| json!({"_class": "blur", "isEnabled": true, "radius": 6, "type": ty});
let (doc, warnings) = open(vec![
styled(styled(r("Shadowed"), "shadows", json!([shadow, off])), "innerShadows", json!([shadow])),
styled(r("Blurred"), "blur", blur(0)),
styled(r("Frosted"), "blur", blur(3)),
styled(r("Moving"), "blur", blur(1)),
]);
let fx = &node(&doc, "Shadowed").effects;
assert_eq!(fx.len(), 3);
match &fx[0].kind {
EffectKind::DropShadow(s) => assert_eq!((s.offset, s.radius, s.spread, s.color.a), ((2.0, 4.0), 8.0, 1.0, 0.5)),
other => panic!("{other:?}"),
}
assert!(!fx[1].visible);
assert!(matches!(fx[2].kind, EffectKind::InnerShadow(_)));
assert!(matches!(node(&doc, "Blurred").effects[0].kind, EffectKind::LayerBlur(_)));
assert!(matches!(node(&doc, "Frosted").effects[0].kind, EffectKind::BackgroundBlur(_)));
assert!(node(&doc, "Moving").effects.is_empty());
assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}");
}
#[test]
fn opacity_and_blend() {
let modes = [
BlendMode::Normal,
BlendMode::Darken,
BlendMode::Multiply,
BlendMode::ColorBurn,
BlendMode::Lighten,
BlendMode::Screen,
BlendMode::ColorDodge,
BlendMode::Overlay,
BlendMode::SoftLight,
BlendMode::HardLight,
BlendMode::Difference,
BlendMode::Exclusion,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Color,
BlendMode::Luminosity,
BlendMode::PlusDarker,
BlendMode::PlusLighter,
];
let layers = (0..modes.len())
.map(|i| styled(r(&format!("B{i}")), "contextSettings", json!({"_class": "graphicsContextSettings", "opacity": 0.3, "blendMode": i})))
.collect();
let (doc, _) = open(layers);
for (i, m) in modes.iter().enumerate() {
let n = node(&doc, &format!("B{i}"));
assert!((n.opacity - 0.3).abs() < 1e-6);
// A normal layer passes through like any other.
if i > 0 {
assert_eq!(n.blend, *m, "mode {i}");
}
}
}
#[test]
fn clipping_mask_chain() {
let mask = with(r("Mask"), "hasClippingMask", json!(true));
let a = r("A");
let b = with(r("B"), "shouldBreakMaskChain", json!(true));
let c = r("C");
let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![mask, a, b, c])]);
let m = node(&doc, "Mask");
assert!(m.is_mask);
// The mask and what it clips share a group of their own, so B and C stay unclipped.
let holder = m.parent.unwrap();
let kids: Vec<&str> = doc.node(holder).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
assert_eq!(kids, ["Mask", "A"]);
assert_ne!(node(&doc, "B").parent, Some(holder));
let bb = doc.world_bounds(node(&doc, "A").id).unwrap();
assert_eq!((bb.x0, bb.y0), (0.0, 0.0));
}
#[test]
fn display_p3_document() {
let mut d = document(&["P"]);
d["colorSpace"] = json!(2);
let (doc, _) = open_doc(d, vec![], &[]);
assert_eq!(doc.color_profile, ColorProfile::DisplayP3);
}
#[test]
fn style_on_frames_and_text_kinds_untouched() {
// An artboard keeps its background when its style has no fills.
let (doc, _) = open(vec![]);
assert!(matches!(node(&doc, "Board").kind, NodeKind::Frame));
assert_eq!(node(&doc, "Board").fills.len(), 1);
}
+51 -2
View File
@@ -50,7 +50,7 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map {
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: Vec<Value> = layers(page).iter().filter_map(|l| layer(&mut cx, l, Affine::IDENTITY, 0, true)).collect();
let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true);
node.insert("children".into(), Value::Array(kids));
canvases.push(Value::Object(node));
}
@@ -95,6 +95,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
let shape = super::path::shape(cx, l, w, h)?;
super::path::put_shape(cx, shape, &mut node);
}
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" }));
}
match class {
"artboard" | "frame" => {
let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) };
@@ -115,8 +119,11 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
},
_ => {}
}
if let Some(st) = l.get("style") {
super::style::apply(cx, st, w, h, &mut node);
}
if matches!(ty, "FRAME" | "GROUP") {
let kids: Vec<Value> = layers(l).iter().filter_map(|c| layer(cx, c, world, depth + 1, false)).collect();
let kids = children(cx, l, world, (w, h), depth, false);
node.insert("children".into(), Value::Array(kids));
}
Some(Value::Object(node))
@@ -224,6 +231,48 @@ 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 FigCraft mask
/// clips everything above it.
pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), depth: usize, root: bool) -> Vec<Value> {
let built: Vec<(bool, Value)> = layers(l)
.iter()
.filter_map(|c| {
let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true);
layer(cx, c, world, depth + 1, root).map(|n| (breaks, n))
})
.collect();
let mut out = Vec::new();
let mut i = 0;
while let Some((_, n)) = built.get(i) {
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)
} else {
None
};
let Some(end) = end else {
out.push(n.clone());
i += 1;
continue;
};
let masked: Vec<Value> = built.get(i..end).unwrap_or_default().iter().map(|(_, n)| n.clone()).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()}));
}
g.insert("children".into(), Value::Array(masked));
out.push(Value::Object(g));
i = end;
}
out
}
/// 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())