mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* Add craft-figma-rest: a typed model of the Figma REST file JSON The neutral model for design files: the .fig and .sketch readers map onto it and an application's importer takes it. Typed records for the document tree, paints, effects, text styles, layout grids, constraints, export settings and the component and style metadata; every record keeps the fields it has no name for, so a file read and written loses nothing. The vectorNetwork extension is typed and documented with the others. The layer tree is converted level by level in both directions, and the depth and node limits are checked on the JSON before any typed value is built. * craft-figma-rest: Warnings and style_name for the readers that build this shape Moved from FigCraft's REST importer and .fig reader (crates/format/src/rest.rs Warnings, fig.rs style_name), which the .fig and Sketch mappings share. * craft-fig: map the message onto the REST file JSON FigCraft's .fig mapping (crates/format/src/fig/map and fig/text_path.rs at e8b53e33) moved onto craft-figma-rest: rest::to_rest turns a decoded message into the REST API file JSON, with the vector networks layers name by vectorNetworkRef handed over beside it. Connector routing and the text path measure check are the caller's geometry (rest::Geometry; rest::Straight when it has none). The plugin data key under which a writer keeps an outlined stroke is an option. The geometry blob is read with craft_fig::path instead of a second decoder. * craft-fig: an empty overriddenSymbolID is no swap The empty GUID (4294967295:4294967295) in overriddenSymbolID means "no swap". The mapping took it as the main, found no node and made the instance an empty frame; it now keeps the main its symbolData names, in the instance mapping and in the list of mains a file's instances use. Ported from storytold/figcraft#2208. * craft-fig: a swapped nested instance takes its new main's look A nested instance swapped to another main stores the previous main's look on its own node, so the mapping drew that. Each group of the look (fills, strokes, stroke settings, effects, corners) whose stored values are a copy of the previous main's now takes the new main's; a group the node or a row above it changes is kept, and a style link is decided by the topmost layer that names it. Ported from storytold/figcraft#2204 (the fig/map part; the warning's locale entry stays in the app). * craft-fig: a vector point without a corner radius of its own takes the layer's A point whose style sets no cornerRadius, or that has no style, takes the layer's cornerRadius, as the file draws it; a point's own value (an explicit 0 included) still wins. The layer's radius is part of the network cache key, so an instance overriding the radius doesn't share its main's network. Ported from storytold/figcraft#2115 (the fig/map part; its export change stays in the app). * craft-fig: map deep files within a 1 MiB stack The mapping recursed once per level of layer nesting in a frame of about 2 KB (an unoptimised build): 300 nested frames, or a chain of instances of mains holding each other, overflowed a 1 MiB stack before the 256 level limit could stop them, a crash on hostile input. The work on one layer now happens in layer_head and layer_tail, which return before and after its children are mapped, and what they hand over is boxed, so the recursive frame is about 800 bytes. An unoptimised build now maps past the limit on 512 KiB; the limit stays 256. The test at the limit runs on a 1 MiB thread, for frames and for instances, and overflowed before this change. --------- Co-authored-by: Trevor Brown <trevorbrown.dev@gmail.com> Co-authored-by: Giovanni Almeida <gioalmeida07@gmail.com>
334 lines
17 KiB
Rust
334 lines
17 KiB
Rust
use super::component::{assigned, prop_guid};
|
|
use super::paint::color;
|
|
use super::variables::variable_value;
|
|
use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows, swap_guid};
|
|
|
|
impl<'a> Mapper<'a> {
|
|
/// The DOCUMENT with its pages.
|
|
pub(super) fn document(&mut self) -> Value {
|
|
let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone());
|
|
let pages: Vec<String> = match &doc_id {
|
|
Some(d) => self.children(d),
|
|
None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(),
|
|
};
|
|
let mut internal = Vec::new();
|
|
let mut visible = Vec::new();
|
|
for p in pages {
|
|
let Some(&n) = self.nodes.get(&p) else { continue };
|
|
if n.get("type").and_then(Kv::str) != Some("CANVAS") {
|
|
self.warn("document children other than pages were skipped");
|
|
continue;
|
|
}
|
|
// The hidden page that holds styles, variables and library copies.
|
|
if n.get("internalOnly").and_then(Kv::bool) == Some(true) {
|
|
internal.push((p, n));
|
|
} else {
|
|
visible.push((p, n));
|
|
}
|
|
}
|
|
// The library copies the pages use come first: in a file past the layer cap, the pages'
|
|
// last layers are skipped rather than the mains every instance of them needs (#981).
|
|
let visible_pages: Vec<String> = visible.iter().map(|(p, _)| p.clone()).collect();
|
|
let required_mains = self.referenced_component_mains(&visible_pages);
|
|
// The pages and their top-level layers, up to half the cap, stay for the pages.
|
|
let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum();
|
|
self.reserved = top.min(self.max_nodes / 2);
|
|
for (id, canvas) in internal {
|
|
if let Some(context) = self.library_context(&id, canvas, &required_mains) {
|
|
self.library_contexts.push(context);
|
|
}
|
|
}
|
|
self.reserved = 0;
|
|
let out: Vec<Value> = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect();
|
|
if out.is_empty() {
|
|
self.warn("the file has no pages");
|
|
}
|
|
let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"});
|
|
doc["children"] = Value::Array(out);
|
|
doc
|
|
}
|
|
|
|
/// The ids of the components the visible pages use, directly or through the components they
|
|
/// use in turn (nested instances, swap targets, overrides naming another main).
|
|
fn referenced_component_mains(&mut self, visible_pages: &[String]) -> BTreeSet<String> {
|
|
let mut mains = BTreeSet::new();
|
|
let mut visited = BTreeSet::new();
|
|
let mut pending: Vec<(String, usize)> = visible_pages.iter().flat_map(|page| self.children(page)).map(|id| (id, 1)).collect();
|
|
while let Some((id, depth)) = pending.pop() {
|
|
if depth > MAX_DEPTH {
|
|
self.warn(format!("component references below {MAX_DEPTH} layer levels were skipped"));
|
|
continue;
|
|
}
|
|
if !visited.insert(id.clone()) {
|
|
continue;
|
|
}
|
|
if visited.len() > self.max_nodes {
|
|
let max = self.max_nodes;
|
|
self.warn(format!("the component reference scan reached the {max}-layer limit"));
|
|
break;
|
|
}
|
|
let Some(node) = self.nodes.get(&id) else { continue };
|
|
for target in self.component_main_targets(node) {
|
|
if mains.insert(target.clone()) {
|
|
pending.push((target, 1));
|
|
}
|
|
}
|
|
pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1)));
|
|
}
|
|
mains
|
|
}
|
|
|
|
/// The mains a layer names: an instance's own, the ones its overrides swap in, the instance-swap
|
|
/// defaults and values of component properties.
|
|
fn component_main_targets(&self, node: &Kv<'a>) -> Vec<String> {
|
|
let mut targets: Vec<String> = node
|
|
.get("componentPropDefs")
|
|
.map(Kv::list)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|definition| {
|
|
self.is_instance_swap(definition.get("id")?)?;
|
|
definition.get("initialValue")?.get("guidValue").and_then(guid)
|
|
})
|
|
.collect();
|
|
if node.get("type").and_then(Kv::str) != Some("INSTANCE") {
|
|
return targets;
|
|
}
|
|
// The instance and the override rows below it (rows carry the same fields, nested).
|
|
let mut pending: Vec<(&Kv<'a>, usize)> = vec![(node, 0)];
|
|
let mut rows = 0usize;
|
|
while let Some((row, depth)) = pending.pop() {
|
|
rows += 1;
|
|
if depth > MAX_DEPTH || rows > self.max_nodes {
|
|
break;
|
|
}
|
|
let data = row.get("symbolData");
|
|
targets.extend(row.get("overriddenSymbolID").and_then(swap_guid));
|
|
targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid));
|
|
for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() {
|
|
if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) {
|
|
targets.extend(assigned(assignment).and_then(prop_guid));
|
|
}
|
|
}
|
|
let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default();
|
|
let derived = row.get("derivedSymbolData").map(Kv::list).unwrap_or_default();
|
|
pending.extend(overrides.iter().chain(derived).map(|row| (row, depth + 1)));
|
|
}
|
|
targets
|
|
}
|
|
|
|
/// The branches of the internal canvas `id` that hold the `required_mains` (with the frames
|
|
/// they sit in), as a frame of its own: the cached definitions of components that come from a
|
|
/// library. `None` when it holds none.
|
|
fn library_context(&mut self, id: &str, canvas: &'a Kv<'a>, required_mains: &BTreeSet<String>) -> Option<Value> {
|
|
let mut included = BTreeSet::new();
|
|
let mut parents: BTreeMap<String, String> = BTreeMap::new();
|
|
// The canvas itself is never a layer of the walk, whatever the parent links say.
|
|
let mut visited = BTreeSet::from([id.to_string()]);
|
|
// Each entry: layer, depth, inside a required definition.
|
|
let mut pending: Vec<(String, usize, bool)> = self.children(id).into_iter().map(|child| (child, 1, false)).collect();
|
|
while let Some((node_id, depth, in_definition)) = pending.pop() {
|
|
if depth > MAX_DEPTH {
|
|
self.warn(format!("internal library branches deeper than {MAX_DEPTH} levels were skipped"));
|
|
continue;
|
|
}
|
|
if !visited.insert(node_id.clone()) {
|
|
continue;
|
|
}
|
|
self.library_scan_count += 1;
|
|
if self.library_scan_count > self.max_nodes {
|
|
let max = self.max_nodes;
|
|
self.warn(format!("the internal library scan reached the {max}-layer limit; the remaining branches were skipped"));
|
|
break;
|
|
}
|
|
let Some(node) = self.nodes.get(&node_id) else { continue };
|
|
let kind = node.get("type").and_then(Kv::str);
|
|
let definition = (kind == Some("SYMBOL") || (kind == Some("FRAME") && node.get("isStateGroup").and_then(Kv::bool) == Some(true)))
|
|
&& required_mains.contains(&node_id);
|
|
let in_definition = in_definition || definition;
|
|
if in_definition {
|
|
included.insert(node_id.clone());
|
|
}
|
|
if definition {
|
|
// The frames around it keep the path its layers are addressed by.
|
|
let mut ancestor = parents.get(&node_id);
|
|
for _ in 0..=MAX_DEPTH {
|
|
let Some(parent) = ancestor else { break };
|
|
if !included.insert(parent.clone()) {
|
|
break;
|
|
}
|
|
ancestor = parents.get(parent);
|
|
}
|
|
}
|
|
for child in self.children(&node_id).into_iter().rev() {
|
|
// A layer reached again keeps the parent it was first reached by, so the parents
|
|
// lead back to the canvas.
|
|
if visited.contains(&child) {
|
|
continue;
|
|
}
|
|
parents.entry(child.clone()).or_insert_with(|| node_id.clone());
|
|
pending.push((child, depth + 1, in_definition));
|
|
}
|
|
}
|
|
if included.is_empty() {
|
|
return None;
|
|
}
|
|
let mut context = Obj::new();
|
|
context.insert("id".into(), json!(id));
|
|
context.insert("type".into(), json!("FRAME"));
|
|
copy_str(&mut context, &View::of(canvas), "name", "name");
|
|
context.insert("size".into(), json!({"x": 0.0, "y": 0.0}));
|
|
context.insert("relativeTransform".into(), rows(Affine::IDENTITY));
|
|
context.insert(super::FIG_SOURCE_FIELD.into(), json!({"origin": id, "expanded": false, "wireFrame": true, "wireInstance": false}));
|
|
self.variable_bindings(&mut context, &View::of(canvas));
|
|
let mut children = Vec::new();
|
|
for child in self.children(id) {
|
|
children.extend(self.layer_filtered(&child, None, Affine::IDENTITY, None, None, 0, Some(&included)));
|
|
}
|
|
if !children.is_empty() {
|
|
context.insert("children".into(), Value::Array(children));
|
|
}
|
|
Some(Value::Object(context))
|
|
}
|
|
|
|
fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value {
|
|
let v = View::of(n);
|
|
let mut o = Obj::new();
|
|
o.insert("id".into(), json!(id));
|
|
o.insert("type".into(), json!("CANVAS"));
|
|
copy_str(&mut o, &v, "name", "name");
|
|
if let Some(c) = v.get("backgroundColor") {
|
|
o.insert("backgroundColor".into(), color(c));
|
|
}
|
|
if let Some(c) = v.get("prototypeBackgroundColor") {
|
|
o.insert("prototypeBackgrounds".into(), json!([color(c)]));
|
|
}
|
|
self.variable_bindings(&mut o, &v);
|
|
let mut flows: Vec<(&str, Value)> = Vec::new();
|
|
let mut kids = Vec::new();
|
|
for c in self.children(id) {
|
|
if let Some(sp) = self.nodes.get(&c).and_then(|k| k.get("prototypeStartingPoint")) {
|
|
let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow");
|
|
flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name})));
|
|
}
|
|
kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0));
|
|
}
|
|
flows.sort_by(|a, b| a.0.cmp(b.0));
|
|
if !flows.is_empty() {
|
|
o.insert("flowStartingPoints".into(), Value::Array(flows.into_iter().map(|f| f.1).collect()));
|
|
}
|
|
o.insert("children".into(), Value::Array(kids));
|
|
Value::Object(o)
|
|
}
|
|
|
|
/// The shared styles the file defines, in their order: each with the sample layer that holds
|
|
/// its values (`styleDefinitions`, our extension of the file JSON; the REST importer reads it).
|
|
pub(super) fn style_definitions(&mut self) -> Vec<Value> {
|
|
let mut found: Vec<(&'a str, String, &'a Kv<'a>)> = Vec::new();
|
|
for (id, n) in &self.nodes {
|
|
let live = n.get("isSoftDeleted").and_then(Kv::bool) != Some(true) && n.get("isSoftDeletedStyle").and_then(Kv::bool) != Some(true);
|
|
if live && n.get("styleType").and_then(Kv::str).is_some_and(|t| matches!(t, "FILL" | "TEXT" | "EFFECT" | "GRID")) {
|
|
found.push((n.get("sortPosition").and_then(Kv::str).unwrap_or(""), id.clone(), n));
|
|
}
|
|
}
|
|
found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
|
|
let mut out = Vec::new();
|
|
for (_, id, n) in found {
|
|
let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break };
|
|
let name = n.get("name").and_then(Kv::str).unwrap_or("Style");
|
|
let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or("");
|
|
let ty = n.get("styleType").and_then(Kv::str).unwrap_or("");
|
|
out.push(json!({"id": id, "name": name, "description": desc, "styleType": ty, "node": node}));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Collections and variables, as the local variables response's `meta`, with their ids in the
|
|
/// file's order (`collectionOrder` and `variableOrder`, our extensions).
|
|
pub(super) fn variables(&self) -> Option<Value> {
|
|
let of_type = |t: &'static str, key: &'static str| {
|
|
let live = move |n: &Kv| n.get("type").and_then(Kv::str) == Some(t) && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true);
|
|
let mut list: Vec<(&str, &String, &'a Kv<'a>)> = self
|
|
.nodes
|
|
.iter()
|
|
.filter(|(_, n)| live(n))
|
|
.map(|(id, n)| (n.get(key).and_then(|p| p.get("position").or(Some(p))).and_then(Kv::str).unwrap_or(""), id, *n))
|
|
.collect();
|
|
list.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
|
|
list.into_iter().map(|(_, id, n)| (id, n))
|
|
};
|
|
let mut order = Vec::new();
|
|
let mut collections = Obj::new();
|
|
// Copies of one library collection (one per version the file met) become one: the newest
|
|
// keeps its modes in its order, the others add the modes it no longer has.
|
|
let mut extra: BTreeMap<String, Vec<(String, Value)>> = BTreeMap::new();
|
|
for (id, n) in of_type("VARIABLE_SET", "parentIndex") {
|
|
let modes = sorted_modes(n);
|
|
match self.canonical_collection(id).filter(|c| c != id) {
|
|
Some(canonical) => extra.entry(canonical).or_default().extend(modes),
|
|
None => {
|
|
order.push(json!(id));
|
|
let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone());
|
|
let name = n.get("name").and_then(Kv::str).unwrap_or("Collection");
|
|
let modes: Vec<Value> = modes.into_iter().map(|m| m.1).collect();
|
|
collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default}));
|
|
}
|
|
}
|
|
}
|
|
for (canonical, more) in extra {
|
|
let Some(modes) = collections.get_mut(&canonical).and_then(|c| c.get_mut("modes")).and_then(Value::as_array_mut) else { continue };
|
|
for (_, m) in more {
|
|
if !modes.iter().any(|x| x["modeId"] == m["modeId"]) {
|
|
modes.push(m);
|
|
}
|
|
}
|
|
}
|
|
let mut variable_order = Vec::new();
|
|
let mut variables = Obj::new();
|
|
for (id, n) in of_type("VARIABLE", "sortPosition") {
|
|
if super::VarCanon::superseded(id) {
|
|
continue;
|
|
}
|
|
let Some(set) = n.get("variableSetID").and_then(|s| self.collection_id(s)).filter(|s| collections.contains_key(s)) else { continue };
|
|
let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or("");
|
|
if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") {
|
|
continue;
|
|
}
|
|
let mut values = Obj::new();
|
|
for e in n.get("variableDataValues").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
|
|
if let (Some(mode), Some(val)) = (e.get("modeID").and_then(guid), e.get("variableData").and_then(variable_value)) {
|
|
values.insert(mode, val);
|
|
}
|
|
}
|
|
let name = n.get("name").and_then(Kv::str).unwrap_or("Variable");
|
|
let desc = n.get("description").and_then(Kv::str).unwrap_or("");
|
|
let mut var =
|
|
json!({"id": id, "name": name, "variableCollectionId": set, "resolvedType": ty, "valuesByMode": values, "description": desc});
|
|
// The properties pickers offer the variable for, under the REST names.
|
|
if let Some(scopes) = n.get("variableScopes") {
|
|
var["scopes"] = scopes.list().iter().filter_map(Kv::str).map(|s| if s == "STROKE" { "STROKE_COLOR" } else { s }).collect();
|
|
}
|
|
variable_order.push(json!(id));
|
|
variables.insert(id.clone(), var);
|
|
}
|
|
(!collections.is_empty())
|
|
.then(|| json!({"variableCollections": collections, "variables": variables, "collectionOrder": order, "variableOrder": variable_order}))
|
|
}
|
|
}
|
|
|
|
/// A collection node's modes in their order (`sortPosition`), as `(position, {modeId, name})`.
|
|
fn sorted_modes(n: &Kv) -> Vec<(String, Value)> {
|
|
let mut modes: Vec<(String, Value)> = n
|
|
.get("variableSetModes")
|
|
.map(Kv::list)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|m| {
|
|
let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")});
|
|
Some((m.get("sortPosition").and_then(Kv::str).unwrap_or("").to_string(), mode))
|
|
})
|
|
.collect();
|
|
modes.sort_by(|a, b| a.0.cmp(&b.0));
|
|
modes
|
|
}
|