From 4be7017ed1d746707d6a8a64aa36e1e68e3304e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:42:22 +0200 Subject: [PATCH] M6: Sketch import: shared styles and colour variables Shared layer styles become a paint style for their fills, a paint style for their border colours (linked to strokes) and an effect style; shared text styles become text styles (colour stays on the layers); local and library copies alike, unused ones listed too. Layers link to them, keeping their own values where they differ. Color Variables (swatches, also library ones) become colour variables in a Color Variables collection, with fills, borders and text colours bound to them. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 9 +- crates/format/src/sketch/shared.rs | 134 ++++++++++++++++++++ crates/format/src/sketch/shared_tests.rs | 150 +++++++++++++++++++++++ crates/format/src/sketch/style.rs | 25 +++- crates/format/src/sketch/symbols.rs | 5 +- crates/format/src/sketch/text.rs | 8 +- crates/format/src/sketch/tree.rs | 26 +++- 8 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 crates/format/src/sketch/shared.rs create mode 100644 crates/format/src/sketch/shared_tests.rs diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index b9063a70..db0421f8 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,9 @@ mod fixture; mod path; #[cfg(test)] mod path_tests; +mod shared; +#[cfg(test)] +mod shared_tests; mod style; #[cfg(test)] mod style_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 6ebb1a75..0cce6e08 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -139,9 +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") { + if l.get("style").is_some() { super::style::clear(m); - super::style::apply(cx, st, cw, ch, m); + m.remove("styles"); + super::tree::styles(cx, l, cw, ch, m); } super::tree::mask(l, m); } @@ -168,9 +169,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, node.insert("booleanOperation".into(), json!(op)); node.insert("children".into(), Value::Array(chain)); super::tree::mask(l, &mut node); - if let Some(st) = l.get("style") { - super::style::apply(cx, st, w, h, &mut node); - } + super::tree::styles(cx, l, w, h, &mut node); Some(Value::Object(node)) } diff --git a/crates/format/src/sketch/shared.rs b/crates/format/src/sketch/shared.rs new file mode 100644 index 00000000..104a1931 --- /dev/null +++ b/crates/format/src/sketch/shared.rs @@ -0,0 +1,134 @@ +//! Sketch shared styles and swatches → REST style definitions and colour variables. +//! +//! A Sketch layer style holds fills, borders and effects together; FigCraft 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 FigCraft 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; + +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)>, + swatches: BTreeMap, +} + +impl Shared { + pub(crate) fn read(document: &Value) -> Shared { + let mut s = Shared::default(); + let objects = + |key: &str| -> Vec { document.get(key).and_then(|c| c.get("objects")).and_then(Value::as_array).cloned().unwrap_or_default() }; + let foreign = |key: &str, inner: &str| -> Vec { + document.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|f| f.get(inner).cloned()).collect() + }; + let entry = |o: &Value| { + Some(( + str_field(o, "do_objectID")?.to_string(), + str_field(o, "name").unwrap_or("Style").chars().take(200).collect(), + o.get("value")?.clone(), + )) + }; + for o in objects("layerStyles").iter().chain(&foreign("foreignLayerStyles", "localSharedStyle")).take(MAX_SHARED) { + s.layer.extend(entry(o)); + } + for o in objects("layerTextStyles").iter().chain(&foreign("foreignTextStyles", "localSharedStyle")).take(MAX_SHARED) { + s.text.extend(entry(o)); + } + 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 + } + + pub(crate) fn has_swatch(&self, id: &str) -> bool { + self.swatches.contains_key(id) + } + + /// The REST `styleDefinitions` of every shared style. + pub(crate) fn definitions(cx: &mut Cx) -> Vec { + let layer = cx.shared.layer.clone(); + let text = cx.shared.text.clone(); + let mut out = Vec::new(); + for (id, name, style) in &layer { + let fills = super::style::fills(cx, style, 100.0, 100.0); + if !fills.is_empty() { + out.push(json!({"id": format!("{id}:fill"), "name": name, "styleType": "FILL", "node": {"fills": fills}})); + } + let strokes = super::style::border_paints(cx, style, 100.0, 100.0); + if !strokes.is_empty() { + out.push(json!({"id": format!("{id}:stroke"), "name": format!("{name} border"), "styleType": "FILL", "node": {"fills": strokes}})); + } + let effects = super::style::effects(cx, style); + if !effects.is_empty() { + out.push(json!({"id": format!("{id}:effect"), "name": name, "styleType": "EFFECT", "node": {"effects": effects}})); + } + } + for (id, name, style) in &text { + let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) else { continue }; + let mut ts = super::text::type_style(cx, enc); + if let Some(m) = ts.as_object_mut() { + m.remove("fills"); + } + out.push(json!({"id": id, "name": name, "styleType": "TEXT", "node": {"type": "TEXT", "characters": "", "style": ts}})); + } + out + } + + /// Link `node` (layer `l`) to the styles made from its shared style. + pub(crate) fn link(&self, cx_defs: &[String], l: &Value, node: &mut JsonMap) { + let Some(sid) = l.get("style").and_then(|s| str_field(s, "sharedObjectID")) else { return }; + let mut refs = JsonMap::new(); + let is_text = str_field(l, "_class") == Some("text"); + if is_text && self.text.iter().any(|(id, _, _)| id == sid) { + refs.insert("text".into(), json!(sid)); + } else if self.layer.iter().any(|(id, _, _)| id == sid) { + for slot in ["fill", "stroke", "effect"] { + let id = format!("{sid}:{slot}"); + if cx_defs.contains(&id) { + refs.insert(slot.into(), json!(id)); + } + } + } + if !refs.is_empty() { + node.insert("styles".into(), Value::Object(refs)); + } + } + + /// The REST `variableCollections` and `variables` for the swatches (none without swatches). + pub(crate) fn variables(&self) -> Option<(Value, Value)> { + if self.swatches.is_empty() { + return None; + } + let ids: Vec<&String> = self.swatches.keys().collect(); + let collections = json!({COLLECTION: {"id": COLLECTION, "name": "Color Variables", "modes": [{"modeId": MODE, "name": "Default"}], + "defaultModeId": MODE, "variableIds": ids}}); + let vars: JsonMap = self + .swatches + .iter() + .map(|(id, (name, c))| { + (id.clone(), json!({"id": id, "name": name, "resolvedType": "COLOR", "variableCollectionId": COLLECTION, "valuesByMode": {MODE: c}})) + }) + .collect(); + Some((collections, Value::Object(vars))) + } +} diff --git a/crates/format/src/sketch/shared_tests.rs b/crates/format/src/sketch/shared_tests.rs new file mode 100644 index 00000000..571a6d2e --- /dev/null +++ b/crates/format/src/sketch/shared_tests.rs @@ -0,0 +1,150 @@ +//! Sketch shared styles → FigCraft styles; Color Variables (swatches) → colour variables. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, Node, StyleKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; + +fn open(doc_json: Value, layers: Vec) -> (Document, Vec) { + let z = sketch_zip("83.2", 139, doc_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn style<'d>(doc: &'d Document, id: &str) -> &'d figcraft_doc::Style { + doc.styles.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("no style {id}: {:?}", doc.styles.iter().map(|s| &s.id).collect::>())) +} + +fn card_style() -> Value { + json!({"_class": "style", "fills": [solid_fill(0.0, 0.0, 1.0, 1.0)], "borders": [], + "shadows": [{"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.3)}], + "innerShadows": []}) +} + +fn with_shared(d: &mut Value, layer_styles: Vec, text_styles: Vec) { + d["layerStyles"]["objects"] = Value::Array(layer_styles); + d["layerTextStyles"]["objects"] = Value::Array(text_styles); +} + +fn shared(id: &str, name: &str, value: Value) -> Value { + json!({"_class": "sharedStyle", "do_objectID": id, "name": name, "value": value}) +} + +fn linked(mut l: Value, style: Value, shared_id: &str) -> Value { + l["style"] = style; + l["style"]["sharedObjectID"] = json!(shared_id); + l +} + +#[test] +fn layer_style_splits() { + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "CARD")]); + let n = node(&doc, "R"); + let fill = n.styles.fill.clone().expect("fill style linked"); + let effect = n.styles.effect.clone().expect("effect style linked"); + assert_eq!(style(&doc, &fill).name, "Card"); + assert!(matches!(style(&doc, &fill).kind, StyleKind::Paint { .. })); + assert!(matches!(style(&doc, &effect).kind, StyleKind::Effect { .. })); + assert_eq!(style(&doc, &effect).name, "Card"); +} + +#[test] +fn layer_style_borders_become_a_stroke_style() { + let mut st = card_style(); + st["borders"] = + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(1.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 2}]); + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", st.clone())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), st, "CARD")]); + let stroke = node(&doc, "R").styles.stroke.clone().expect("stroke style linked"); + assert!(style(&doc, &stroke).name.starts_with("Card")); +} + +#[test] +fn unused_shared_styles_are_listed() { + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![]); + assert!(doc.styles.iter().any(|s| s.name == "Card")); +} + +#[test] +fn text_style_links() { + let enc = json!({"MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": "Helvetica-Bold", "size": 32}}, + "MSAttributedStringColorAttribute": color(1.0, 0.0, 0.0, 1.0), + "paragraphStyle": {"_class": "paragraphStyle", "alignment": 0, "maximumLineHeight": 40, "minimumLineHeight": 40}}); + let st = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": enc, "verticalAlignment": 0}}); + let mut d = document(&["P"]); + with_shared(&mut d, vec![], vec![shared("H1", "Heading/H1", st.clone())]); + let t = linked(text("Title", 0.0, 0.0, 200.0, 40.0, "Hello", &[(0, 5, "Helvetica-Bold", 32.0, [1.0, 0.0, 0.0, 1.0])]), st, "H1"); + let (doc, _) = open(d, vec![t]); + let n = node(&doc, "Title"); + let sid = n.styles.text.clone().expect("text style linked"); + let s = style(&doc, &sid); + assert_eq!(s.name, "Heading/H1"); + let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") }; + assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0)); + assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))); +} + +#[test] +fn locally_changed_style_keeps_link() { + let mut local = card_style(); + local["fills"] = json!([solid_fill(0.0, 1.0, 0.0, 1.0)]); + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), local, "CARD")]); + let n = node(&doc, "R"); + assert!(n.styles.fill.is_some()); + assert!(matches!(n.fills[0].kind, PaintKind::Solid { color } if color.g == 1.0 && color.b == 0.0)); +} + +#[test] +fn foreign_styles_too() { + let mut d = document(&["P"]); + d["foreignLayerStyles"] = json!([{"_class": "MSImmutableForeignLayerStyle", "sourceLibraryName": "Kit", "localSharedStyle": shared("FCARD", "Kit card", card_style())}]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "FCARD")]); + assert_eq!(style(&doc, node(&doc, "R").styles.fill.as_deref().unwrap()).name, "Kit card"); +} + +#[test] +fn swatches_are_color_variables() { + let mut d = document(&["P"]); + d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW1", "name": "Brand/Blue", "value": color(0.06, 0.38, 1.0, 1.0)}]}); + d["foreignSwatches"] = json!([{"_class": "MSImmutableForeignSwatch", "sourceLibraryName": "Kit", "localSwatch": {"_class": "swatch", "do_objectID": "SW2", "name": "Kit/Red", "value": color(1.0, 0.0, 0.0, 1.0)}}]); + let mut fill = solid_fill(0.06, 0.38, 1.0, 1.0); + fill["color"]["swatchID"] = json!("SW1"); + let mut fill2 = solid_fill(1.0, 0.0, 0.0, 1.0); + fill2["color"]["swatchID"] = json!("SW2"); + let (doc, _) = open( + d, + vec![styled(rect("R", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill])), styled(rect("S", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill2]))], + ); + let coll = doc.variables.collections.iter().find(|c| c.name == "Color Variables").expect("collection"); + let var = doc.variables.variables.iter().find(|v| v.name == "Brand/Blue").expect("variable"); + assert_eq!(var.collection, coll.id); + assert!(doc.variables.variables.iter().any(|v| v.name == "Kit/Red")); + assert_eq!(node(&doc, "R").bound.get("fill"), Some(&var.id)); + assert!(node(&doc, "S").bound.contains_key("fill")); +} + +#[test] +fn unknown_shared_ids_are_ignored() { + let (doc, _) = open(document(&["P"]), vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "NOPE")]); + let n = node(&doc, "R"); + assert!(n.styles.fill.is_none()); + let mut f = solid_fill(0.0, 0.0, 0.0, 1.0); + f["color"]["swatchID"] = json!("NOPE"); + let (doc, _) = + open(document(&["P"]), vec![styled(text("T", 0.0, 0.0, 10.0, 10.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]), "fills", json!([f]))]); + assert!(node(&doc, "T").bound.is_empty()); +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index 2da1b7a4..de2529aa 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -17,7 +17,7 @@ const MAX_LIST: usize = 64; /// Write the paints, strokes, effects, opacity and blend mode of `style` into `node`, a layer of /// `w`×`h`. Lists the style doesn't have are left as they are. pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { - let fills: Vec = list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect(); + let fills = fills(cx, style, w, h); if !fills.is_empty() { node.insert("fills".into(), Value::Array(fills)); } @@ -36,6 +36,16 @@ pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonM } } +/// A style's fills as REST paints. +pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect() +} + +/// A style's borders as REST paints. +pub(crate) fn border_paints(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + list(style, "borders").iter().filter_map(|b| paint(cx, b, w, h)).collect() +} + /// Remove what [`apply`] writes, so another style can take its place. pub(crate) fn clear(node: &mut JsonMap) { for key in [ @@ -91,6 +101,9 @@ fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { } }; p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); + if 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)); @@ -131,7 +144,7 @@ fn pt((x, y): (f64, f64)) -> Value { /// Borders → stroke paints, and the first enabled border's thickness and position. fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { let list = list(style, "borders"); - let strokes: Vec = list.iter().filter_map(|b| paint(cx, b, w, h)).collect(); + let strokes = border_paints(cx, style, w, h); if strokes.is_empty() { return; } @@ -201,7 +214,7 @@ fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap Vec { +pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { let mut out = Vec::new(); for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { for s in list(style, key) { @@ -230,6 +243,12 @@ fn effects(cx: &mut Cx, style: &Value) -> Vec { out } +/// The variable binding of a colour that names a swatch the file has (`{color: alias}`). +pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { + let id = c?.get("swatchID").and_then(Value::as_str).filter(|id| cx.shared.has_swatch(id))?; + Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}})) +} + /// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. fn blend(mode: i64) -> Option<&'static str> { const MODES: [&str; 18] = [ diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index d6e7165c..15079702 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -209,8 +209,11 @@ fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, d _ => {} } for (prop, list) in [("layerStyle", "layerStyles"), ("textStyle", "layerTextStyles")] { - if let Some(style) = get(prop).and_then(Value::as_str).and_then(|sid| shared_style(document, list, sid)) { + let sid = get(prop).and_then(Value::as_str); + if let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared_style(document, list, sid)?))) { l["style"] = style.clone(); + // The layer now uses that shared style: it links to it. + l["style"]["sharedObjectID"] = json!(sid); if prop == "textStyle" && 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) diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 74e7a731..3cdb2afc 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -94,7 +94,7 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { } /// A run's attributes as a REST type style (with its colour as `fills`). -fn type_style(cx: &mut Cx, a: &Value) -> Value { +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); @@ -126,7 +126,11 @@ fn type_style(cx: &mut Cx, a: &Value) -> Value { _ => "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})); - s["fills"] = json!([{"type": "SOLID", "color": c}]); + 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 } diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 11c7a414..481eec70 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -27,6 +27,9 @@ pub(crate) struct Cx<'a> { /// `document.json`: shared styles and library copies. pub document: &'a Value, pub symbols: super::symbols::Symbols, + pub shared: super::shared::Shared, + /// Ids of the styles made from the shared styles. + pub style_defs: Vec, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -49,9 +52,12 @@ impl Cx<'_> { /// Map the pages of `archive` to a REST file JSON named `name`. pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); + let shared = super::shared::Shared::read(&archive.document); let mut cx = Cx { document: &archive.document, symbols, + shared, + style_defs: Vec::new(), chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -59,6 +65,8 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { nodes: 0, max_nodes, }; + 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(); @@ -75,7 +83,11 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { let libraries = cx.symbols.libraries.join(", "); cx.warn(format!("symbols from the libraries {libraries} became local components on the page Library symbols")); } - let json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}}); + 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 } } @@ -147,9 +159,7 @@ 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); - } + styles(cx, l, w, h, &mut node); if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); @@ -157,6 +167,14 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: Some(Value::Object(node)) } +/// 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) { + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, node); + } + cx.shared.link(&cx.style_defs, l, node); +} + /// A clipping mask's flag and kind (outline or alpha). pub(crate) fn mask(l: &Value, node: &mut JsonMap) { if bool_field(l, "hasClippingMask") == Some(true) {