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 <noreply@anthropic.com>
This commit is contained in:
Jindřich Šaršon
2026-10-22 00:27:20 +02:00
co-authored by Claude Opus 5.5
parent 60cc184ab7
commit 4437d49a72
8 changed files with 579 additions and 5 deletions
Generated
+2
View File
@@ -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",
+30
View File
@@ -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:?}");
}
+1
View File
@@ -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 }
+18
View File
@@ -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>) -> 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
}
+35 -2
View File
@@ -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<Stri
/// Build a document from a `.sketch` file. Returns it with the warnings about what was skipped or
/// approximated. `name`: the document name.
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), 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<String>), 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<String>), 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<String>), 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<String> {
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<String>) -> FormatError {
FormatError::Corrupt(format!("Sketch file: {}", msg.into()))
}
+298
View File
@@ -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>_<property>`: `path` is the `do_objectID` of a layer of the master, or of
//! nested instances and then a layer of theirs, joined by `/`; properties: `stringValue` (text),
//! `symbolID` (swap a nested instance; empty hides it), `image`, `layerStyle` and `textStyle`
//! (shared style ids).
use std::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<String, Value>,
/// Masters copied from libraries that no page holds (they get a page of their own), with the
/// libraries' names.
pub foreign: Vec<Value>,
pub libraries: Vec<String>,
}
impl Symbols {
/// The masters of `pages` and of the document's library copies.
pub(crate) fn read(document: &Value, pages: &[(String, Value)]) -> Symbols {
let mut s = Symbols::default();
let mut found = Vec::new();
for (_, p) in pages {
masters_in(p, &mut found, 0);
}
for m in found {
s.add(m.clone());
}
for f in document.get("foreignSymbols").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() {
let Some(m) = f.get("symbolMaster") else { continue };
let Some(id) = str_field(m, "symbolID") else { continue };
if s.masters.contains_key(id) {
continue;
}
if let Some(lib) = str_field(f, "sourceLibraryName")
&& !s.libraries.iter().any(|l| l == lib)
{
s.libraries.push(lib.chars().take(64).collect());
}
s.add(m.clone());
s.foreign.push(m.clone());
}
s
}
fn add(&mut self, m: Value) {
if self.masters.len() >= MAX_MASTERS {
return;
}
if let Some(id) = str_field(&m, "symbolID") {
self.masters.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<Value> {
if bool_field(m, "hasBackgroundColor") != Some(true) {
return None;
}
let c = m.get("backgroundColor").and_then(super::tree::color)?;
Some(json!([{"type": "SOLID", "color": c}]))
}
/// A symbol master as a COMPONENT node.
pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option<Value> {
let (w, h) = size(l);
let world = parent * local(l, w, h);
let 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<Value> {
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(&copy);
cx.chain.push(sid.to_string());
let mut kids = children(cx, &copy, 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 (`<path>_<property>` → value).
fn overrides(l: &Value) -> BTreeMap<String, Value> {
l.get("overrideValues")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.take(100_000)
.filter_map(|o| Some((str_field(o, "overrideName")?.to_string(), o.get("value")?.clone())))
.collect()
}
/// 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<String, Value>, 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<Value> = 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 (`<instance>;<layer>`): the master's layers keep
/// theirs.
fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) {
if depth > 256 {
return;
}
let Some(m) = v.as_object_mut() else { return };
if let Some(id) = m.get("id").and_then(Value::as_str).map(str::to_string) {
m.insert("id".into(), json!(format!("{prefix};{id}")));
}
if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) {
for k in kids {
prefix_ids(k, prefix, depth + 1);
}
}
}
/// Library masters no page holds: a page of their own, side by side.
pub(crate) fn library_page(cx: &mut Cx, masters: &[Value]) -> Value {
let mut x = 0.0;
let mut kids = Vec::new();
for m in masters {
let mut placed = m.clone();
let (w, _) = size(m);
if let Some(f) = placed.get_mut("frame").and_then(Value::as_object_mut) {
f.insert("x".into(), json!(x));
f.insert("y".into(), json!(0.0));
}
x += w + 100.0;
if let Some(node) = component(cx, &placed, Affine::IDENTITY, 0, true) {
kids.push(node);
}
}
let mut page = JsonMap::new();
page.insert("type".into(), json!("CANVAS"));
page.insert("id".into(), json!("library-symbols"));
page.insert("name".into(), json!("Library symbols"));
page.insert("children".into(), Value::Array(kids));
Value::Object(page)
}
+165
View File
@@ -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<Value>, masters: Vec<Value>) -> (Document, Vec<String>) {
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<Value>, masters: Vec<Value>) -> (Document, Vec<String>) {
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<Value> = (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:?}");
}
+30 -3
View File
@@ -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<String>,
pub warnings: Vec<String>,
pub networks: Vec<Option<VectorNetwork>>,
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<String>) {
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",