M1: REST import keeps slot layers and instances' slot content (#792) (#796)

Fixes #792.

Fixes #792.
This commit is contained in:
BFlat
2026-10-18 15:52:30 +07:00
committed by GitHub
parent 8629a6f586
commit ef535e49b0
7 changed files with 431 additions and 10 deletions
+2
View File
@@ -1744,6 +1744,8 @@ mod proto_run_tests;
#[cfg(test)]
mod proto_tail_tests;
#[cfg(test)]
mod rest_slot_tests;
#[cfg(test)]
mod retained_walk_tests;
#[cfg(test)]
mod rotated_guides_tests;
+91
View File
@@ -0,0 +1,91 @@
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
//! moves of the mains, and through saving.
use crate::Session;
use crate::components::tests::{ex, healthy, idv};
use crate::components::*;
use base64::Engine as _;
use figcraft_doc::*;
use serde_json::{Value, json};
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
fn text(id: &str, s: &str) -> Value {
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
}
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
/// holds a text and a chip.
fn file() -> Value {
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]},
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
]}]}})
}
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
}
/// The instance's slot and the names of what it holds.
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
let doc = s.doc().unwrap();
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
(slot, names)
}
#[test]
fn rest_slot_content_survives_main_edits_moves_and_saving() {
let mut s = Session::new();
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
healthy(&s);
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
let (slot, names) = slot_content(&s);
assert_eq!(names, want);
let doc = s.doc().unwrap();
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
assert!(slot_edited(doc, slot), "the instance's own content");
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
assert!(!inside_instance(doc, chip), "content stands alone");
// Edit and move both mains (the container's and the one used in the content).
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
let title = doc.node(main).unwrap().children[0];
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
healthy(&s);
assert_eq!(slot_content(&s).1, want, "kept across syncs");
// Saving and opening again keeps it.
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
let mut again = Session::new();
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
healthy(&again);
let (slot, names) = slot_content(&again);
assert_eq!(names, want);
assert!(slot_edited(again.doc().unwrap(), slot));
// Resetting the slot shows the main's (empty) default content.
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
assert!(slot_content(&again).1.is_empty());
}
+104 -8
View File
@@ -6,8 +6,8 @@
//! (`relativeTransform`, else `absoluteBoundingBox` + `rotation`), paints, strokes, effects,
//! corners, constraints, auto layout, layout grids, export settings, text with styled runs,
//! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and
//! component properties are imported. Image fills keep their `imageRef` as the image key and
//! show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
//! component properties (slots included) are imported. Image fills keep their `imageRef` as the
//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
//! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows,
//! scrolling and the prototype device. Variables come from the local variables response
//! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately
@@ -37,6 +37,8 @@ pub(crate) mod proto;
#[cfg(test)]
mod proto_variable_tests;
#[cfg(test)]
pub(crate) mod slot_tests;
#[cfg(test)]
mod tests;
mod text;
mod variables;
@@ -381,6 +383,10 @@ pub(crate) struct Ctx<'a> {
style_meta: BTreeMap<&'a str, &'a Value>,
/// Instances (imported as frames until their main is resolved) with their JSON.
instances: Vec<(NodeId, &'a Value)>,
/// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON.
slots: Vec<(NodeId, &'a Value)>,
/// Slot property defaults (the main's slot content id): (component or set, property) → value.
slot_defaults: BTreeMap<(NodeId, String), &'a Value>,
/// Instance-swap property defaults: (component or set, property, REST id of the default).
swap_defaults: Vec<(NodeId, String, String)>,
/// Property definitions of component sets, shared by their variants.
@@ -415,6 +421,8 @@ impl<'a> Ctx<'a> {
set_meta: BTreeMap::new(),
style_meta: BTreeMap::new(),
instances: Vec::new(),
slots: Vec::new(),
slot_defaults: BTreeMap::new(),
swap_defaults: Vec::new(),
set_defs: BTreeMap::new(),
flows: Vec::new(),
@@ -515,8 +523,10 @@ impl<'a> Ctx<'a> {
sources.record(id, j, primary);
}
}
if ty == "INSTANCE" {
self.instances.push((id, j));
match ty {
"INSTANCE" => self.instances.push((id, j)),
"SLOT" => self.slots.push((id, j)),
_ => {}
}
let kids = children(j);
if kids.is_empty() {
@@ -539,8 +549,8 @@ impl<'a> Ctx<'a> {
let point_count = || num(j, "pointCount").filter(|c| *c >= 3.0).map(|c| c.min(1000.0) as u32);
let has_geometry = !list(j, "fillGeometry").is_empty() || !list(j, "strokeGeometry").is_empty();
Some(match ty {
// Instances start as frames; `finish` links them to their main.
"FRAME" | "INSTANCE" => NodeKind::Frame,
// Instances and slots start as frames; `finish` links them to their main and property.
"FRAME" | "INSTANCE" | "SLOT" => NodeKind::Frame,
"GROUP" => NodeKind::Group,
"SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) },
"RECTANGLE" => NodeKind::Rectangle,
@@ -751,7 +761,7 @@ impl<'a> Ctx<'a> {
}
/// Component property definitions (variant ones are implied by the variant names).
fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap<String, PropDef> {
fn prop_defs(&mut self, owner: NodeId, j: &'a Value) -> BTreeMap<String, PropDef> {
let mut out = BTreeMap::new();
for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) {
let default = d.get("defaultValue");
@@ -765,7 +775,13 @@ impl<'a> Ctx<'a> {
continue;
}
Some("VARIANT") => continue,
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
Some("SLOT") => {
if let Some(d) = default {
self.slot_defaults.insert((owner, name.clone()), d);
}
self.slot_notes(d);
PropDef::Slot { preferred: Vec::new() }
}
_ => {
self.warn("unknown component property types were skipped");
continue;
@@ -776,6 +792,20 @@ impl<'a> Ctx<'a> {
out
}
/// Notes on what a slot property sets that the editor doesn't keep: preferred components (keys
/// of library components, not layers of the file) and limits on its content.
fn slot_notes(&mut self, d: &Value) {
if !list(d, "preferredValues").is_empty() {
self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped");
}
// Insertion and empty-slot display only change editing aids, not what the file shows.
let s = d.get("slotSettings");
let limit = |k: &str| s.and_then(|s| num(s, k)).is_some_and(|n| n != 0.0);
if limit("minChildren") || limit("maxChildren") || s.and_then(|s| flag(s, "allowPreferredValuesOnly")) == Some(true) {
self.warn("non-default slot settings are not represented and were skipped");
}
}
/// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of
/// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in
/// the file's order. Layers then link to them; styles that no layer uses come across too.
@@ -984,6 +1014,7 @@ impl<'a> Ctx<'a> {
/// outside components, and instances that would nest a component inside itself (directly or
/// through other components), stay frames.
fn resolve_instances(&mut self) {
let frames: BTreeSet<NodeId> = self.instances.iter().map(|(i, _)| *i).collect();
let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new();
for (inst, j) in std::mem::take(&mut self.instances) {
match text(j, "componentId").and_then(|r| self.component(r)) {
@@ -1013,6 +1044,7 @@ impl<'a> Ctx<'a> {
linked.push((inst, j));
}
}
self.resolve_slots(&frames, &linked);
// Overridden fields the file lists, on the instance and its layers.
for (inst, j) in linked {
for o in list(j, "overrides") {
@@ -1028,6 +1060,70 @@ impl<'a> Ctx<'a> {
}
}
/// `SLOT` layers. In a main component a slot is bound to its slot property: the one the layer
/// references, else the one named like it, else the main's only slot property left. In an
/// instance it follows its main's slot, and holds the instance's own content (kept across syncs)
/// unless the instance's value of the property is the main's default content (without the
/// values to compare, a slot holding layers keeps them as its own). A slot without a property
/// stays a frame. `frames`: every instance
/// layer of the file, `linked`: those that became instances.
fn resolve_slots(&mut self, frames: &BTreeSet<NodeId>, linked: &[(NodeId, &'a Value)]) {
let linked: BTreeMap<NodeId, &Value> = linked.iter().copied().collect();
let mut bound: BTreeSet<(NodeId, String)> = BTreeSet::new();
for (id, j) in std::mem::take(&mut self.slots) {
// The nearest main component or instance above the slot owns it; in an instance whose
// main isn't in the file (a frame), the slot stays a frame too.
let owner = self.doc.ancestors(id).into_iter().find_map(|a| match self.doc.node(a).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => Some(linked.get(&a).map(|j| (*component, Some(*j)))),
Some(NodeKind::Component { .. }) => Some(Some((a, None))),
_ => frames.contains(&a).then_some(None),
});
let Some(owner) = owner else {
self.warn("slot layers without a matching slot property were imported as frames");
continue;
};
let Some((main, inst)) = owner else { continue };
let Some(NodeKind::Component { props, .. }) = self.doc.node(main).map(|n| &n.kind) else { continue };
let defs: Vec<&String> = props.iter().filter(|(_, d)| matches!(d, PropDef::Slot { .. })).map(|(k, _)| k).collect();
let layer = text(j, "name").unwrap_or_default();
let refs: Vec<&str> =
j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten().filter_map(|(_, v)| v.as_str()).collect();
// An instance's slot is matched against all of its main's slot properties.
let free: Vec<&&String> = defs.iter().filter(|d| inst.is_some() || !bound.contains(&(main, (**d).clone()))).collect();
let name = defs
.iter()
.find(|d| refs.contains(&d.as_str()))
.or_else(|| defs.iter().find(|d| short_prop_name(d) == layer))
.or_else(|| if free.len() == 1 { free.first().copied() } else { None })
.map(|d| (*d).clone());
let Some(n) = self.doc.node(id) else { continue };
let has_content = !n.children.is_empty();
let Some(inst) = inst else {
match name.filter(|p| bound.insert((main, p.clone()))) {
Some(p) => {
if let Some(n) = self.doc.node_mut(id) {
n.prop_refs.insert("slot".into(), p);
}
}
None => self.warn("slot layers without a matching slot property were imported as frames"),
}
continue;
};
let set = self.doc.node(main).and_then(|n| n.parent);
let default =
name.as_ref().and_then(|p| [Some(main), set].into_iter().flatten().find_map(|o| self.slot_defaults.get(&(o, p.clone())).copied()));
let value = name.as_ref().and_then(|p| inst.get("componentProperties")?.get(p)?.get("value"));
let own = name.is_some()
&& match (value, default) {
(Some(v), Some(d)) => v != d,
_ => has_content,
};
if own && let Some(n) = self.doc.node_mut(id) {
n.overrides.insert("slot".into());
}
}
}
/// An instance's property values (variant values are implied by the main it shows).
fn instance_props(&self, j: &Value) -> BTreeMap<String, Value> {
let mut out = BTreeMap::new();
+229
View File
@@ -0,0 +1,229 @@
//! `SLOT` layers of the REST file JSON: bound to their slot property in a main component, and
//! holding each instance's own content (synthetic files).
use super::*;
use serde_json::json;
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
pub(crate) fn file(children: Value) -> Value {
json!({"name": "Slots", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": children}
]}})
}
fn content_id(local: u32) -> Value {
json!({"guid": {"sessionID": 5, "localID": local}})
}
fn slot(id: &str, name: &str, children: Value) -> Value {
json!({"id": id, "name": name, "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": children})
}
fn label(id: &str, text: &str) -> Value {
json!({"id": id, "name": text, "type": "TEXT", "characters": text, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
}
/// A "Chip" component (`1:9`), a "Container" component (`1:2`) with a "Title" text and an empty
/// "Slot" (property `Slot#1:3`, default content 5:1), and the given layers after them.
pub(crate) fn container_file(more: Vec<Value>) -> Value {
let mut layers = vec![
json!({"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]}),
json!({"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": content_id(1)}},
"children": [label("1:4", "Title"), slot("1:5", "Slot", json!([]))]}),
];
layers.extend(more);
file(Value::Array(layers))
}
/// An instance `id` of Container at `x` whose slot holds `content` (property value `value`).
pub(crate) fn container(id: &str, x: f64, value: Value, content: Value) -> Value {
json!({"id": id, "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(x, 0.0), "size": {"x": 300, "y": 160},
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": value}},
"children": [label(&format!("I{id};1:4"), "Title"), slot(&format!("I{id};1:5"), "Slot", content)]})
}
pub(crate) fn chip(id: &str) -> Value {
json!({"id": id, "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, "children": [
{"id": format!("I{id};1:10"), "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]})
}
fn by_rest<'d>(doc: &'d Document, v: &Value, rest: &str) -> &'d Node {
// Names repeat across copies: find by position in the file's tree instead.
fn path(v: &Value, rest: &str, out: &mut Vec<usize>) -> bool {
if v.get("id").and_then(Value::as_str) == Some(rest) {
return true;
}
for (i, c) in v.get("children").and_then(Value::as_array).into_iter().flatten().enumerate() {
out.push(i);
if path(c, rest, out) {
return true;
}
out.pop();
}
false
}
let mut p = Vec::new();
assert!(path(&v["document"]["children"][0], rest, &mut p), "no {rest} in the file");
let mut id = doc.pages[0];
for i in p {
id = doc.node(id).unwrap().children[i];
}
doc.node(id).unwrap()
}
fn names(doc: &Document, n: &Node) -> Vec<String> {
n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect()
}
#[test]
fn slot_layers_bind_to_their_property_and_keep_instance_content() {
let v = container_file(vec![
container("2:1", 400.0, content_id(7), json!([label("2:2", "Reports in progress"), chip("2:3")])),
container("3:1", 800.0, content_id(1), json!([])),
]);
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.iter().all(|w| !w.contains("SLOT") && !w.contains("slot")), "{warnings:?}");
let main = by_rest(&doc, &v, "1:2");
let NodeKind::Component { props, .. } = &main.kind else { panic!("{:?}", main.kind) };
assert_eq!(props.get("Slot"), Some(&PropDef::Slot { preferred: Vec::new() }), "named without its #id suffix");
let main_slot = by_rest(&doc, &v, "1:5");
assert!(matches!(main_slot.kind, NodeKind::Frame));
assert_eq!(main_slot.prop_refs.get("slot").map(String::as_str), Some("Slot"));
assert!(main_slot.children.is_empty(), "the main's slot stays empty");
let filled = by_rest(&doc, &v, "I2:1;1:5");
assert!(filled.overrides.contains("slot"), "the instance's own content");
assert!(filled.prop_refs.is_empty(), "an instance's slot follows its main's");
assert_eq!(names(&doc, filled), ["Reports in progress", "Chip"]);
let chip = by_rest(&doc, &v, "2:3");
assert!(matches!(chip.kind, NodeKind::Instance { .. }), "instances in slot content link to their own main");
let default = by_rest(&doc, &v, "I3:1;1:5");
assert!(!default.overrides.contains("slot"), "the main's default content follows the main");
}
#[test]
fn slot_ownership_without_values_follows_the_content() {
let mut owned = container("2:1", 400.0, json!(null), json!([label("2:2", "Own")]));
owned["componentProperties"] = json!({});
let mut empty = container("3:1", 800.0, json!(null), json!([]));
empty["componentProperties"] = json!({});
let v = container_file(vec![owned, empty]);
let (doc, _) = import(&v, &no_images).unwrap();
assert!(by_rest(&doc, &v, "I2:1;1:5").overrides.contains("slot"));
assert!(!by_rest(&doc, &v, "I3:1;1:5").overrides.contains("slot"));
}
#[test]
fn slots_match_by_reference_then_name_then_the_one_left() {
let comp = json!({"id": "1:2", "name": "Card", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 300},
"componentPropertyDefinitions": {
"Header#1:3": {"type": "SLOT", "defaultValue": content_id(1)},
"Body#1:4": {"type": "SLOT", "defaultValue": content_id(2)},
"Footer#1:5": {"type": "SLOT", "defaultValue": content_id(3)},
"Visible#1:6": {"type": "BOOLEAN", "defaultValue": true}
},
"children": [
{"id": "1:7", "name": "Top", "type": "SLOT", "componentPropertyReferences": {"visible": "Visible#1:6", "slot": "Header#1:3"},
"relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 50}},
{"id": "1:8", "name": "Body", "type": "SLOT", "relativeTransform": at(0.0, 50.0), "size": {"x": 300, "y": 50}},
{"id": "1:9", "name": "Bottom", "type": "SLOT", "relativeTransform": at(0.0, 100.0), "size": {"x": 300, "y": 50}}
]});
let v = file(json!([comp]));
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let bound = |rest: &str| by_rest(&doc, &v, rest).prop_refs.get("slot").cloned();
assert_eq!(bound("1:7").as_deref(), Some("Header"));
assert_eq!(by_rest(&doc, &v, "1:7").prop_refs.get("visible").map(String::as_str), Some("Visible"));
assert_eq!(bound("1:8").as_deref(), Some("Body"));
assert_eq!(bound("1:9").as_deref(), Some("Footer"));
}
#[test]
fn variants_share_their_set_slot_property() {
let variant = |id: &str, name: &str, x: f64| {
json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(x, 0.0), "size": {"x": 100, "y": 100},
"children": [slot(&format!("{id}s"), "Content", json!([]))]})
};
let set = json!({"id": "1:1", "name": "Panel", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 100},
"componentPropertyDefinitions": {"Size": {"type": "VARIANT", "defaultValue": "Small"}, "Content#2:1": {"type": "SLOT", "defaultValue": content_id(1)}},
"children": [variant("1:2", "Size=Small", 0.0), variant("1:3", "Size=Large", 150.0)]});
let inst = json!({"id": "4:1", "name": "Panel", "type": "INSTANCE", "componentId": "1:3", "relativeTransform": at(0.0, 300.0), "size": {"x": 100, "y": 100},
"componentProperties": {"Content#2:1": {"type": "SLOT", "value": content_id(9)}},
"children": [slot("I4:1;1:3s", "Content", json!([label("4:2", "Mine")]))]});
let v = file(json!([set, inst]));
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
for rest in ["1:2s", "1:3s"] {
assert_eq!(by_rest(&doc, &v, rest).prop_refs.get("slot").map(String::as_str), Some("Content"), "{rest}");
}
assert!(by_rest(&doc, &v, "I4:1;1:3s").overrides.contains("slot"));
}
#[test]
fn nested_instances_own_the_slots_of_their_main() {
// A "Screen" main places a Container whose slot holds a chip; an instance of Screen places
// other content in that nested slot.
let screen = json!({"id": "6:1", "name": "Screen", "type": "COMPONENT", "relativeTransform": at(0.0, 600.0), "size": {"x": 300, "y": 160},
"children": [container("6:2", 0.0, content_id(7), json!([chip("6:3")]))]});
let nested = container("I7:1;6:2", 0.0, content_id(8), json!([label("7:2", "Other")]));
let outer = json!({"id": "7:1", "name": "Screen", "type": "INSTANCE", "componentId": "6:1", "relativeTransform": at(400.0, 600.0), "size": {"x": 300, "y": 160},
"children": [nested]});
let v = container_file(vec![screen, outer]);
let (doc, _) = import(&v, &no_images).unwrap();
let in_main = by_rest(&doc, &v, "I6:2;1:5");
assert!(in_main.overrides.contains("slot") && in_main.prop_refs.is_empty());
assert!(matches!(by_rest(&doc, &v, "6:3").kind, NodeKind::Instance { .. }));
let in_outer = by_rest(&doc, &v, "II7:1;6:2;1:5");
assert!(in_outer.overrides.contains("slot"));
assert_eq!(names(&doc, in_outer), ["Other"]);
}
#[test]
fn slots_outside_components_or_without_a_property_stay_frames() {
let mut no_def = container_file(Vec::new());
no_def["document"]["children"][0]["children"][1].as_object_mut().unwrap().remove("componentPropertyDefinitions");
let loose = slot("8:1", "Loose", json!([label("8:2", "Kept")]));
no_def["document"]["children"][0]["children"].as_array_mut().unwrap().push(loose);
let (doc, warnings) = import(&no_def, &no_images).unwrap();
assert!(warnings.iter().any(|w| w.starts_with("slot layers without a matching slot property were imported as frames (×2)")), "{warnings:?}");
let kept = by_rest(&doc, &no_def, "8:1");
assert!(matches!(kept.kind, NodeKind::Frame) && kept.prop_refs.is_empty());
assert_eq!(names(&doc, kept), ["Kept"]);
assert!(by_rest(&doc, &no_def, "1:5").prop_refs.is_empty());
}
#[test]
fn slot_settings_the_editor_cannot_keep_are_noted() {
let mut v = container_file(Vec::new());
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
json!({"stretchChildOnInsert": true, "displayEmptyByDefault": true, "minChildren": null, "maxChildren": 3});
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["preferredValues"] =
json!([{"type": "COMPONENT", "key": "abc"}]);
let (_, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.iter().any(|w| w == "non-default slot settings are not represented and were skipped"), "{warnings:?}");
assert!(warnings.iter().any(|w| w == "preferred slot component keys cannot be resolved to file nodes and were skipped"), "{warnings:?}");
let mut plain = container_file(Vec::new());
plain["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
json!({"stretchChildOnInsert": true, "minChildren": null, "maxChildren": null, "allowPreferredValuesOnly": false});
assert!(import(&plain, &no_images).unwrap().1.is_empty());
}
#[test]
fn junk_slot_json_never_panics() {
for junk in [json!(null), json!(1), json!("x"), json!([]), json!({"guid": null}), json!({"type": "SLOT"})] {
let mut v = container_file(vec![container("2:1", 400.0, junk.clone(), json!([label("2:2", "A")]))]);
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"] =
json!({"type": "SLOT", "defaultValue": junk.clone(), "slotSettings": junk.clone(), "preferredValues": junk.clone()});
v["document"]["children"][0]["children"][1]["children"][1]["componentPropertyReferences"] = json!({"slot": junk});
assert!(import(&v, &no_images).is_ok());
}
}
+1
View File
@@ -1625,6 +1625,7 @@ name = "English"
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"
"slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content"
"slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped"
"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames"
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
+1
View File
@@ -1626,6 +1626,7 @@ status = "draft — needs native review"
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables"
"slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura"
"slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió"
"slot layers without a matching slot property were imported as frames" = "las capas de ranura sin una propiedad de ranura correspondiente se importaron como marcos"
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa"
"stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas"
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -19,7 +19,7 @@ and prototype interactions are still unchecked there.
|---|---|---|---|---|---|
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
@@ -165,6 +165,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged |
| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged |
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged |