mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 21:02:50 +00:00
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:
1 parent
72c7eae868
commit
d77d57912b
6 files changed
+585
-5
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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>`).
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user