mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
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:
co-authored by
Claude Opus 5.5
parent
60cc184ab7
commit
4437d49a72
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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(©);
|
||||
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 (`<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)
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user