From 4437d49a7250ddbb312adf435084bcc36c9053e2 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:36:10 +0200 Subject: [PATCH] M6: Sketch import: symbols as components, instances with overrides Symbol masters become components, also those copied from libraries (on a page of their own); instances carry their master's layers with their overrides applied (text, nested symbol swaps and hiding, images, layer and text styles, nested paths) and resize them by their constraints, so editing a master still updates every instance. A symbol inside itself stops with a note; instances of missing symbols are empty frames. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + crates/engine/src/sketch_import_tests.rs | 30 +++ crates/format/Cargo.toml | 1 + crates/format/src/sketch/fixture.rs | 18 ++ crates/format/src/sketch/mod.rs | 37 ++- crates/format/src/sketch/symbols.rs | 298 ++++++++++++++++++++++ crates/format/src/sketch/symbols_tests.rs | 165 ++++++++++++ crates/format/src/sketch/tree.rs | 33 ++- 8 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/sketch/symbols.rs create mode 100644 crates/format/src/sketch/symbols_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 459c8320..0d118fda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1579,6 +1579,7 @@ dependencies = [ "thiserror 2.0.21", "wat", "web-time", + "zip", ] [[package]] @@ -1586,6 +1587,7 @@ name = "figcraft-format" version = "0.9.0" dependencies = [ "figcraft-doc", + "figcraft-layout", "figcraft-pathops", "flate2", "image", diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index ac35cb06..10a7b9ca 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -6,6 +6,8 @@ use std::io::Write; use base64::Engine; use serde_json::{Value, json}; +use figcraft_doc::NodeKind; + use crate::Session; /// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. @@ -145,3 +147,31 @@ fn sketch_font_families_match_installed_ones() { assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans")); assert_eq!(m("Comic Sans"), None); } + +/// Editing a master changes its instances, and a resized instance's layout isn't an override. +#[test] +fn sketch_instances_stay_live() { + let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []})); + let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]})); + let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let find = |s: &Session, inst: bool| { + let doc = s.doc().unwrap(); + let root = doc + .nodes() + .find(|n| if inst { matches!(n.kind, NodeKind::Instance { .. }) } else { matches!(n.kind, NodeKind::Component { .. }) }) + .unwrap() + .id; + doc.subtree(root).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Tag")).unwrap() + }; + let (main_tag, inst_tag) = (find(&s, false), find(&s, true)); + assert!(s.doc().unwrap().node(inst_tag).unwrap().overrides.is_empty(), "{:?}", s.doc().unwrap().node(inst_tag).unwrap().overrides); + s.execute("node.set", &json!({"id": main_tag, "props": {"fill": "#ff0000"}})).unwrap(); + let doc = s.doc().unwrap(); + let fill = &doc.node(inst_tag).unwrap().fills; + assert!(fill.iter().any(|p| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r > 0.99 && color.g < 0.01)), "{fill:?}"); + let b = doc.world_bounds(inst_tag).unwrap(); + assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}"); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 3dca3912..80a943e2 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] figcraft-doc = { workspace = true } figcraft-pathops = { workspace = true } +figcraft-layout = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index a7c5ea73..ac426f0e 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -168,3 +168,21 @@ pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: l["textBehaviour"] = json!(1); l } + +/// A symbol master `symbol_id` holding `layers`. +pub(crate) fn master(id: &str, symbol_id: &str, name: &str, w: f64, h: f64, layers: Vec) -> Value { + let mut l = artboard(id, name, 0.0, 0.0, w, h, layers); + l["_class"] = json!("symbolMaster"); + l["symbolID"] = json!(symbol_id); + l +} + +/// An instance of symbol `symbol_id` with `overrides` (`overrideName`, value). +pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64, overrides: &[(&str, Value)]) -> Value { + let mut l = layer("symbolInstance", id, id, x, y, w, h); + l["symbolID"] = json!(symbol_id); + l["scale"] = json!(1); + l["overrideValues"] = + Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect()); + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 8859c4a9..b9063a70 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -19,6 +19,9 @@ mod path_tests; mod style; #[cfg(test)] mod style_tests; +mod symbols; +#[cfg(test)] +mod symbols_tests; #[cfg(test)] mod tests; mod text; @@ -68,16 +71,26 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - import_within(bytes, name, MAX_JSON_MEMBER) + import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len())) } /// [`import`] reading pages of at most `max_member` bytes of JSON each. +#[cfg(test)] pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len())) +} + +/// [`import`] with at most `max_nodes` layers. +#[cfg(test)] +pub(crate) fn import_within_nodes(bytes: &[u8], name: &str, max_nodes: usize) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes) +} + +fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> Result<(Document, Vec), FormatError> { if bytes.starts_with(b"SQLite format 3\0") { return Err(FormatError::Io(TOO_OLD.into())); } let archive = Archive::open(bytes, max_member)?; - let max_nodes = crate::fig::layer_budget(bytes.len()); let map = tree::to_rest(&archive, name, max_nodes); if map.too_old { return Err(FormatError::Io(TOO_OLD.into())); @@ -92,6 +105,7 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; } + fit_instances(&mut doc); warnings.extend(more); Ok((doc, warnings)) } @@ -109,6 +123,25 @@ fn embedded_fonts(document: &Value) -> Option { Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", "))) } +/// Instances come in with their master's layers laid out at the master's size; resized ones lay +/// them out for their own size by their constraints, innermost first. +fn fit_instances(doc: &mut Document) { + let mut insts: Vec<(usize, figcraft_doc::NodeId, f64, f64)> = doc + .nodes() + .filter_map(|n| match n.kind { + figcraft_doc::NodeKind::Instance { component, .. } => { + let c = doc.node(component)?; + Some((doc.ancestors(n.id).len(), n.id, c.width, c.height)) + } + _ => None, + }) + .collect(); + insts.sort_by_key(|i| std::cmp::Reverse(i.0)); + for (_, id, w, h) in insts { + figcraft_layout::apply_constraints(doc, id, w, h); + } +} + fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs new file mode 100644 index 00000000..d6e7165c --- /dev/null +++ b/crates/format/src/sketch/symbols.rs @@ -0,0 +1,298 @@ +//! Sketch symbols → REST components and instances. +//! +//! A `symbolMaster` (on any page, or copied from a library into `document.foreignSymbols`) is a +//! component; a `symbolInstance` names it by `symbolID`. REST instances carry their layers, so an +//! instance is its master's layers with the instance's `overrideValues` applied, laid out at the +//! master's size (the import then resizes them to the instance by their constraints). An override +//! is named `_`: `path` is the `do_objectID` of a layer of the master, or of +//! nested instances and then a layer of theirs, joined by `/`; properties: `stringValue` (text), +//! `symbolID` (swap a nested instance; empty hides it), `image`, `layerStyle` and `textStyle` +//! (shared style ids). + +use std::collections::BTreeMap; + +use figcraft_doc::geom::Affine; +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, base, bool_field, children, local, size, str_field}; + +/// 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, + /// Masters copied from libraries that no page holds (they get a page of their own), with the + /// libraries' names. + pub foreign: Vec, + pub libraries: Vec, +} + +impl Symbols { + /// The masters of `pages` and of the document's library copies. + pub(crate) fn read(document: &Value, pages: &[(String, Value)]) -> Symbols { + let mut s = Symbols::default(); + let mut found = Vec::new(); + for (_, p) in pages { + masters_in(p, &mut found, 0); + } + for m in found { + s.add(m.clone()); + } + for f in document.get("foreignSymbols").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() { + let Some(m) = f.get("symbolMaster") else { continue }; + let Some(id) = str_field(m, "symbolID") else { continue }; + if s.masters.contains_key(id) { + continue; + } + if let Some(lib) = str_field(f, "sourceLibraryName") + && !s.libraries.iter().any(|l| l == lib) + { + s.libraries.push(lib.chars().take(64).collect()); + } + s.add(m.clone()); + s.foreign.push(m.clone()); + } + s + } + + fn add(&mut self, m: Value) { + if self.masters.len() >= MAX_MASTERS { + return; + } + if let Some(id) = str_field(&m, "symbolID") { + self.masters.entry(id.to_string()).or_insert(m); + } + } + + fn master(&self, symbol_id: &str) -> Option<&Value> { + self.masters.get(symbol_id) + } +} + +fn masters_in<'v>(l: &'v Value, out: &mut Vec<&'v Value>, depth: usize) { + if depth > 64 { + return; + } + for c in super::tree::layers(l) { + if str_field(c, "_class") == Some("symbolMaster") { + out.push(c); + } else { + masters_in(c, out, depth + 1); + } + } +} + +/// A master's background as REST fills (masters show one only when they have it on). +pub(crate) fn master_fills(m: &Value) -> Option { + if bool_field(m, "hasBackgroundColor") != Some(true) { + return None; + } + let c = m.get("backgroundColor").and_then(super::tree::color)?; + Some(json!([{"type": "SOLID", "color": c}])) +} + +/// A symbol master as a COMPONENT node. +pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let mut node = base(l, "COMPONENT", parent, world, w, h, root); + node.insert("clipsContent".into(), json!(true)); + if let Some(f) = master_fills(l) { + node.insert("fills".into(), f); + } + let sid = str_field(l, "symbolID").unwrap_or("").to_string(); + cx.chain.push(sid); + let kids = children(cx, l, world, (w, h), depth, false); + cx.chain.pop(); + node.insert("children".into(), Value::Array(kids)); + Some(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. +pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let sid = str_field(l, "symbolID").unwrap_or(""); + 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 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 None; + } + if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && (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))); + node.insert("clipsContent".into(), json!(true)); + if let Some(f) = master_fills(&m) { + node.insert("fills".into(), f); + } + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, &mut node); + } + let overrides = overrides(l); + let mut copy = m; + if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { + apply(layers, &overrides, cx.document, 0); + } + let (mw, mh) = size(©); + cx.chain.push(sid.to_string()); + let mut kids = children(cx, ©, world, (mw, mh), depth, false); + cx.chain.pop(); + let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); + for k in &mut kids { + prefix_ids(k, &prefix, 0); + } + node.insert("children".into(), Value::Array(kids)); + Some(Value::Object(node)) +} + +/// An instance's overrides by name (`_` → value). +fn overrides(l: &Value) -> BTreeMap { + l.get("overrideValues") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .take(100_000) + .filter_map(|o| Some((str_field(o, "overrideName")?.to_string(), o.get("value")?.clone()))) + .collect() +} + +/// 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). +fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, depth: usize) { + 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), + Some(other) => l["symbolID"] = json!(other), + None => {} + } + let prefix = format!("{id}/"); + let inner: Vec = ov + .iter() + .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|rest| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))) + .collect(); + if !inner.is_empty() { + let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); + own.extend(inner); + l["overrideValues"] = Value::Array(own); + } + continue; + } + Some("text") => { + if let Some(s) = get("stringValue").and_then(Value::as_str) { + set_string(l, s); + } + } + Some("bitmap") => { + if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) { + l["image"] = img.clone(); + } + } + _ => {} + } + 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)) { + l["style"] = style.clone(); + 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) + { + for r in runs { + r["attributes"] = enc.clone(); + } + } + } + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + apply(kids, ov, document, depth + 1); + } + } +} + +/// 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(mut r) => { + r["location"] = json!(0); + r["length"] = json!(units); + vec![r] + } + None => Vec::new(), + }; + a.insert("attributes".into(), Value::Array(runs)); +} + +/// The style of shared style `id` from the document's `list` (local) or its library copies. +pub(crate) fn shared_style<'v>(document: &'v Value, list: &str, id: &str) -> Option<&'v Value> { + let local = document.get(list).and_then(|c| c.get("objects")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let foreign_key = if list == "layerStyles" { "foreignLayerStyles" } else { "foreignTextStyles" }; + let foreign = document.get(foreign_key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + local + .iter() + .find(|s| str_field(s, "do_objectID") == Some(id)) + .or_else(|| foreign.iter().filter_map(|f| f.get("localSharedStyle")).find(|s| str_field(s, "do_objectID") == Some(id))) + .and_then(|s| s.get("value")) +} + +/// Make the ids of an instance's layers its own (`;`): the master's layers keep +/// theirs. +fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) { + if depth > 256 { + return; + } + let Some(m) = v.as_object_mut() else { return }; + if let Some(id) = m.get("id").and_then(Value::as_str).map(str::to_string) { + m.insert("id".into(), json!(format!("{prefix};{id}"))); + } + if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) { + for k in kids { + prefix_ids(k, prefix, depth + 1); + } + } +} + +/// Library masters no page holds: a page of their own, side by side. +pub(crate) fn library_page(cx: &mut Cx, masters: &[Value]) -> Value { + let mut x = 0.0; + let mut kids = Vec::new(); + for m in masters { + let mut placed = m.clone(); + let (w, _) = size(m); + if let Some(f) = placed.get_mut("frame").and_then(Value::as_object_mut) { + f.insert("x".into(), json!(x)); + f.insert("y".into(), json!(0.0)); + } + x += w + 100.0; + if let Some(node) = component(cx, &placed, Affine::IDENTITY, 0, true) { + kids.push(node); + } + } + let mut page = JsonMap::new(); + page.insert("type".into(), json!("CANVAS")); + page.insert("id".into(), json!("library-symbols")); + page.insert("name".into(), json!("Library symbols")); + page.insert("children".into(), Value::Array(kids)); + Value::Object(page) +} diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs new file mode 100644 index 00000000..e2204399 --- /dev/null +++ b/crates/format/src/sketch/symbols_tests.rs @@ -0,0 +1,165 @@ +//! Sketch symbols → components and instances with overrides. + +use figcraft_doc::{Document, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::{import, import_within_nodes}; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; + +/// One page with `layers`, one "Symbols" page with `masters`, and `document` changes. +fn open_doc(doc_json: Value, layers: Vec, masters: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, doc_json, &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap() +} + +fn open(layers: Vec, masters: Vec) -> (Document, Vec) { + let mut d = document(&["P", "S"]); + d["do_objectID"] = json!("DOC"); + open_doc(d, layers, masters) +} + +fn named<'d>(doc: &'d Document, name: &str) -> Vec<&'d Node> { + doc.nodes().filter(|n| n.name == name).collect() +} + +fn inside<'d>(doc: &'d Document, root: &Node, name: &str) -> &'d Node { + doc.subtree(root.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn chars(n: &Node) -> &str { + match &n.kind { + NodeKind::Text(td) => &td.characters, + other => panic!("{other:?}"), + } +} + +fn instance_of<'d>(doc: &'d Document, name: &str) -> (&'d Node, &'d Node) { + let inst = named(doc, name).into_iter().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no instance {name}")); + let NodeKind::Instance { component, .. } = inst.kind else { panic!("not an instance") }; + (inst, doc.node(component).unwrap()) +} + +fn button() -> Value { + master("M1", "S1", "Button", 100.0, 40.0, vec![text("Label", 10.0, 10.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, BLACK)])]) +} + +#[test] +fn master_is_component_instance_links() { + let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![button()]); + let (inst, main) = instance_of(&doc, "I1"); + assert!(matches!(main.kind, NodeKind::Component { .. })); + assert_eq!(main.name, "Button"); + assert_eq!(chars(inside(&doc, inst, "Label")), "Go"); +} + +#[test] +fn string_override() { + let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[("Label_stringValue", json!("OK"))])], vec![button()]); + let (inst, main) = instance_of(&doc, "I1"); + assert_eq!(chars(inside(&doc, inst, "Label")), "OK"); + assert_eq!(chars(inside(&doc, main, "Label")), "Go"); +} + +#[test] +fn nested_symbol_swap_and_hide() { + let icon_a = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]); + let icon_b = master("MB", "SB", "Icon B", 20.0, 20.0, vec![rect("Dot B", 0.0, 0.0, 20.0, 20.0)]); + let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]); + let swapped = instance("Swapped", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("SB"))]); + let hidden = instance("Hidden", "SC", 0.0, 100.0, 100.0, 40.0, &[("N_symbolID", json!(""))]); + let (doc, _) = open(vec![swapped, hidden], vec![icon_a, icon_b, card]); + let (sw, _) = instance_of(&doc, "Swapped"); + assert!(doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot B")); + assert!(!doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A")); + let (hd, _) = instance_of(&doc, "Hidden"); + let nested = doc.node(hd.children[0]).unwrap(); + assert!(!nested.visible); +} + +#[test] +fn nested_path_override() { + let inner = master("MB", "SB", "Badge", 50.0, 20.0, vec![text("Count", 0.0, 0.0, 50.0, 20.0, "1", &[(0, 1, "Helvetica", 12.0, BLACK)])]); + let outer = master("MA", "SA", "Row", 100.0, 20.0, vec![instance("N", "SB", 50.0, 0.0, 50.0, 20.0, &[])]); + let (doc, _) = open(vec![instance("I1", "SA", 0.0, 0.0, 100.0, 20.0, &[("N/Count_stringValue", json!("42"))])], vec![inner, outer]); + let (inst, _) = instance_of(&doc, "I1"); + assert_eq!(chars(inside(&doc, inst, "Count")), "42"); +} + +#[test] +fn image_override() { + let mut bitmap = rect("Photo", 0.0, 0.0, 40.0, 40.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + let m = master("M", "S", "Avatar", 40.0, 40.0, vec![bitmap]); + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(1, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let mut png2 = Vec::new(); + image::RgbaImage::new(2, 1).write_to(&mut std::io::Cursor::new(&mut png2), image::ImageFormat::Png).unwrap(); + let inst = instance("I1", "S", 0.0, 0.0, 40.0, 40.0, &[("Photo_image", json!({"_class": "MSJSONFileReference", "_ref": "images/b.png"}))]); + let z = sketch_zip( + "50.2", + 103, + document(&["P", "S"]), + &[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])], + &[("images/a.png", &png), ("images/b.png", &png2)], + ); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(doc.images.len(), 2); +} + +#[test] +fn foreign_symbols_are_components() { + let mut d = document(&["P", "S"]); + d["foreignSymbols"] = json!([{"_class": "MSImmutableForeignSymbol", "sourceLibraryName": "Kit", "symbolMaster": button()}]); + let (doc, warnings) = open_doc(d, vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![]); + let (_, main) = instance_of(&doc, "I1"); + assert_eq!(main.name, "Button"); + let page = doc.pages.iter().copied().find(|p| doc.is_ancestor(*p, main.id)).unwrap(); + assert_eq!(doc.node(page).unwrap().name, "Library symbols"); + assert!(warnings.iter().any(|w| w.contains("Kit")), "{warnings:?}"); +} + +#[test] +fn missing_master_warns() { + let (doc, warnings) = open(vec![instance("Lost", "NOPE", 0.0, 0.0, 30.0, 30.0, &[])], vec![]); + let n = named(&doc, "Lost")[0]; + assert!(matches!(n.kind, NodeKind::Frame)); + assert_eq!((n.width, n.height), (30.0, 30.0)); + assert!(warnings.iter().any(|w| w.contains("symbol")), "{warnings:?}"); +} + +#[test] +fn self_containing_master_stops() { + let a = master("MA", "SA", "Loop A", 50.0, 50.0, vec![instance("Self", "SA", 0.0, 0.0, 10.0, 10.0, &[])]); + let b = master("MB", "SB", "Loop B", 50.0, 50.0, vec![instance("ToC", "SC", 0.0, 0.0, 10.0, 10.0, &[])]); + let c = master("MC", "SC", "Loop C", 50.0, 50.0, vec![instance("ToB", "SB", 0.0, 0.0, 10.0, 10.0, &[])]); + let (_, warnings) = open(vec![instance("I1", "SA", 0.0, 0.0, 50.0, 50.0, &[]), instance("I2", "SB", 100.0, 0.0, 50.0, 50.0, &[])], vec![a, b, c]); + assert!(warnings.iter().any(|w| w.contains("contains itself")), "{warnings:?}"); +} + +#[test] +fn instance_explosion_is_bounded() { + let big = master("M", "S", "Big", 100.0, 100.0, (0..1000).map(|i| rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0)).collect()); + let many: Vec = (0..1000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 100.0, 100.0, &[])).collect(); + let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![big])], &[]); + let (doc, warnings) = import_within_nodes(&z, "f", 20_000).unwrap(); + assert!(doc.nodes().count() <= 20_100); + assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + +#[test] +fn resized_instance_follows_constraints() { + // Pinned right with a fixed width (bits 1 and 2 cleared). + let pinned = with(rect("Tag", 80.0, 0.0, 20.0, 40.0), "resizingConstraint", json!(60)); + let m = master("M", "S", "Bar", 100.0, 40.0, vec![pinned]); + let (doc, _) = open(vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]); + let (inst, _) = instance_of(&doc, "Wide"); + let tag = inside(&doc, inst, "Tag"); + let b = doc.world_bounds(tag.id).unwrap(); + assert!((b.x0 - 180.0).abs() < 1e-6 && (b.width() - 20.0).abs() < 1e-6, "{b:?}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 16382dbe..11c7a414 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -23,7 +23,12 @@ pub(crate) struct Map { } /// The mapping's state while walking the file. -pub(crate) struct Cx { +pub(crate) struct Cx<'a> { + /// `document.json`: shared styles and library copies. + pub document: &'a Value, + pub symbols: super::symbols::Symbols, + /// The symbols being expanded, outermost first (a symbol inside itself stops there). + pub chain: Vec, pub warnings: Vec, pub networks: Vec>, pub too_old: bool, @@ -32,7 +37,7 @@ pub(crate) struct Cx { pub max_nodes: usize, } -impl Cx { +impl Cx<'_> { pub(crate) fn warn(&mut self, msg: impl Into) { let msg = msg.into(); if !self.warnings.contains(&msg) { @@ -43,7 +48,17 @@ 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 mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false, nodes: 0, max_nodes }; + let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); + let mut cx = Cx { + document: &archive.document, + symbols, + chain: Vec::new(), + warnings: Vec::new(), + networks: Vec::new(), + too_old: false, + nodes: 0, + max_nodes, + }; let mut canvases = Vec::new(); for (id, page) in &archive.pages { let mut node = JsonMap::new(); @@ -54,6 +69,12 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { node.insert("children".into(), Value::Array(kids)); 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 json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}}); Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } } @@ -73,6 +94,12 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: if class == "shapeGroup" { return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); } + if class == "symbolMaster" { + return count(cx).then(|| super::symbols::component(cx, l, parent, depth, root)).flatten(); + } + if class == "symbolInstance" { + return count(cx).then(|| super::symbols::instance(cx, l, parent, depth, root)).flatten(); + } let ty = match class { "artboard" | "frame" => "FRAME", "group" | "graphic" => "GROUP",