diff --git a/README.md b/README.md
index c98aab0..2523eee 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
π Offline and private
- Your files are plain .figcraft archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out.
+ Your files are plain .figcraft archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; designs exported as the public REST API file JSON import too.
|
π€ Agent-ready
diff --git a/ROADMAP.md b/ROADMAP.md
index ca3dd58..56e61ed 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -66,8 +66,12 @@ dimension measured side by side.
Custom bezier and custom spring easings with editor fields; device frames (phone, tablet,
laptop, desktop bodies drawn in code) around the presented frame. *Left:* overlay close
animation, multi-selection editing, scrolling inside device frames. 5β8 h.
-6. **Interop**: .fig import (best effort), PDF export (in progress), image crop handles (landed),
- paste from other apps, Figma REST JSON import. 25β40 h.
+6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import`
+ / CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry
+ paths, auto layout, constraints, grids, components, sets, instances with overrides, component
+ properties, styles; image fills from an `images` folder or placeholders; warnings for the rest).
+ *Left:* .fig import (best effort), PDF export (in progress), paste from other apps, REST import
+ of prototype interactions and variables. 20β32 h.
7. **Look & feel and interaction-fidelity pass**: tool by tool, panel by panel against the incumbent
(cursors, modifier keys, hover states, menus, popovers). 40β60 h.
8. **Collaboration (local):** comments landed (pins, threads, resolve, panel). Version history UI,
@@ -137,7 +141,7 @@ Scores by behaviour, 0β100%. Hours are what remains (one agent).
| Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12β18 |
| Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6β10 |
| Prototyping | 6 | 60% | overlay close animation, multi-selection editing, scrolling inside device frames | 6β10 |
-| Import/export | 6 | 45% | PDF (in progress), .fig import, export panel polish | 20β35 |
+| Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18β32 |
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6β10 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15β25 |
diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs
index 4b93778..a540e1c 100644
--- a/apps/figcraft-cli/src/files.rs
+++ b/apps/figcraft-cli/src/files.rs
@@ -10,7 +10,12 @@ use serde_json::{Value, json};
use crate::{CliError, CliResult};
pub fn load(path: &str) -> CliResult {
- figcraft_engine::cmd::file::load(path).map(|(d, _)| d).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))
+ load_noted(path).map(|(d, _)| d)
+}
+
+/// The document with the loader's notes (repairs of a damaged file, or import warnings).
+fn load_noted(path: &str) -> CliResult<(Document, Vec)> {
+ figcraft_engine::cmd::file::load(path).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))
}
fn ext(path: &str) -> String {
@@ -112,7 +117,7 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
}
pub fn info(path: &str, as_json: bool) -> CliResult {
- let doc = load(path)?;
+ let (doc, notes) = load_noted(path)?;
let pages: Vec = doc
.pages
.iter()
@@ -137,11 +142,15 @@ pub fn info(path: &str, as_json: bool) -> CliResult {
"styles": doc.styles.len(),
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
+ "notes": notes,
});
if as_json {
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
return Ok(ExitCode::SUCCESS);
}
+ for n in ¬es {
+ eprintln!("note: {n}");
+ }
println!(
"{} β {} ({} nodes, {} styles, {} variables, {} images)",
path,
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index f720673..b9fd03e 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -18,7 +18,13 @@ pub fn specs() -> Vec {
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}),
- spec!("file.open", "Open", "{path} (.figcraft, .json, .svg, .png, .jpg β¦)", false, open),
+ spec!(
+ "file.open",
+ "Open",
+ "{path} (.figcraft, .json: FigCraft or REST API file JSON, .svg, .png, .jpg β¦); REST imports return `warnings`",
+ false,
+ open
+ ),
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
spec!("file.saveAs", "Save as", "{path}", false, save_as),
spec!(
@@ -45,7 +51,7 @@ pub fn specs() -> Vec {
spec!(
"file.import",
"Import",
- "{path | data (base64) + name, x?, y?} SVG becomes vectors, images become image layers, .figcraft pages are appended",
+ "{path | data (base64) + name, x?, y?} SVG becomes vectors, images become image layers, .figcraft and REST API file JSON pages are appended (REST: with `warnings`)",
true,
import
),
@@ -179,19 +185,31 @@ fn file_stem(path: &str) -> String {
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
}
-/// Load a document from any supported file.
+/// Load a document from any supported file (REST API file JSON images come from an `images`
+/// folder next to the file).
pub fn load(path: &str) -> Result<(Document, Vec)> {
- let bytes = read(path)?;
- load_bytes(&bytes, path)
+ load_path(path).map(|(doc, notes, _)| (doc, notes))
}
pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> {
+ load_with(bytes, name_hint, &figcraft_format::rest::no_images).map(|(doc, notes, _)| (doc, notes))
+}
+
+/// [`load`], also telling whether the file was REST API file JSON.
+pub fn load_path(path: &str) -> Result<(Document, Vec, bool)> {
+ let bytes = read(path)?;
+ load_with(&bytes, path, &rest_images(path))
+}
+
+/// Load from bytes. Returns the document, notes (repairs of a native file, or import warnings) and
+/// whether it was imported from REST API file JSON (such a file is never saved over).
+fn load_with(bytes: &[u8], name_hint: &str, images: figcraft_format::rest::ImageResolver) -> Result<(Document, Vec, bool)> {
match ext(name_hint).as_str() {
"svg" => {
let mut doc = Document::new(file_stem(name_hint));
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
figcraft_svg::import_svg(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)).map_err(|e| EngineError::Other(e.to_string()))?;
- Ok((doc, vec![]))
+ Ok((doc, vec![], false))
}
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => {
let mut doc = Document::new(file_stem(name_hint));
@@ -199,18 +217,62 @@ pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec {
+ if let Some(r) = figcraft_format::rest::try_import(bytes, images) {
+ let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
+ link_instances(&mut doc);
+ return Ok((doc, warnings, true));
+ }
let (mut doc, issues) = figcraft_format::load_bytes(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
if doc.name.is_empty() {
doc.name = file_stem(name_hint);
}
- Ok((doc, issues))
+ Ok((doc, issues, false))
}
}
}
+/// Link imported instances to their mains (layer sources, overrides found by comparing values),
+/// innermost first so outer instances settle last.
+fn link_instances(doc: &mut Document) {
+ let mut insts: Vec<(usize, NodeId)> =
+ doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
+ insts.sort_by_key(|a| std::cmp::Reverse(a.0));
+ for (_, i) in insts {
+ crate::components::rebase_instance(doc, i);
+ }
+}
+
+/// Largest image file read for a REST API file JSON import.
+#[cfg(not(target_arch = "wasm32"))]
+const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024;
+
+/// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|β¦]` next to
+/// the file.
+#[cfg(not(target_arch = "wasm32"))]
+fn rest_images(path: &str) -> impl Fn(&str) -> Option> {
+ let dir = std::path::Path::new(path).parent().map(|d| d.join("images"));
+ move |r: &str| {
+ // Image refs are content hashes; anything else never becomes a path.
+ if r.is_empty() || r.len() > 128 || !r.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
+ return None;
+ }
+ let dir = dir.as_ref()?;
+ ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| {
+ let p = dir.join(format!("{r}{e}"));
+ let meta = std::fs::metadata(&p).ok()?;
+ if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None }
+ })
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+fn rest_images(_path: &str) -> impl Fn(&str) -> Option> {
+ figcraft_format::rest::no_images
+}
+
fn open(s: &mut Session, p: &Value) -> Result {
let path = req_string("file.open", p, "path")?;
// Already open: switch to it.
@@ -218,11 +280,13 @@ fn open(s: &mut Session, p: &Value) -> Result {
s.active = i;
return Ok(json!({"index": i}));
}
- let (doc, issues) = load(&path)?;
- let native = matches!(ext(&path).as_str(), "figcraft" | "json");
+ let (doc, notes, rest) = load_path(&path)?;
+ let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
s.docs.push(DocState::new(doc, native.then_some(path)));
s.active = s.docs.len() - 1;
- Ok(json!({"index": s.active, "repairs": issues}))
+ let mut out = json!({"index": s.active});
+ out[if rest { "warnings" } else { "repairs" }] = json!(notes);
+ Ok(out)
}
/// Thumbnail PNG of the first page's content (for file browsers / the home screen).
@@ -375,8 +439,9 @@ fn export_frames_pdf(s: &mut Session, p: &Value) -> Result {
}
fn import(s: &mut Session, p: &Value) -> Result {
- let (bytes, name) = if let Some(path) = string(p, "path") {
- (read(&path)?, path)
+ let path = string(p, "path");
+ let (bytes, name) = if let Some(path) = &path {
+ (read(path)?, path.clone())
} else if let Some(d) = string(p, "data") {
let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d);
(
@@ -398,26 +463,18 @@ fn import(s: &mut Session, p: &Value) -> Result {
Ok(json!({"id": id}))
}
"figcraft" | "json" => {
- let (other, _) = figcraft_format::load_bytes(&bytes).map_err(|e| EngineError::Other(e.to_string()))?;
- let doc = s.doc_mut()?;
- for (k, v) in &other.images {
- doc.images.entry(k.clone()).or_insert_with(|| v.clone());
+ let resolver = path.as_deref().map(rest_images);
+ let images: figcraft_format::rest::ImageResolver = match &resolver {
+ Some(r) => r,
+ None => &figcraft_format::rest::no_images,
+ };
+ let (other, notes, rest) = load_with(&bytes, &name, images)?;
+ let pages = append_pages(s.doc_mut()?, &other)?;
+ let mut out = json!({"pages": pages});
+ if rest {
+ out["warnings"] = json!(notes);
}
- let mut new_pages = Vec::new();
- for pg in &other.pages {
- let Some(pn) = other.node(*pg) else { continue };
- let np = doc.add_page(&pn.name);
- for c in pn.children.clone() {
- let nodes: Vec = other.subtree(c).into_iter().filter_map(|i| other.node(i).cloned()).collect();
- let mut map = std::collections::BTreeMap::new();
- for n in &nodes {
- map.insert(n.id, doc.alloc_id());
- }
- doc.paste_nodes(&nodes, &map, c, np, None)?;
- }
- new_pages.push(np);
- }
- Ok(json!({"pages": new_pages}))
+ Ok(out)
}
_ => {
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
@@ -426,6 +483,47 @@ fn import(s: &mut Session, p: &Value) -> Result {
}
}
+/// Append `other`'s pages (with their backgrounds, its images and styles) to `doc`. Ids are
+/// remapped across all pages at once, so instances keep their main and instance layers their
+/// source wherever those are.
+fn append_pages(doc: &mut Document, other: &Document) -> Result> {
+ for (k, v) in &other.images {
+ doc.images.entry(k.clone()).or_insert_with(|| v.clone());
+ }
+ for st in &other.styles {
+ if !doc.styles.iter().any(|s| s.id == st.id) {
+ doc.styles.push(st.clone());
+ }
+ }
+ let mut map = std::collections::BTreeMap::new();
+ for pg in &other.pages {
+ for id in other.subtree(*pg).into_iter().skip(1) {
+ map.insert(id, doc.alloc_id());
+ }
+ }
+ let mut new_pages = Vec::new();
+ for pg in &other.pages {
+ let Some(pn) = other.node(*pg) else { continue };
+ let np = doc.add_page(&pn.name);
+ if let (NodeKind::Page { background: from, .. }, Some(NodeKind::Page { background, .. })) = (&pn.kind, doc.node_mut(np).map(|n| &mut n.kind))
+ {
+ *background = *from;
+ }
+ for c in &pn.children {
+ let nodes: Vec = other.subtree(*c).into_iter().filter_map(|i| other.node(i).cloned()).collect();
+ doc.paste_nodes(&nodes, &map, *c, np, None)?;
+ }
+ new_pages.push(np);
+ }
+ for new in map.values() {
+ let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied());
+ if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) {
+ n.source = Some(src);
+ }
+ }
+ Ok(new_pages)
+}
+
fn close(s: &mut Session, p: &Value) -> Result {
let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active);
let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) };
@@ -439,3 +537,93 @@ fn close(s: &mut Session, p: &Value) -> Result {
}
ok()
}
+
+#[cfg(test)]
+mod rest_import_tests {
+ use super::*;
+
+ /// A synthetic REST API file JSON: a component with a text layer, an instance of it whose text
+ /// is overridden, and an image fill.
+ fn kit() -> Value {
+ let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]);
+ let label = |id: &str, text: &str| {
+ json!({"id": id, "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 40, "y": 20}, "characters": text,
+ "style": {"fontFamily": "Inter", "fontSize": 14}, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]})
+ };
+ json!({"name": "Kit", "document": {"id": "0:0", "type": "DOCUMENT", "children": [
+ {"id": "0:1", "name": "Main", "type": "CANVAS", "backgroundColor": {"r": 0.9, "g": 0.9, "b": 0.9, "a": 1}, "children": [
+ {"id": "1:1", "name": "Button", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 40},
+ "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("1:2", "OK")]},
+ {"id": "2:1", "name": "Button", "type": "INSTANCE", "componentId": "1:1", "relativeTransform": at(0.0, 100.0), "size": {"x": 100, "y": 40},
+ "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("I2:1;1:2", "Go")]},
+ {"id": "3:1", "name": "Photo", "type": "RECTANGLE", "relativeTransform": at(200.0, 0.0), "size": {"x": 30, "y": 20},
+ "fills": [{"type": "IMAGE", "imageRef": "photo1", "scaleMode": "FILL"}]}
+ ]}
+ ]}, "components": {"1:1": {"key": "k", "name": "Button", "description": ""}}, "styles": {}})
+ }
+
+ fn instance(doc: &Document) -> &Node {
+ doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap()
+ }
+
+ #[test]
+ fn rest_json_loads_with_linked_instances() {
+ let bytes = serde_json::to_vec(&kit()).unwrap();
+ let (doc, warnings) = load_bytes(&bytes, "kit.json").unwrap();
+ assert_eq!(doc.name, "Kit");
+ assert!(warnings.iter().any(|w| w.contains("placeholder")), "{warnings:?}");
+ let inst = instance(&doc);
+ let NodeKind::Instance { component, .. } = inst.kind else { panic!() };
+ assert_eq!(inst.source, Some(component));
+ let main_label = doc.node(component).unwrap().children[0];
+ let label = doc.node(inst.children[0]).unwrap();
+ assert_eq!(label.source, Some(main_label));
+ assert!(label.overrides.contains("characters"), "{:?}", label.overrides);
+ assert!(crate::components::mirror_issues(&doc).is_empty());
+ assert!(thumbnail(&doc).is_some());
+ }
+
+ #[test]
+ fn open_and_import_rest_json() {
+ let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id()));
+ std::fs::create_dir_all(dir.join("images")).unwrap();
+ let png = {
+ let mut d = Document::new("x");
+ let p = d.first_page().unwrap();
+ let id = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 3.0, 2.0).unwrap();
+ export_bytes(&d, &[id], ExportFormat::Png, 1.0).unwrap()
+ };
+ std::fs::write(dir.join("images").join("photo1.png"), &png).unwrap();
+ let path = dir.join("kit.json");
+ std::fs::write(&path, serde_json::to_vec(&kit()).unwrap()).unwrap();
+ let path = path.to_string_lossy().to_string();
+
+ let mut s = Session::new();
+ let r = s.execute("file.open", &json!({"path": path})).unwrap();
+ assert!(r["warnings"].as_array().is_some_and(|w| w.iter().all(|w| !w.as_str().unwrap_or("").contains("placeholder"))), "{r}");
+ assert!(r.get("repairs").is_none());
+ assert_eq!(s.ds().unwrap().path, None, "an import is never saved over its JSON");
+ let doc = s.doc().unwrap();
+ let photo = doc.nodes().find(|n| n.name == "Photo").unwrap();
+ let figcraft_color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!() };
+ assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((3, 2)), "read from images/ next to the file");
+
+ // Import into another document: pages are appended and references follow the new ids.
+ let mut s = Session::new();
+ let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&kit()).unwrap());
+ let r = s.execute("file.import", &json!({"data": data, "name": "kit.json"})).unwrap();
+ assert_eq!(r["pages"].as_array().map(Vec::len), Some(1));
+ assert!(r["warnings"].is_array(), "{r}");
+ let doc = s.doc().unwrap();
+ assert_eq!(doc.pages.len(), 2);
+ let page = doc.node(doc.pages[1]).unwrap();
+ assert!(matches!(page.kind, NodeKind::Page { background, .. } if (background.r - 0.9).abs() < 1e-6));
+ let inst = instance(doc);
+ let NodeKind::Instance { component, .. } = inst.kind else { panic!() };
+ assert!(doc.node(component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })));
+ let label = doc.node(inst.children[0]).unwrap();
+ assert!(label.source.is_some_and(|src| doc.is_ancestor(component, src)));
+ assert!(crate::components::mirror_issues(doc).is_empty());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml
index d4e9f10..d8627e8 100644
--- a/crates/format/Cargo.toml
+++ b/crates/format/Cargo.toml
@@ -3,7 +3,7 @@ name = "figcraft-format"
version.workspace = true
edition.workspace = true
license.workspace = true
-description = "FigCraft native file format (.figcraft: zip of document.json + images) and image import helpers"
+description = "FigCraft native file format (.figcraft: zip of document.json + images), image import helpers and REST API file JSON import"
[lints]
workspace = true
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 01f495d..7cd4e7b 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -4,8 +4,12 @@
//! header), `images/` for every image the document uses, and an optional
//! `thumbnail.png`. Plain JSON (the `document.json` alone) also opens. Reading is lenient:
//! structural problems are repaired (see [`Document::repair`]) and reported.
+//!
+//! [`rest`] imports the public REST API file JSON.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+pub mod rest;
+
use std::io::{Read, Write};
use std::sync::Arc;
diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs
new file mode 100644
index 0000000..e355fe0
--- /dev/null
+++ b/crates/format/src/rest/mod.rs
@@ -0,0 +1,873 @@
+//! Import of the public REST API file JSON: the response of `GET /v1/files/:key` (a `document`
+//! with `CANVAS` pages, plus `components`, `componentSets` and `styles` metadata) and of
+//! `GET /v1/files/:key/nodes` (a `nodes` map of such entries).
+//!
+//! The mapping follows the published REST API reference only. Pages, the layer tree, geometry
+//! (`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).
+//! Anything unknown is skipped and reported in the warning list; malformed input never panics.
+//!
+//! Instances come out with their own layers as the file lists them. Linking those layers to the
+//! main component's (`source`) and deriving overrides is left to the caller (the engine does it
+//! after loading), since that needs the component model.
+
+mod props;
+#[cfg(test)]
+mod tests;
+mod text;
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::Arc;
+
+use figcraft_doc::geom::{Affine, BezPath, Rect, Shape};
+use figcraft_doc::proto::Flow;
+use figcraft_doc::{BoolOp, Document, ImageData, LayoutMode, Node, NodeId, NodeKind, PropDef, Style, StyleKind, VectorNetwork, WindingRule};
+use serde_json::Value;
+
+use crate::FormatError;
+use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text};
+
+/// Most layers imported from one file.
+pub const MAX_NODES: usize = 200_000;
+/// Deepest layer nesting followed.
+const MAX_DEPTH: usize = 256;
+/// Longest SVG path string read from one geometry entry.
+const MAX_PATH_BYTES: usize = 4 << 20;
+/// Most path elements in one vector.
+const MAX_PATH_ELEMENTS: usize = 200_000;
+/// Most distinct warnings reported.
+const MAX_WARNINGS: usize = 200;
+/// Image key used for image fills without an `imageRef`.
+const MISSING_IMAGE: &str = "rest-image-missing";
+
+/// Image bytes for an `imageRef` (the REST API serves image fills separately).
+pub type ImageResolver<'a> = &'a dyn Fn(&str) -> Option>;
+
+/// An [`ImageResolver`] for when no image files are at hand.
+pub fn no_images(_: &str) -> Option> {
+ None
+}
+
+/// Whether `v` is a REST API file response or a nodes response.
+pub fn is_rest_json(v: &Value) -> bool {
+ is_document(v) || nodes_entries(v).next().is_some()
+}
+
+fn is_document(entry: &Value) -> bool {
+ entry.get("document").and_then(|d| text(d, "type")) == Some("DOCUMENT")
+}
+
+/// Entries of a nodes response (`{"nodes": {"1:2": {"document": β¦, "components": β¦}}}`).
+fn nodes_entries(v: &Value) -> impl Iterator- {
+ v.get("nodes")
+ .and_then(Value::as_object)
+ .into_iter()
+ .flat_map(|m| m.values())
+ .filter(|e| e.get("document").and_then(|d| text(d, "type")).is_some())
+}
+
+/// Import `bytes` when they are REST API file JSON; `None` when they are something else (the
+/// caller then tries its other formats).
+pub fn try_import(bytes: &[u8], images: ImageResolver) -> Option), FormatError>> {
+ let start = bytes.iter().position(|b| !b.is_ascii_whitespace())?;
+ if bytes.get(start) != Some(&b'{') {
+ return None;
+ }
+ let src = std::str::from_utf8(bytes).ok()?;
+ if !src.contains("\"document\"") {
+ return None;
+ }
+ match serde_json::from_str::(src) {
+ Ok(v) => is_rest_json(&v).then(|| import(&v, images)),
+ // Broken JSON that names a DOCUMENT root: report it as such.
+ Err(e) => src.contains("\"DOCUMENT\"").then(|| Err(FormatError::Corrupt(format!("REST API file JSON: {e}")))),
+ }
+}
+
+/// Build a document from a parsed REST API file (or nodes) response. Returns it with the
+/// warnings about what was skipped or approximated.
+pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec), FormatError> {
+ let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() };
+ if entries.is_empty() {
+ return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into()));
+ }
+ let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str);
+ let mut cx = Ctx::new(Document::new(name), images);
+ let blank = cx.doc.first_page();
+ let mut loose: Option = None;
+ for e in &entries {
+ cx.collect_meta(e);
+ }
+ for e in entries {
+ let Some(root) = e.get("document") else { continue };
+ match text(root, "type") {
+ Some("DOCUMENT") => {
+ for c in children(root) {
+ if text(c, "type") == Some("CANVAS") {
+ cx.page(c);
+ } else {
+ cx.warn("document children other than pages were skipped");
+ }
+ }
+ }
+ Some("CANVAS") => cx.page(root),
+ // A nodes response for layers: they share one page, placed where they are.
+ Some(_) => {
+ let page = *loose.get_or_insert_with(|| cx.doc.add_page("Imported layers"));
+ cx.build(root, page, Space::default(), None, 0, true);
+ }
+ None => cx.warn("entries without a node type were skipped"),
+ }
+ }
+ // Drop the empty page the new document started with once real pages exist.
+ if let Some(p) = blank
+ && cx.doc.pages.len() > 1
+ {
+ let _ = cx.doc.remove(p);
+ }
+ cx.doc.current_page = cx.doc.first_page();
+ cx.finish();
+ let warnings = cx.warnings.finish();
+ Ok((cx.doc, warnings))
+}
+
+fn children(v: &Value) -> &[Value] {
+ v.get("children").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default()
+}
+
+/// Distinct warnings in first-seen order, with repeat counts.
+#[derive(Default)]
+struct Warnings {
+ list: Vec<(String, usize)>,
+ index: BTreeMap,
+}
+
+impl Warnings {
+ fn add(&mut self, msg: String) {
+ if let Some(i) = self.index.get(&msg) {
+ if let Some(e) = self.list.get_mut(*i) {
+ e.1 += 1;
+ }
+ } else if self.list.len() < MAX_WARNINGS {
+ self.index.insert(msg.clone(), self.list.len());
+ self.list.push((msg, 1));
+ }
+ }
+
+ fn finish(self) -> Vec {
+ self.list.into_iter().map(|(m, n)| if n > 1 { format!("{m} (Γ{n})") } else { m }).collect()
+ }
+}
+
+/// The coordinate spaces a child is placed in: its parent's (`world`) and that of the nearest
+/// ancestor that is not a group or boolean (`frame`), both relative to the page.
+#[derive(Clone, Copy)]
+struct Space {
+ world: Affine,
+ frame: Affine,
+}
+
+impl Default for Space {
+ fn default() -> Self {
+ Self { world: Affine::IDENTITY, frame: Affine::IDENTITY }
+ }
+}
+
+pub(crate) struct Ctx<'a> {
+ doc: Document,
+ images: ImageResolver<'a>,
+ /// `imageRef` β key in `doc.images`.
+ image_keys: BTreeMap,
+ missing_images: usize,
+ warnings: Warnings,
+ count: usize,
+ /// REST node id β imported node.
+ ids: BTreeMap,
+ component_meta: BTreeMap<&'a str, &'a Value>,
+ set_meta: BTreeMap<&'a str, &'a Value>,
+ style_meta: BTreeMap<&'a str, &'a Value>,
+ /// Instances (imported as frames until their main is resolved) with their JSON.
+ instances: Vec<(NodeId, &'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.
+ set_defs: BTreeMap>,
+ /// Prototype starting points: (page, name, REST node id).
+ flows: Vec<(NodeId, String, String)>,
+}
+
+impl<'a> Ctx<'a> {
+ fn new(doc: Document, images: ImageResolver<'a>) -> Self {
+ Self {
+ doc,
+ images,
+ image_keys: BTreeMap::new(),
+ missing_images: 0,
+ warnings: Warnings::default(),
+ count: 0,
+ ids: BTreeMap::new(),
+ component_meta: BTreeMap::new(),
+ set_meta: BTreeMap::new(),
+ style_meta: BTreeMap::new(),
+ instances: Vec::new(),
+ swap_defaults: Vec::new(),
+ set_defs: BTreeMap::new(),
+ flows: Vec::new(),
+ }
+ }
+
+ pub(crate) fn warn(&mut self, msg: impl Into) {
+ self.warnings.add(msg.into());
+ }
+
+ fn collect_meta(&mut self, e: &'a Value) {
+ for (key, into) in [("components", &mut self.component_meta), ("componentSets", &mut self.set_meta), ("styles", &mut self.style_meta)] {
+ for (k, v) in e.get(key).and_then(Value::as_object).into_iter().flatten() {
+ into.entry(k.as_str()).or_insert(v);
+ }
+ }
+ }
+
+ /// A CANVAS β a page.
+ fn page(&mut self, c: &'a Value) {
+ let page = self.doc.add_page(text(c, "name").map_or("Page", clip_str));
+ if let Some(bg) = c.get("backgroundColor").and_then(props::color)
+ && let Some(NodeKind::Page { background, .. }) = self.doc.node_mut(page).map(|n| &mut n.kind)
+ {
+ *background = bg;
+ }
+ for f in list(c, "flowStartingPoints") {
+ if let Some(start) = text(f, "nodeId") {
+ self.flows.push((page, clip_str(text(f, "name").unwrap_or("Flow")).to_string(), start.to_string()));
+ }
+ }
+ for ch in children(c) {
+ self.build(ch, page, Space::default(), None, 0, false);
+ }
+ }
+
+ /// Import layer `j` (and its subtree) under `parent`. `root_abs`: place it by its absolute
+ /// bounds (roots of a nodes response, whose parent isn't in the file).
+ fn build(&mut self, j: &'a Value, parent: NodeId, space: Space, parent_mode: Option, depth: usize, root_abs: bool) {
+ if depth > MAX_DEPTH {
+ self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
+ return;
+ }
+ if self.count >= MAX_NODES {
+ self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped"));
+ return;
+ }
+ let Some(ty) = text(j, "type") else {
+ self.warn("layers without a type were skipped");
+ return;
+ };
+ let Some(kind) = self.kind(ty, j) else {
+ self.warn(format!("{} layers are not supported and were skipped", clip_str(ty)));
+ return;
+ };
+ let (world, w, h) = self.placement(j, space, root_abs);
+ let id = self.doc.alloc_id();
+ let label = kind.label();
+ let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h);
+ n.transform = local(space.world, world);
+ self.common(&mut n, j, parent_mode);
+ let (container, follows, mode) = (n.kind.is_container(), n.kind.bounds_follow_children(), n.layout.as_ref().map(|l| l.mode));
+ if self.doc.insert(n, parent, None).is_err() {
+ return;
+ }
+ self.count += 1;
+ if let Some(rid) = text(j, "id") {
+ self.ids.entry(rid.to_string()).or_insert(id);
+ }
+ if ty == "INSTANCE" {
+ self.instances.push((id, j));
+ }
+ let kids = children(j);
+ if kids.is_empty() {
+ return;
+ }
+ if !container {
+ self.warn(format!("children of {} layers were skipped", clip_str(ty)));
+ return;
+ }
+ let inner = Space { world, frame: if follows { space.frame } else { world } };
+ for c in kids {
+ self.build(c, id, inner, mode, depth + 1, false);
+ }
+ if follows {
+ let _ = self.doc.fit_to_children(id);
+ }
+ }
+
+ fn kind(&mut self, ty: &str, j: &'a Value) -> Option {
+ 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,
+ "GROUP" => NodeKind::Group,
+ "SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) },
+ "RECTANGLE" => NodeKind::Rectangle,
+ "LINE" => NodeKind::Line,
+ "ELLIPSE" => {
+ let a = j.get("arcData");
+ let arc = |k: &str| a.and_then(|a| num(a, k));
+ let start = arc("startingAngle").unwrap_or(0.0).clamp(-1e3, 1e3);
+ let end = arc("endingAngle").map_or(start + std::f64::consts::TAU, |e| e.clamp(-1e3, 1e3));
+ let tau = std::f64::consts::TAU;
+ NodeKind::Ellipse { start, sweep: (end - start).clamp(-tau, tau), inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0) }
+ }
+ // Point counts aren't part of the documented JSON: the outline is exact when present.
+ "REGULAR_POLYGON" | "STAR" if point_count().is_none() && has_geometry => NodeKind::Vector { network: VectorNetwork::default() },
+ "REGULAR_POLYGON" => NodeKind::Polygon { count: point_count().unwrap_or_else(|| self.default_count(3)) },
+ "STAR" => NodeKind::Star {
+ count: point_count().unwrap_or_else(|| self.default_count(5)),
+ inner_ratio: num(j, "innerRadius").map_or(0.382, |r| r.clamp(0.01, 1.0)),
+ },
+ "VECTOR" => NodeKind::Vector { network: VectorNetwork::default() },
+ "BOOLEAN_OPERATION" => NodeKind::BooleanOp {
+ op: match text(j, "booleanOperation") {
+ Some("SUBTRACT") => BoolOp::Subtract,
+ Some("INTERSECT") => BoolOp::Intersect,
+ Some("EXCLUDE") => BoolOp::Exclude,
+ _ => BoolOp::Union,
+ },
+ },
+ "TEXT" => NodeKind::Text(text::text_data(self, j)),
+ "COMPONENT" => NodeKind::Component { description: description(&self.component_meta, j), props: BTreeMap::new() },
+ "COMPONENT_SET" => NodeKind::ComponentSet { description: description(&self.set_meta, j) },
+ "SLICE" => NodeKind::Slice,
+ _ => return None,
+ })
+ }
+
+ fn default_count(&mut self, count: u32) -> u32 {
+ self.warn("stars and polygons without point counts or geometry use the default point count");
+ count
+ }
+
+ /// The layer's transform relative to the page and its size.
+ fn placement(&mut self, j: &Value, space: Space, root_abs: bool) -> (Affine, f64, f64) {
+ let bbox = j.get("absoluteBoundingBox").and_then(rect);
+ let rot = num(j, "rotation").unwrap_or(0.0).clamp(-1e3, 1e3);
+ let (w, h) = j
+ .get("size")
+ .and_then(props::vec2)
+ .map(|(x, y)| (x.clamp(0.0, MAX_SIZE), y.clamp(0.0, MAX_SIZE)))
+ .or_else(|| bbox.map(|b| unrotated_size(b, rot)))
+ .unwrap_or((0.0, 0.0));
+ let box_rect = Rect::new(0.0, 0.0, w, h);
+ // Rotation is counter-clockwise on screen (y down), about the box centre.
+ let from_bbox = |b: Rect| Affine::translate(b.center().to_vec2()) * Affine::rotate(-rot) * Affine::translate((-w / 2.0, -h / 2.0));
+ let world = match (j.get("relativeTransform").and_then(props::transform), bbox) {
+ (Some(_), Some(b)) if root_abs => from_bbox(b),
+ (Some(rel), Some(b)) => {
+ // Children of groups and booleans are placed in the enclosing frame's space; the
+ // absolute bounds settle which space the file used.
+ let err = |xf: Affine| {
+ let r = figcraft_doc::geom::transform_rect_bbox(xf, box_rect);
+ (r.x0 - b.x0).abs() + (r.y0 - b.y0).abs() + (r.x1 - b.x1).abs() + (r.y1 - b.y1).abs()
+ };
+ let (a, c) = (space.frame * rel, space.world * rel);
+ if err(c) < err(a) { c } else { a }
+ }
+ (Some(rel), None) => space.frame * rel,
+ (None, Some(b)) => from_bbox(b),
+ (None, None) => {
+ self.warn("layers without a position were placed at their parent's origin");
+ space.world
+ }
+ };
+ (world, w, h)
+ }
+
+ /// Properties shared by every layer type.
+ fn common(&mut self, n: &mut Node, j: &'a Value, parent_mode: Option) {
+ n.visible = flag(j, "visible").unwrap_or(true);
+ n.locked = flag(j, "locked").unwrap_or(false);
+ n.opacity = num(j, "opacity").map_or(1.0, |o| o.clamp(0.0, 1.0) as f32);
+ if let Some(b) = text(j, "blendMode").and_then(props::blend_mode) {
+ n.blend = b;
+ }
+ n.is_mask = flag(j, "isMask").unwrap_or(false);
+ // The JSON lists every paint; a missing list means none (text keeps its default black).
+ match props::paints(self, j, "fills") {
+ Some(f) if !matches!(n.kind, NodeKind::Group) => n.fills = f,
+ None if !matches!(n.kind, NodeKind::Text(_)) => n.fills.clear(),
+ _ => {}
+ }
+ n.strokes = props::paints(self, j, "strokes").unwrap_or_default();
+ props::stroke_props(j, &mut n.stroke);
+ n.effects = props::effects(self, j);
+ if n.kind.has_corner_radius() {
+ props::corners(j, &mut n.corners.radii, &mut n.corners.smoothing);
+ }
+ if let Some(c) = props::constraints(j) {
+ n.constraints = c;
+ }
+ if n.kind.is_frame_like() {
+ if let Some(c) = flag(j, "clipsContent") {
+ n.clip = c;
+ }
+ n.layout = props::auto_layout(j);
+ n.grids = props::layout_grids(j);
+ }
+ n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode);
+ n.exports = props::export_settings(j);
+ for (field, prop) in j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten() {
+ if let (Some(p), true) = (prop.as_str(), matches!(field.as_str(), "visible" | "characters" | "mainComponent")) {
+ n.prop_refs.insert(field.clone(), p.to_string());
+ }
+ }
+ if matches!(n.kind, NodeKind::Vector { .. }) {
+ self.vector(n, j);
+ }
+ if matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) {
+ let defs = self.prop_defs(n.id, j);
+ match &mut n.kind {
+ NodeKind::Component { props, .. } => *props = defs,
+ _ => {
+ self.set_defs.insert(n.id, defs);
+ }
+ }
+ }
+ self.link_styles(n, j);
+ if !list(j, "interactions").is_empty() {
+ self.warn("prototype interactions are not imported");
+ }
+ if j.get("boundVariables").and_then(Value::as_object).is_some_and(|m| !m.is_empty()) {
+ self.warn("variable bindings are not imported (the file JSON has no variable values)");
+ }
+ }
+
+ /// A vector's network from its geometry. A stroke-only vector uses the stroke outline,
+ /// painted with the stroke paints (exact look, editable as a shape).
+ fn vector(&mut self, n: &mut Node, j: &Value) {
+ let fills_shown = n.fills.iter().any(|p| p.visible);
+ let strokes_shown = n.strokes.iter().any(|p| p.visible) && n.stroke.weight > 0.0;
+ let fill = self.geometry(j, "fillGeometry");
+ let stroke = self.geometry(j, "strokeGeometry");
+ let ((path, winding), outline) = match (fill, stroke) {
+ (_, Some(s)) if strokes_shown && !fills_shown => (s, true),
+ (Some(f), _) => (f, false),
+ (None, Some(s)) => (s, true),
+ (None, None) => {
+ self.warn("vectors without geometry are empty (export the file JSON with geometry=paths)");
+ return;
+ }
+ };
+ let mut network = VectorNetwork::from_path(&path);
+ for r in &mut network.regions {
+ r.winding = winding;
+ }
+ if outline {
+ n.fills = std::mem::take(&mut n.strokes);
+ }
+ n.kind = NodeKind::Vector { network };
+ }
+
+ /// The paths of a `fillGeometry` / `strokeGeometry` list (node-local SVG path data).
+ fn geometry(&mut self, j: &Value, key: &str) -> Option<(BezPath, WindingRule)> {
+ let mut out = BezPath::new();
+ let mut winding = None;
+ for p in list(j, key) {
+ let Some(d) = text(p, "path") else { continue };
+ if d.len() > MAX_PATH_BYTES {
+ self.warn("very long vector paths were skipped");
+ continue;
+ }
+ match BezPath::from_svg(d) {
+ Ok(bp) if sane(&bp) && out.elements().len() + bp.elements().len() <= MAX_PATH_ELEMENTS => {
+ winding.get_or_insert(if text(p, "windingRule") == Some("EVENODD") { WindingRule::EvenOdd } else { WindingRule::NonZero });
+ out.extend(bp);
+ }
+ _ => self.warn("invalid or oversized vector path data was skipped"),
+ }
+ }
+ (!out.elements().is_empty()).then(|| (out, winding.unwrap_or_default()))
+ }
+
+ /// Component property definitions (variant ones are implied by the variant names).
+ fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap {
+ 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");
+ let def = match text(d, "type") {
+ Some("BOOLEAN") => PropDef::Boolean { default: default.and_then(bool_of).unwrap_or(true) },
+ Some("TEXT") => PropDef::Text { default: default.and_then(Value::as_str).unwrap_or("").to_string() },
+ Some("INSTANCE_SWAP") => {
+ if let Some(r) = default.and_then(Value::as_str) {
+ self.swap_defaults.push((owner, name.clone(), r.to_string()));
+ }
+ continue;
+ }
+ Some("VARIANT") => continue,
+ _ => {
+ self.warn("unknown component property types were skipped");
+ continue;
+ }
+ };
+ out.insert(name.clone(), def);
+ }
+ out
+ }
+
+ /// Shared styles the layer uses: the metadata names them, the layer supplies the values.
+ fn link_styles(&mut self, n: &mut Node, j: &Value) {
+ for (slot, sid) in j.get("styles").and_then(Value::as_object).into_iter().flatten() {
+ let Some(sid) = sid.as_str().map(clip_str) else { continue };
+ let (kind, target) = match slot.as_str() {
+ "fill" | "fills" => (StyleKind::Paint { paints: n.fills.clone() }, &mut n.styles.fill),
+ "stroke" | "strokes" => (StyleKind::Paint { paints: n.strokes.clone() }, &mut n.styles.stroke),
+ "effect" | "effects" => (StyleKind::Effect { effects: n.effects.clone() }, &mut n.styles.effect),
+ "grid" | "grids" => (StyleKind::Grid { grids: n.grids.clone() }, &mut n.styles.grid),
+ "text" => match &n.kind {
+ NodeKind::Text(t) => (StyleKind::Text { style: t.style.clone() }, &mut n.styles.text),
+ _ => continue,
+ },
+ _ => continue,
+ };
+ *target = Some(sid.to_string());
+ if self.doc.styles.iter().any(|s| s.id == sid) {
+ continue;
+ }
+ let meta = self.style_meta.get(sid);
+ let name =
+ meta.and_then(|m| text(m, "name")).filter(|s| !s.is_empty()).map_or_else(|| format!("Style {sid}"), |s| clip_str(s).to_string());
+ let description = meta.and_then(|m| text(m, "description")).map(|s| clip_str(s).to_string()).unwrap_or_default();
+ self.doc.styles.push(Style { id: sid.to_string(), name, description, kind });
+ }
+ }
+
+ /// The document image key for an `imageRef`: its bytes when the resolver has them, else a
+ /// grey placeholder stored under the `imageRef` itself.
+ pub(crate) fn image_key(&mut self, image_ref: &str) -> String {
+ if let Some(k) = self.image_keys.get(image_ref) {
+ return k.clone();
+ }
+ let resolved = if image_ref.is_empty() { None } else { (self.images)(image_ref) };
+ let key = match resolved.and_then(|b| crate::add_image(&mut self.doc, b).ok()) {
+ Some((hash, _, _)) => hash,
+ None => {
+ self.missing_images += 1;
+ let key = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) }.to_string();
+ if !self.doc.images.contains_key(&key)
+ && let Some(png) = placeholder_png()
+ {
+ self.doc.images.insert(key.clone(), ImageData { bytes: Arc::new(png), width: PLACEHOLDER_SIDE, height: PLACEHOLDER_SIDE });
+ }
+ key
+ }
+ };
+ self.image_keys.insert(image_ref.to_string(), key.clone());
+ key
+ }
+
+ fn is_component(&self, id: NodeId) -> bool {
+ self.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
+ }
+
+ /// The component a REST id names, if it is in the file.
+ fn component(&self, rest_id: &str) -> Option {
+ self.ids.get(rest_id).copied().filter(|c| self.is_component(*c))
+ }
+
+ /// Cross-references that need every layer imported first.
+ fn finish(&mut self) {
+ self.resolve_swap_defaults();
+ // Variants share their set's property definitions.
+ for (set, defs) in std::mem::take(&mut self.set_defs) {
+ for v in self.doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
+ if let Some(NodeKind::Component { props, .. }) = self.doc.node_mut(v).map(|n| &mut n.kind) {
+ for (k, d) in &defs {
+ props.entry(k.clone()).or_insert_with(|| d.clone());
+ }
+ }
+ }
+ }
+ self.resolve_instances();
+ self.rename_props();
+ for (page, name, start) in std::mem::take(&mut self.flows) {
+ let Some(&id) = self.ids.get(&start) else { continue };
+ if self.doc.page_of(id) == Some(page)
+ && let Some(NodeKind::Page { flows, .. }) = self.doc.node_mut(page).map(|n| &mut n.kind)
+ {
+ flows.push(Flow { name, start: id, description: String::new() });
+ }
+ }
+ if self.missing_images > 0 {
+ let n = self.missing_images;
+ let (s, verb) = if n == 1 { ("", "shows") } else { ("s", "show") };
+ self.warn(format!(
+ "{n} image{s} without image data {verb} a grey placeholder named by its imageRef (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
+ ));
+ }
+ let unused = self.style_meta.keys().filter(|k| !self.doc.styles.iter().any(|s| s.id == **k)).count();
+ if unused > 0 {
+ let (s, verb) = if unused == 1 { ("", "was") } else { ("s", "were") };
+ self.warn(format!("{unused} style{s} that no layer uses {verb} skipped (the file JSON carries style values only on layers)"));
+ }
+ }
+
+ fn resolve_swap_defaults(&mut self) {
+ for (owner, name, rest_id) in std::mem::take(&mut self.swap_defaults) {
+ let Some(c) = self.component(&rest_id) else {
+ self.warn("instance swap properties whose default component isn't in the file were skipped");
+ continue;
+ };
+ let def = PropDef::InstanceSwap { default: c };
+ if let Some(defs) = self.set_defs.get_mut(&owner) {
+ defs.insert(name, def);
+ } else if let Some(NodeKind::Component { props, .. }) = self.doc.node_mut(owner).map(|n| &mut n.kind) {
+ props.insert(name, def);
+ }
+ }
+ }
+
+ /// Turn the instance frames whose main component is in the file into instances. Instances of
+ /// outside components, and instances that would nest a component inside itself (directly or
+ /// through other components), stay frames.
+ fn resolve_instances(&mut self) {
+ 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)) {
+ None => self.warn("instances of components that aren't in the file were imported as frames"),
+ Some(c) if self.doc.is_ancestor(c, inst) => self.warn("instances inside their own main component were imported as frames"),
+ Some(c) => links.push((inst, c, j)),
+ }
+ }
+ let owner = |doc: &Document, id: NodeId| {
+ doc.ancestors(id).into_iter().find(|a| doc.node(*a).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })))
+ };
+ let mut uses: BTreeMap> = BTreeMap::new();
+ for (inst, c, _) in &links {
+ if let Some(o) = owner(&self.doc, *inst) {
+ uses.entry(o).or_default().insert(*c);
+ }
+ }
+ let mut linked = Vec::new();
+ for (inst, c, j) in links {
+ if owner(&self.doc, inst).is_some_and(|o| reaches(&uses, c, o)) {
+ self.warn("instances that would nest a component inside itself were imported as frames");
+ continue;
+ }
+ let props = self.instance_props(j);
+ if let Some(n) = self.doc.node_mut(inst) {
+ n.kind = NodeKind::Instance { component: c, props };
+ linked.push((inst, j));
+ }
+ }
+ // Overridden fields the file lists, on the instance and its layers.
+ for (inst, j) in linked {
+ for o in list(j, "overrides") {
+ let Some(&id) = text(o, "id").and_then(|r| self.ids.get(r)) else { continue };
+ if !(id == inst || self.doc.is_ancestor(inst, id)) {
+ continue;
+ }
+ let groups: Vec<&'static str> = list(o, "overriddenFields").iter().filter_map(Value::as_str).filter_map(override_group).collect();
+ if let Some(n) = self.doc.node_mut(id) {
+ n.overrides.extend(groups.into_iter().map(str::to_string));
+ }
+ }
+ }
+ }
+
+ /// An instance's property values (variant values are implied by the main it shows).
+ fn instance_props(&self, j: &Value) -> BTreeMap {
+ let mut out = BTreeMap::new();
+ for (name, p) in j.get("componentProperties").and_then(Value::as_object).into_iter().flatten().take(256) {
+ let value = p.get("value");
+ let v = match text(p, "type") {
+ Some("BOOLEAN") => value.and_then(bool_of).map(Value::Bool),
+ Some("TEXT") => value.and_then(Value::as_str).map(|s| Value::String(s.to_string())),
+ Some("INSTANCE_SWAP") => value.and_then(Value::as_str).and_then(|r| self.component(r)).map(|c| Value::from(c.0)),
+ _ => None,
+ };
+ if let Some(v) = v {
+ out.insert(name.clone(), v);
+ }
+ }
+ out
+ }
+
+ /// Component property names drop their `#id` suffix ("Label#12:3" β "Label") unless that
+ /// would merge two different properties.
+ fn rename_props(&mut self) {
+ let mut by_short: BTreeMap> = BTreeMap::new();
+ let ids: Vec = self.doc.ids().collect();
+ for n in ids.iter().filter_map(|i| self.doc.node(*i)) {
+ let keys: Vec<&String> = match &n.kind {
+ NodeKind::Component { props, .. } => props.keys().collect(),
+ NodeKind::Instance { props, .. } => props.keys().collect(),
+ _ => Vec::new(),
+ };
+ for k in keys.into_iter().chain(n.prop_refs.values()) {
+ by_short.entry(short_prop_name(k).to_string()).or_default().insert(k.clone());
+ }
+ }
+ let rename: BTreeMap = by_short
+ .into_iter()
+ .filter(|(_, raws)| raws.len() == 1)
+ .filter_map(|(short, raws)| raws.into_iter().next().filter(|r| *r != short).map(|r| (r, short)))
+ .collect();
+ if rename.is_empty() {
+ return;
+ }
+ let re = |k: &String| rename.get(k).cloned().unwrap_or_else(|| k.clone());
+ for id in ids {
+ let Some(n) = self.doc.node(id) else { continue };
+ let touches = n.prop_refs.values().any(|v| rename.contains_key(v))
+ || match &n.kind {
+ NodeKind::Component { props, .. } => props.keys().any(|k| rename.contains_key(k)),
+ NodeKind::Instance { props, .. } => props.keys().any(|k| rename.contains_key(k)),
+ _ => false,
+ };
+ if !touches {
+ continue;
+ }
+ let Some(n) = self.doc.node_mut(id) else { continue };
+ for v in n.prop_refs.values_mut() {
+ *v = re(v);
+ }
+ match &mut n.kind {
+ NodeKind::Component { props, .. } => *props = std::mem::take(props).into_iter().map(|(k, d)| (re(&k), d)).collect(),
+ NodeKind::Instance { props, .. } => *props = std::mem::take(props).into_iter().map(|(k, d)| (re(&k), d)).collect(),
+ _ => {}
+ }
+ }
+ }
+}
+
+/// "Label#12:3" β "Label".
+fn short_prop_name(raw: &str) -> &str {
+ match raw.rsplit_once('#') {
+ Some((name, id)) if !name.is_empty() && !id.is_empty() && id.chars().all(|c| c.is_ascii_digit() || c == ':') => name,
+ _ => raw,
+ }
+}
+
+/// Whether component `to` is reachable from component `from` through the instances inside them.
+fn reaches(uses: &BTreeMap>, from: NodeId, to: NodeId) -> bool {
+ let mut seen = BTreeSet::new();
+ let mut stack = vec![from];
+ while let Some(c) = stack.pop() {
+ if c == to {
+ return true;
+ }
+ if seen.insert(c) {
+ stack.extend(uses.get(&c).into_iter().flatten().copied());
+ }
+ }
+ false
+}
+
+/// The override group of an overridden field name.
+fn override_group(field: &str) -> Option<&'static str> {
+ Some(match field {
+ "fills" | "fillStyleId" => "fills",
+ "strokes"
+ | "strokeStyleId"
+ | "strokeWeight"
+ | "strokeAlign"
+ | "strokeCap"
+ | "strokeJoin"
+ | "strokeDashes"
+ | "individualStrokeWeights"
+ | "strokeTopWeight"
+ | "strokeRightWeight"
+ | "strokeBottomWeight"
+ | "strokeLeftWeight" => "strokes",
+ "effects" | "effectStyleId" => "effects",
+ "opacity" => "opacity",
+ "visible" => "visible",
+ "name" => "name",
+ "cornerRadius"
+ | "rectangleCornerRadii"
+ | "topLeftRadius"
+ | "topRightRadius"
+ | "bottomLeftRadius"
+ | "bottomRightRadius"
+ | "cornerSmoothing" => "corners",
+ "characters" => "characters",
+ "fontName"
+ | "fontFamily"
+ | "fontStyle"
+ | "fontWeight"
+ | "fontSize"
+ | "letterSpacing"
+ | "lineHeight"
+ | "textCase"
+ | "textDecoration"
+ | "textStyleId"
+ | "textAlignHorizontal"
+ | "textAlignVertical"
+ | "textAutoResize"
+ | "paragraphSpacing"
+ | "paragraphIndent" => "textStyle",
+ "width" | "height" | "size" => "size",
+ _ => return None,
+ })
+}
+
+fn bool_of(v: &Value) -> Option {
+ v.as_bool().or_else(|| match v.as_str() {
+ Some("true") => Some(true),
+ Some("false") => Some(false),
+ _ => None,
+ })
+}
+
+fn description(meta: &BTreeMap<&str, &Value>, j: &Value) -> String {
+ text(j, "id").and_then(|id| meta.get(id)).and_then(|m| text(m, "description")).map(|d| clip_str(d).to_string()).unwrap_or_default()
+}
+
+/// `{x, y, width, height}`.
+fn rect(v: &Value) -> Option {
+ let (x, y) = (num(v, "x")?.clamp(-MAX_COORD, MAX_COORD), num(v, "y")?.clamp(-MAX_COORD, MAX_COORD));
+ let (w, h) = (num(v, "width")?.clamp(0.0, MAX_SIZE), num(v, "height")?.clamp(0.0, MAX_SIZE));
+ Some(Rect::new(x, y, x + w, y + h))
+}
+
+/// The box size whose bounds, rotated by `rot` radians, are `b`.
+fn unrotated_size(b: Rect, rot: f64) -> (f64, f64) {
+ let (bw, bh) = (b.width(), b.height());
+ let (c, s) = (rot.cos().abs(), rot.sin().abs());
+ if s < 1e-9 {
+ return (bw, bh);
+ }
+ let det = c * c - s * s;
+ if det.abs() > 0.05 {
+ let (w, h) = ((bw * c - bh * s) / det, (bh * c - bw * s) / det);
+ if w >= 0.0 && h >= 0.0 {
+ return (w, h);
+ }
+ }
+ // Near 45Β° the size is ambiguous: a box with the same proportions.
+ (bw / (c + s), bh / (c + s))
+}
+
+/// `world` expressed in the space whose page transform is `parent`.
+fn local(parent: Affine, world: Affine) -> Affine {
+ let l = if parent.determinant().abs() > 1e-12 { parent.inverse() * world } else { Affine::translate(world.translation() - parent.translation()) };
+ if l.as_coeffs().iter().all(|c| c.is_finite()) { l } else { Affine::IDENTITY }
+}
+
+/// Finite path data within the coordinate cap.
+fn sane(p: &BezPath) -> bool {
+ let b = p.bounding_box();
+ [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)
+}
+
+/// Side of the placeholder image (large enough that scaling it up stays flat).
+const PLACEHOLDER_SIDE: u32 = 64;
+
+/// A light-grey PNG standing in for image fills whose bytes aren't available.
+fn placeholder_png() -> Option> {
+ let img = image::RgbaImage::from_pixel(PLACEHOLDER_SIDE, PLACEHOLDER_SIDE, image::Rgba([0xD9, 0xD9, 0xD9, 0xFF]));
+ let mut out = Vec::new();
+ img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
+ Some(out)
+}
diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs
new file mode 100644
index 0000000..f13a119
--- /dev/null
+++ b/crates/format/src/rest/props.rs
@@ -0,0 +1,456 @@
+//! Property mapping for the REST file JSON: JSON readers, enums, paints, effects, strokes,
+//! corners, constraints, auto layout, layout grids and export settings.
+
+use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind};
+use figcraft_doc::geom::Affine;
+use figcraft_doc::{
+ AutoLayout, ChildLayout, Constraints, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, ExportSize, GridAlign, GridKind, HConstraint,
+ LayoutGrid, LayoutMode, MainAlign, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VConstraint,
+};
+use serde_json::Value;
+
+use super::Ctx;
+
+/// Largest width/height/stroke/radius/spacing value taken from the file.
+pub(super) const MAX_SIZE: f64 = 1e6;
+/// Largest coordinate taken from the file.
+pub(super) const MAX_COORD: f64 = 1e7;
+/// Most entries read from any one list (paints, effects, dashes, gridsβ¦).
+const MAX_LIST: usize = 256;
+
+// ---- JSON readers (missing, wrongly typed or non-finite values read as `None`) ----
+
+pub(super) fn num(v: &Value, k: &str) -> Option {
+ v.get(k)?.as_f64().filter(|x| x.is_finite())
+}
+
+/// A non-negative length, capped.
+pub(super) fn len(v: &Value, k: &str) -> Option {
+ num(v, k).map(|x| x.clamp(0.0, MAX_SIZE))
+}
+
+pub(super) fn text<'a>(v: &'a Value, k: &str) -> Option<&'a str> {
+ v.get(k)?.as_str()
+}
+
+pub(super) fn flag(v: &Value, k: &str) -> Option {
+ v.get(k)?.as_bool()
+}
+
+pub(super) fn list<'a>(v: &'a Value, k: &str) -> &'a [Value] {
+ v.get(k).and_then(Value::as_array).map(|a| a.get(..a.len().min(MAX_LIST)).unwrap_or_default()).unwrap_or_default()
+}
+
+/// `{x, y}`.
+pub(super) fn vec2(v: &Value) -> Option<(f64, f64)> {
+ Some((num(v, "x")?, num(v, "y")?))
+}
+
+/// `{r, g, b, a}` with components 0..1 (`a` defaults to 1).
+pub(super) fn color(v: &Value) -> Option {
+ let c = |k: &str| num(v, k).map(|x| x.clamp(0.0, 1.0) as f32);
+ Some(Color::new(c("r")?, c("g")?, c("b")?, c("a").unwrap_or(1.0)))
+}
+
+/// A 2Γ3 matrix `[[a, c, e], [b, d, f]]` (x' = aΒ·x + cΒ·y + e, y' = bΒ·x + dΒ·y + f).
+pub(super) fn transform(v: &Value) -> Option {
+ let rows = v.as_array()?;
+ let row = |i: usize| -> Option<[f64; 3]> {
+ let r = rows.get(i)?.as_array()?;
+ let n = |j: usize| r.get(j)?.as_f64().filter(|x| x.is_finite());
+ Some([n(0)?, n(1)?, n(2)?])
+ };
+ let ([a, c, e], [b, d, f]) = (row(0)?, row(1)?);
+ if e.abs() > MAX_COORD || f.abs() > MAX_COORD || [a, b, c, d].iter().any(|x| x.abs() > 1e6) {
+ return None;
+ }
+ Some(Affine::new([a, b, c, d, e, f]))
+}
+
+// ---- enums ----
+
+pub(super) fn blend_mode(s: &str) -> Option {
+ Some(match s {
+ "PASS_THROUGH" => BlendMode::PassThrough,
+ "NORMAL" => BlendMode::Normal,
+ "DARKEN" => BlendMode::Darken,
+ "MULTIPLY" => BlendMode::Multiply,
+ "LINEAR_BURN" => BlendMode::PlusDarker,
+ "COLOR_BURN" => BlendMode::ColorBurn,
+ "LIGHTEN" => BlendMode::Lighten,
+ "SCREEN" => BlendMode::Screen,
+ "LINEAR_DODGE" => BlendMode::PlusLighter,
+ "COLOR_DODGE" => BlendMode::ColorDodge,
+ "OVERLAY" => BlendMode::Overlay,
+ "SOFT_LIGHT" => BlendMode::SoftLight,
+ "HARD_LIGHT" => BlendMode::HardLight,
+ "DIFFERENCE" => BlendMode::Difference,
+ "EXCLUSION" => BlendMode::Exclusion,
+ "HUE" => BlendMode::Hue,
+ "SATURATION" => BlendMode::Saturation,
+ "COLOR" => BlendMode::Color,
+ "LUMINOSITY" => BlendMode::Luminosity,
+ _ => return None,
+ })
+}
+
+/// A paint's or effect's blend mode (pass-through only applies to layers).
+fn paint_blend(v: &Value) -> BlendMode {
+ match text(v, "blendMode").and_then(blend_mode) {
+ Some(BlendMode::PassThrough) | None => BlendMode::Normal,
+ Some(b) => b,
+ }
+}
+
+fn sizing(s: &str) -> Option {
+ Some(match s {
+ "FIXED" => Sizing::Fixed,
+ "HUG" => Sizing::Hug,
+ "FILL" => Sizing::Fill,
+ _ => return None,
+ })
+}
+
+// ---- paints ----
+
+/// A `fills` / `strokes` list.
+pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option> {
+ let a = v.get(k)?.as_array()?;
+ Some(a.iter().take(MAX_LIST).filter_map(|p| paint(cx, p)).collect())
+}
+
+fn paint(cx: &mut Ctx, p: &Value) -> Option {
+ let ty = text(p, "type")?;
+ let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
+ let kind = match ty {
+ "SOLID" => {
+ let c = p.get("color").and_then(color).unwrap_or(Color::BLACK);
+ opacity *= c.a;
+ PaintKind::Solid { color: c.with_alpha(1.0) }
+ }
+ "GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)),
+ "GRADIENT_RADIAL" => PaintKind::Gradient(gradient(GradientKind::Radial, p)),
+ "GRADIENT_ANGULAR" => PaintKind::Gradient(gradient(GradientKind::Angular, p)),
+ "GRADIENT_DIAMOND" => PaintKind::Gradient(gradient(GradientKind::Diamond, p)),
+ "IMAGE" => PaintKind::Image(image(cx, p)),
+ other => {
+ cx.warn(format!("{} paints are not supported and were skipped", clip_str(other)));
+ return None;
+ }
+ };
+ Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p) })
+}
+
+/// Gradient handles are already in the node's unit box (start, end, width), like ours.
+fn gradient(kind: GradientKind, p: &Value) -> Gradient {
+ let mut g = Gradient::default_for(kind, Color::BLACK);
+ let h: Vec<(f64, f64)> =
+ list(p, "gradientHandlePositions").iter().filter_map(vec2).map(|(x, y)| (x.clamp(-1e3, 1e3), y.clamp(-1e3, 1e3))).collect();
+ if let [from, to, rest @ ..] = h.as_slice() {
+ g.from = *from;
+ g.to = *to;
+ // Without the width handle: perpendicular to the main axis, same length.
+ g.width = rest.first().copied().unwrap_or((from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)));
+ }
+ let stops: Vec = list(p, "gradientStops")
+ .iter()
+ .filter_map(|s| Some(ColorStop { position: num(s, "position")?.clamp(0.0, 1.0) as f32, color: s.get("color").and_then(color)? }))
+ .collect();
+ if !stops.is_empty() {
+ g.stops = stops;
+ }
+ g
+}
+
+fn image(cx: &mut Ctx, p: &Value) -> ImagePaint {
+ let scale_mode = match text(p, "scaleMode") {
+ Some("FIT") => ImageScaleMode::Fit,
+ Some("TILE") => ImageScaleMode::Tile,
+ Some("STRETCH") => ImageScaleMode::Crop,
+ _ => ImageScaleMode::Fill,
+ };
+ // STRETCH: the image transform maps the unit box to image space, like our crop matrix.
+ let crop = (scale_mode == ImageScaleMode::Crop)
+ .then(|| p.get("imageTransform").and_then(transform))
+ .flatten()
+ .map(|t| t.as_coeffs())
+ .filter(|c| c[0].abs() > 1e-9 && c[3].abs() > 1e-9);
+ let f = p.get("filters");
+ let filter = |k: &str| f.and_then(|f| num(f, k)).unwrap_or(0.0).clamp(-1.0, 1.0) as f32;
+ ImagePaint {
+ image: cx.image_key(text(p, "imageRef").unwrap_or("")),
+ scale_mode,
+ scale: num(p, "scalingFactor").filter(|s| *s > 0.0).map_or(1.0, |s| s.min(1e3)),
+ rotation: (num(p, "rotation").unwrap_or(0.0).clamp(-1e6, 1e6) / 90.0).round().rem_euclid(4.0) as i32,
+ crop,
+ filters: ImageFilters {
+ exposure: filter("exposure"),
+ contrast: filter("contrast"),
+ saturation: filter("saturation"),
+ temperature: filter("temperature"),
+ tint: filter("tint"),
+ highlights: filter("highlights"),
+ shadows: filter("shadows"),
+ },
+ }
+}
+
+// ---- effects ----
+
+pub(super) fn effects(cx: &mut Ctx, v: &Value) -> Vec {
+ list(v, "effects").iter().filter_map(|e| effect(cx, e)).collect()
+}
+
+fn effect(cx: &mut Ctx, e: &Value) -> Option {
+ let radius = len(e, "radius").unwrap_or(0.0);
+ let shadow = || Shadow {
+ color: e.get("color").and_then(color).unwrap_or(Color::new(0.0, 0.0, 0.0, 0.25)),
+ offset: e.get("offset").and_then(vec2).map_or((0.0, 0.0), |(x, y)| (x.clamp(-MAX_SIZE, MAX_SIZE), y.clamp(-MAX_SIZE, MAX_SIZE))),
+ radius,
+ spread: num(e, "spread").unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE),
+ blend: paint_blend(e),
+ show_behind: flag(e, "showShadowBehindNode").unwrap_or(false),
+ };
+ let kind = match text(e, "type")? {
+ "DROP_SHADOW" => EffectKind::DropShadow(shadow()),
+ "INNER_SHADOW" => EffectKind::InnerShadow(shadow()),
+ "LAYER_BLUR" => EffectKind::LayerBlur { radius },
+ "BACKGROUND_BLUR" => EffectKind::BackgroundBlur { radius },
+ other => {
+ cx.warn(format!("{} effects are not supported and were skipped", clip_str(other)));
+ return None;
+ }
+ };
+ Some(Effect { kind, visible: flag(e, "visible").unwrap_or(true) })
+}
+
+// ---- strokes, corners, constraints ----
+
+pub(super) fn stroke_props(v: &Value, sp: &mut StrokeProps) {
+ if let Some(w) = len(v, "strokeWeight") {
+ sp.weight = w;
+ }
+ if let Some(s) = v.get("individualStrokeWeights") {
+ let side = |k: &str| len(s, k).unwrap_or(sp.weight);
+ sp.sides = Some([side("top"), side("right"), side("bottom"), side("left")]);
+ }
+ match text(v, "strokeAlign") {
+ Some("INSIDE") => sp.align = StrokeAlign::Inside,
+ Some("CENTER") => sp.align = StrokeAlign::Center,
+ Some("OUTSIDE") => sp.align = StrokeAlign::Outside,
+ _ => {}
+ }
+ let cap = match text(v, "strokeCap") {
+ Some("ROUND") => Some(StrokeCap::Round),
+ Some("SQUARE") => Some(StrokeCap::Square),
+ Some("LINE_ARROW") => Some(StrokeCap::LineArrow),
+ Some("TRIANGLE_ARROW" | "TRIANGLE_FILLED") => Some(StrokeCap::TriangleArrow),
+ Some("DIAMOND_FILLED") => Some(StrokeCap::DiamondFilled),
+ Some("CIRCLE_FILLED") => Some(StrokeCap::CircleFilled),
+ Some(_) => Some(StrokeCap::None),
+ None => None,
+ };
+ if let Some(c) = cap {
+ sp.cap_start = c;
+ sp.cap_end = c;
+ }
+ match text(v, "strokeJoin") {
+ Some("MITER") => sp.join = StrokeJoin::Miter,
+ Some("BEVEL") => sp.join = StrokeJoin::Bevel,
+ Some("ROUND") => sp.join = StrokeJoin::Round,
+ _ => {}
+ }
+ if v.get("strokeDashes").is_some() {
+ sp.dashes = list(v, "strokeDashes").iter().filter_map(Value::as_f64).filter(|d| d.is_finite()).map(|d| d.clamp(0.0, MAX_SIZE)).collect();
+ if sp.dashes.iter().all(|d| *d <= 0.0) {
+ sp.dashes.clear();
+ }
+ }
+ // The miter angle (degrees) below which joins bevel β the equivalent miter limit.
+ if let Some(a) = num(v, "strokeMiterAngle").filter(|a| *a > 0.0 && *a < 180.0) {
+ sp.miter_limit = (1.0 / (a.to_radians() / 2.0).sin()).clamp(1.0, 100.0);
+ }
+}
+
+/// `[tl, tr, br, bl]` radii and smoothing, when the node sets them.
+pub(super) fn corners(v: &Value, radii: &mut [f64; 4], smoothing: &mut f64) {
+ if let Some(r) = len(v, "cornerRadius") {
+ *radii = [r; 4];
+ }
+ let each: Vec =
+ list(v, "rectangleCornerRadii").iter().filter_map(Value::as_f64).filter(|r| r.is_finite()).map(|r| r.clamp(0.0, MAX_SIZE)).collect();
+ if let [a, b, c, d] = each.as_slice() {
+ *radii = [*a, *b, *c, *d];
+ }
+ if let Some(s) = num(v, "cornerSmoothing") {
+ *smoothing = s.clamp(0.0, 1.0);
+ }
+}
+
+pub(super) fn constraints(v: &Value) -> Option {
+ let c = v.get("constraints")?;
+ Some(Constraints {
+ horizontal: match text(c, "horizontal") {
+ Some("RIGHT") => HConstraint::Right,
+ Some("LEFT_RIGHT") => HConstraint::LeftRight,
+ Some("CENTER") => HConstraint::Center,
+ Some("SCALE") => HConstraint::Scale,
+ _ => HConstraint::Left,
+ },
+ vertical: match text(c, "vertical") {
+ Some("BOTTOM") => VConstraint::Bottom,
+ Some("TOP_BOTTOM") => VConstraint::TopBottom,
+ Some("CENTER") => VConstraint::Center,
+ Some("SCALE") => VConstraint::Scale,
+ _ => VConstraint::Top,
+ },
+ })
+}
+
+// ---- auto layout ----
+
+/// The auto layout of a frame-like node (`layoutMode` other than `NONE`).
+pub(super) fn auto_layout(v: &Value) -> Option {
+ let mode = match text(v, "layoutMode")? {
+ "HORIZONTAL" => LayoutMode::Horizontal,
+ "VERTICAL" => LayoutMode::Vertical,
+ "GRID" => LayoutMode::Grid,
+ _ => return None,
+ };
+ let pad = |k: &str| len(v, k).unwrap_or(0.0);
+ let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32);
+ let grid = mode == LayoutMode::Grid;
+ Some(AutoLayout {
+ mode,
+ wrap: text(v, "layoutWrap") == Some("WRAP"),
+ gap: if grid { len(v, "gridColumnGap") } else { num(v, "itemSpacing").map(|g| g.clamp(-MAX_SIZE, MAX_SIZE)) }.unwrap_or(0.0),
+ cross_gap: if grid { len(v, "gridRowGap") } else { len(v, "counterAxisSpacing") }.unwrap_or(0.0),
+ padding: [pad("paddingTop"), pad("paddingRight"), pad("paddingBottom"), pad("paddingLeft")],
+ main_align: match text(v, "primaryAxisAlignItems") {
+ Some("CENTER") => MainAlign::Center,
+ Some("MAX") => MainAlign::Max,
+ Some("SPACE_BETWEEN") => MainAlign::SpaceBetween,
+ _ => MainAlign::Min,
+ },
+ cross_align: match text(v, "counterAxisAlignItems") {
+ Some("CENTER") => CrossAlign::Center,
+ Some("MAX") => CrossAlign::Max,
+ Some("BASELINE") => CrossAlign::Baseline,
+ _ => CrossAlign::Min,
+ },
+ grid_columns: count("gridColumnCount", 2),
+ grid_rows: count("gridRowCount", 2),
+ strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false),
+ reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false),
+ ..AutoLayout::default()
+ })
+}
+
+/// How the node sizes and sits in its parent: its own hug/fixed axes (auto layout frames), its
+/// fill/fixed axes as a child of an auto layout `parent_mode`, absolute positioning, min/max sizes
+/// and grid cells.
+pub(super) fn child_layout(v: &Value, own: Option, parent_mode: Option) -> ChildLayout {
+ let mut cl = ChildLayout::default();
+ let horizontal_main = |m: LayoutMode| m != LayoutMode::Vertical;
+ // Legacy fields first, the explicit per-axis sizing wins.
+ if let Some(m) = own {
+ let hug = |k: &str| text(v, k) == Some("AUTO");
+ let (main, cross) = (hug("primaryAxisSizingMode"), hug("counterAxisSizingMode"));
+ let (h, w) = if horizontal_main(m) { (main, cross) } else { (cross, main) };
+ if h {
+ cl.sizing_h = Sizing::Hug;
+ }
+ if w {
+ cl.sizing_v = Sizing::Hug;
+ }
+ }
+ if let Some(m) = parent_mode {
+ let grow = num(v, "layoutGrow").is_some_and(|g| g > 0.0);
+ let stretch = text(v, "layoutAlign") == Some("STRETCH");
+ let (fill_h, fill_v) = if horizontal_main(m) { (grow, stretch) } else { (stretch, grow) };
+ if fill_h {
+ cl.sizing_h = Sizing::Fill;
+ }
+ if fill_v {
+ cl.sizing_v = Sizing::Fill;
+ }
+ }
+ if let Some(s) = text(v, "layoutSizingHorizontal").and_then(sizing) {
+ cl.sizing_h = s;
+ }
+ if let Some(s) = text(v, "layoutSizingVertical").and_then(sizing) {
+ cl.sizing_v = s;
+ }
+ cl.absolute = text(v, "layoutPositioning") == Some("ABSOLUTE");
+ cl.min_w = len(v, "minWidth");
+ cl.max_w = len(v, "maxWidth");
+ cl.min_h = len(v, "minHeight");
+ cl.max_h = len(v, "maxHeight");
+ let index = |k: &str| num(v, k).filter(|n| *n >= 0.0).map(|n| n.min(1000.0) as u32);
+ cl.grid_column = index("gridColumnAnchorIndex");
+ cl.grid_row = index("gridRowAnchorIndex");
+ cl.grid_column_span = index("gridColumnSpan").unwrap_or(0);
+ cl.grid_row_span = index("gridRowSpan").unwrap_or(0);
+ cl
+}
+
+// ---- layout grids and export settings ----
+
+pub(super) fn layout_grids(v: &Value) -> Vec {
+ list(v, "layoutGrids")
+ .iter()
+ .filter_map(|g| {
+ let kind = match text(g, "pattern")? {
+ "COLUMNS" => GridKind::Columns,
+ "ROWS" => GridKind::Rows,
+ "GRID" => GridKind::Grid,
+ _ => return None,
+ };
+ let base = match kind {
+ GridKind::Columns => LayoutGrid::columns(),
+ GridKind::Rows => LayoutGrid::rows(),
+ GridKind::Grid => LayoutGrid::default(),
+ };
+ Some(LayoutGrid {
+ kind,
+ size: len(g, "sectionSize").unwrap_or(base.size),
+ // Auto-fit counts come through as 0, negative or huge.
+ count: num(g, "count").filter(|c| *c >= 1.0 && *c <= 1000.0).map_or(0, |c| c as u32),
+ gutter: len(g, "gutterSize").unwrap_or(base.gutter),
+ offset: len(g, "offset").unwrap_or(0.0),
+ align: match text(g, "alignment") {
+ Some("MIN") => GridAlign::Min,
+ Some("CENTER") => GridAlign::Center,
+ Some("MAX") => GridAlign::Max,
+ _ => GridAlign::Stretch,
+ },
+ color: g.get("color").and_then(color).unwrap_or(base.color),
+ visible: flag(g, "visible").unwrap_or(true),
+ })
+ })
+ .collect()
+}
+
+pub(super) fn export_settings(v: &Value) -> Vec {
+ list(v, "exportSettings")
+ .iter()
+ .map(|e| {
+ let c = e.get("constraint");
+ let value = c.and_then(|c| num(c, "value")).filter(|x| *x > 0.0).map_or(1.0, |x| x.min(MAX_SIZE));
+ ExportSetting {
+ size: match c.and_then(|c| text(c, "type")) {
+ Some("WIDTH") => ExportSize::Width { value },
+ Some("HEIGHT") => ExportSize::Height { value },
+ _ => ExportSize::Scale { value: value.min(64.0) },
+ },
+ suffix: clip_str(text(e, "suffix").unwrap_or("")).to_string(),
+ format: text(e, "format").and_then(ExportFormat::from_ext).unwrap_or_default(),
+ }
+ })
+ .collect()
+}
+
+/// At most 200 chars of `s` (names and labels from the file stay readable in warnings).
+pub(super) fn clip_str(s: &str) -> &str {
+ s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s)
+}
diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs
new file mode 100644
index 0000000..c27e7ba
--- /dev/null
+++ b/crates/format/src/rest/tests.rs
@@ -0,0 +1,708 @@
+//! REST API file JSON import tests. Every fixture is synthetic, written by hand from the published
+//! schema.
+
+use super::*;
+use figcraft_doc::color::{BlendMode, GradientKind, ImageScaleMode, PaintKind};
+use figcraft_doc::{
+ CrossAlign, EffectKind, ExportFormat, ExportSize, GridKind, HConstraint, LetterSpacing, LineHeight, ListStyle, MainAlign, Sizing, StrokeAlign,
+ StrokeCap, StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextDecoration, VConstraint,
+};
+use serde_json::json;
+
+fn file(pages: Value) -> Value {
+ json!({"name": "Fixture", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": pages}})
+}
+
+fn page(name: &str, children: Value) -> Value {
+ json!({"id": format!("0:{name}"), "name": name, "type": "CANVAS", "backgroundColor": {"r": 0.1, "g": 0.2, "b": 0.3, "a": 1.0}, "children": children})
+}
+
+fn imp(v: &Value) -> (Document, Vec) {
+ import(v, &no_images).unwrap()
+}
+
+/// One page holding `children`.
+fn one(children: Value) -> (Document, Vec) {
+ imp(&file(json!([page("Page", children)])))
+}
+
+fn named<'a>(doc: &'a Document, name: &str) -> &'a Node {
+ doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no node {name}"))
+}
+
+fn at(x: f64, y: f64) -> Value {
+ json!([[1, 0, x], [0, 1, y]])
+}
+
+fn close(a: f64, b: f64) -> bool {
+ (a - b).abs() < 1e-6
+}
+
+fn solid(r: f64, g: f64, b: f64) -> Value {
+ json!({"type": "SOLID", "color": {"r": r, "g": g, "b": b, "a": 1}})
+}
+
+#[test]
+fn pages_tree_and_layer_basics() {
+ let v = file(json!([
+ page(
+ "Cover",
+ json!([
+ {"id": "1:1", "name": "Card", "type": "FRAME", "relativeTransform": at(10.0, 20.0), "size": {"x": 200, "y": 100},
+ "fills": [solid(1.0, 1.0, 1.0)], "clipsContent": false, "opacity": 0.5, "blendMode": "MULTIPLY", "locked": true,
+ "cornerRadius": 12, "cornerSmoothing": 0.6,
+ "children": [
+ {"id": "1:2", "name": "Hidden", "type": "RECTANGLE", "visible": false, "relativeTransform": at(5.0, 5.0), "size": {"x": 10, "y": 10},
+ "rectangleCornerRadii": [1, 2, 3, 4], "isMask": true, "fills": []},
+ {"id": "1:3", "name": "Slice", "type": "SLICE", "relativeTransform": at(0.0, 0.0), "size": {"x": 50, "y": 50},
+ "exportSettings": [{"suffix": "@2x", "format": "PNG", "constraint": {"type": "SCALE", "value": 2}},
+ {"suffix": "", "format": "SVG", "constraint": {"type": "WIDTH", "value": 64}}]}
+ ]}
+ ])
+ ),
+ page("Second", json!([]))
+ ]));
+ let (doc, warnings) = imp(&v);
+ assert!(warnings.is_empty(), "{warnings:?}");
+ assert_eq!(doc.name, "Fixture");
+ assert_eq!(doc.pages.len(), 2, "the blank starting page is dropped");
+ let p0 = doc.node(doc.pages[0]).unwrap();
+ assert_eq!(p0.name, "Cover");
+ assert!(matches!(p0.kind, NodeKind::Page { background, .. } if (background.g - 0.2).abs() < 1e-6));
+ assert_eq!(doc.current_page, Some(doc.pages[0]));
+ let card = named(&doc, "Card");
+ assert!(matches!(card.kind, NodeKind::Frame));
+ assert_eq!((card.width, card.height), (200.0, 100.0));
+ assert!(!card.clip && card.locked);
+ assert!(close(f64::from(card.opacity), 0.5));
+ assert_eq!(card.blend, BlendMode::Multiply);
+ assert_eq!(card.corners.radii, [12.0; 4]);
+ assert!(close(card.corners.smoothing, 0.6));
+ assert_eq!(doc.world_bounds(card.id).unwrap().x0, 10.0);
+ let hidden = named(&doc, "Hidden");
+ assert!(!hidden.visible && hidden.is_mask);
+ assert_eq!(hidden.corners.radii, [1.0, 2.0, 3.0, 4.0]);
+ assert!(hidden.fills.is_empty());
+ assert_eq!(doc.world_bounds(hidden.id).unwrap().x0, 15.0);
+ let slice = named(&doc, "Slice");
+ assert!(matches!(slice.kind, NodeKind::Slice));
+ assert_eq!(slice.exports.len(), 2);
+ assert_eq!(slice.exports[0].suffix, "@2x");
+ assert_eq!(slice.exports[0].size, ExportSize::Scale { value: 2.0 });
+ assert_eq!(slice.exports[1].format, ExportFormat::Svg);
+ assert_eq!(slice.exports[1].size, ExportSize::Width { value: 64.0 });
+ assert!(doc.validate().is_empty());
+}
+
+#[test]
+fn geometry_rotation_and_group_spaces() {
+ let (s, c) = (30f64.to_radians().sin(), 30f64.to_radians().cos());
+ let (doc, _) = one(json!([
+ {"id": "1:1", "name": "Rotated", "type": "RECTANGLE", "relativeTransform": [[c, s, 10], [-s, c, 20]], "size": {"x": 40, "y": 20}, "fills": []},
+ // No transform: the box comes from the absolute bounds and the rotation (radians).
+ {"id": "1:2", "name": "ByBounds", "type": "RECTANGLE", "rotation": std::f64::consts::FRAC_PI_2,
+ "absoluteBoundingBox": {"x": 0, "y": 0, "width": 50, "height": 100}, "fills": []},
+ {"id": "2:1", "name": "Frame", "type": "FRAME", "relativeTransform": at(100.0, 100.0), "size": {"x": 300, "y": 300},
+ "absoluteBoundingBox": {"x": 100, "y": 100, "width": 300, "height": 300}, "children": [
+ {"id": "2:2", "name": "Group", "type": "GROUP", "relativeTransform": at(50.0, 50.0), "size": {"x": 100, "y": 100},
+ "absoluteBoundingBox": {"x": 150, "y": 150, "width": 100, "height": 100}, "children": [
+ // Placed in the frame's space (the group doesn't define one).
+ {"id": "2:3", "name": "InFrameSpace", "type": "RECTANGLE", "relativeTransform": at(60.0, 60.0), "size": {"x": 40, "y": 40},
+ "absoluteBoundingBox": {"x": 160, "y": 160, "width": 40, "height": 40}},
+ // Placed in the group's space: the absolute bounds tell.
+ {"id": "2:4", "name": "InGroupSpace", "type": "RECTANGLE", "relativeTransform": at(50.0, 50.0), "size": {"x": 40, "y": 40},
+ "absoluteBoundingBox": {"x": 200, "y": 200, "width": 40, "height": 40}}
+ ]}
+ ]}
+ ]));
+ let r = named(&doc, "Rotated");
+ let k = r.transform.as_coeffs();
+ assert!(close(k[0], c) && close(k[1], -s) && close(k[2], s) && close(k[3], c) && close(k[4], 10.0) && close(k[5], 20.0));
+ let b = named(&doc, "ByBounds");
+ assert!(close(b.width, 100.0) && close(b.height, 50.0));
+ let wb = doc.world_bounds(b.id).unwrap();
+ assert!(close(wb.x0, 0.0) && close(wb.y0, 0.0) && close(wb.x1, 50.0) && close(wb.y1, 100.0), "{wb:?}");
+ let a = doc.world_bounds(named(&doc, "InFrameSpace").id).unwrap();
+ assert!(close(a.x0, 160.0) && close(a.y0, 160.0), "{a:?}");
+ let g = doc.world_bounds(named(&doc, "InGroupSpace").id).unwrap();
+ assert!(close(g.x0, 200.0) && close(g.y0, 200.0), "{g:?}");
+ // The group wraps its children.
+ let gb = doc.world_bounds(named(&doc, "Group").id).unwrap();
+ assert!(close(gb.x0, 160.0) && close(gb.x1, 240.0), "{gb:?}");
+}
+
+#[test]
+fn paints_gradients_and_image_placeholders() {
+ let rect = |name: &str, fills: Value| json!({"id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "fills": fills});
+ let (doc, warnings) = one(json!([
+ rect(
+ "Solid",
+ json!([{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 0.5}, "opacity": 0.5, "blendMode": "SCREEN", "visible": false}])
+ ),
+ rect(
+ "Linear",
+ json!([{"type": "GRADIENT_LINEAR", "gradientHandlePositions": [{"x": 0, "y": 0.5}, {"x": 1, "y": 0.5}, {"x": 0, "y": 1}],
+ "gradientStops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}, {"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 0}}]}])
+ ),
+ rect(
+ "Radial",
+ json!([{"type": "GRADIENT_RADIAL", "gradientHandlePositions": [{"x": 0.5, "y": 0.5}, {"x": 1, "y": 0.5}, {"x": 0.5, "y": 1}],
+ "gradientStops": [{"position": 0, "color": {"r": 1, "g": 1, "b": 1, "a": 1}}]}])
+ ),
+ rect("Angular", json!([{"type": "GRADIENT_ANGULAR", "gradientHandlePositions": [{"x": 0.5, "y": 0.5}, {"x": 1, "y": 0.5}]}])),
+ rect("Diamond", json!([{"type": "GRADIENT_DIAMOND"}])),
+ rect(
+ "Image",
+ json!([{"type": "IMAGE", "imageRef": "abc123", "scaleMode": "TILE", "scalingFactor": 0.5, "rotation": 90,
+ "filters": {"exposure": 0.25, "saturation": -2}}])
+ ),
+ rect("Image2", json!([{"type": "IMAGE", "imageRef": "abc123", "scaleMode": "STRETCH", "imageTransform": [[0.5, 0, 0.25], [0, 0.5, 0.25]]}])),
+ rect("Emoji", json!([{"type": "EMOJI"}, solid(0.0, 1.0, 0.0)]))
+ ]));
+ let s = &named(&doc, "Solid").fills[0];
+ assert!(matches!(s.kind, PaintKind::Solid { color } if color.r == 1.0 && color.a == 1.0));
+ assert!(close(f64::from(s.opacity), 0.25), "paint opacity Γ colour alpha");
+ assert!(!s.visible && s.blend == BlendMode::Screen);
+ let PaintKind::Gradient(g) = &named(&doc, "Linear").fills[0].kind else { panic!() };
+ assert_eq!((g.kind, g.from, g.to, g.width), (GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0)));
+ assert_eq!(g.stops.len(), 2);
+ assert_eq!(g.stops[1].color.a, 0.0);
+ let PaintKind::Gradient(r) = &named(&doc, "Radial").fills[0].kind else { panic!() };
+ assert_eq!((r.kind, r.from, r.to, r.width), (GradientKind::Radial, (0.5, 0.5), (1.0, 0.5), (0.5, 1.0)));
+ let PaintKind::Gradient(a) = &named(&doc, "Angular").fills[0].kind else { panic!() };
+ assert_eq!(a.kind, GradientKind::Angular);
+ assert_eq!(a.width, (0.5, 1.0), "missing width handle: perpendicular");
+ assert!(matches!(&named(&doc, "Diamond").fills[0].kind, PaintKind::Gradient(d) if d.kind == GradientKind::Diamond && !d.stops.is_empty()));
+ let PaintKind::Image(img) = &named(&doc, "Image").fills[0].kind else { panic!() };
+ assert_eq!(img.image, "abc123", "the imageRef names the placeholder");
+ assert_eq!((img.scale_mode, img.scale, img.rotation), (ImageScaleMode::Tile, 0.5, 1));
+ assert!(close(f64::from(img.filters.exposure), 0.25) && img.filters.saturation == -1.0);
+ assert!(doc.images.get("abc123").is_some_and(|i| i.width == PLACEHOLDER_SIDE), "grey placeholder stored");
+ let PaintKind::Image(crop) = &named(&doc, "Image2").fills[0].kind else { panic!() };
+ assert_eq!(crop.scale_mode, ImageScaleMode::Crop);
+ assert_eq!(crop.crop, Some([0.5, 0.0, 0.0, 0.5, 0.25, 0.25]));
+ assert_eq!(named(&doc, "Emoji").fills.len(), 1, "unsupported paints are skipped");
+ assert!(warnings.iter().any(|w| w.contains("EMOJI")), "{warnings:?}");
+ assert!(warnings.iter().any(|w| w.starts_with("1 image without image data shows")), "{warnings:?}");
+}
+
+#[test]
+fn image_bytes_come_from_the_resolver() {
+ let png = {
+ let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([0, 0, 255, 255]));
+ let mut out = Vec::new();
+ img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
+ out
+ };
+ let v = file(json!([page(
+ "P",
+ json!([{"id": "1", "name": "I", "type": "RECTANGLE", "size": {"x": 3, "y": 2}, "relativeTransform": at(0.0, 0.0),
+ "fills": [{"type": "IMAGE", "imageRef": "ref1", "scaleMode": "FIT"}]}])
+ )]));
+ let resolver = |r: &str| (r == "ref1").then(|| png.clone());
+ let (doc, warnings) = import(&v, &resolver).unwrap();
+ let PaintKind::Image(img) = &named(&doc, "I").fills[0].kind else { panic!() };
+ assert_eq!(img.scale_mode, ImageScaleMode::Fit);
+ assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((3, 2)));
+ assert!(warnings.is_empty(), "{warnings:?}");
+}
+
+#[test]
+fn strokes_and_effects() {
+ let (doc, warnings) = one(json!([
+ {"id": "1", "name": "S", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
+ "strokes": [solid(0.0, 0.0, 0.0)], "strokeWeight": 3, "strokeAlign": "OUTSIDE", "strokeCap": "ROUND", "strokeJoin": "BEVEL",
+ "strokeDashes": [4, 2], "strokeMiterAngle": 60, "individualStrokeWeights": {"top": 1, "right": 2, "bottom": 3, "left": 4},
+ "effects": [
+ {"type": "DROP_SHADOW", "color": {"r": 0, "g": 0, "b": 0, "a": 0.5}, "offset": {"x": 2, "y": 6}, "radius": 8, "spread": 3,
+ "blendMode": "NORMAL", "showShadowBehindNode": true, "visible": true},
+ {"type": "INNER_SHADOW", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "offset": {"x": 0, "y": 1}, "radius": 2, "visible": false},
+ {"type": "LAYER_BLUR", "radius": 5},
+ {"type": "BACKGROUND_BLUR", "radius": 9},
+ {"type": "SPARKLE", "radius": 1}
+ ]},
+ {"id": "2", "name": "L", "type": "LINE", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 0}, "strokes": [solid(1.0, 0.0, 0.0)],
+ "strokeCap": "TRIANGLE_FILLED", "strokeWeight": 2}
+ ]));
+ let s = named(&doc, "S");
+ assert_eq!(s.stroke.weight, 3.0);
+ assert_eq!(s.stroke.align, StrokeAlign::Outside);
+ assert_eq!((s.stroke.cap_start, s.stroke.cap_end), (StrokeCap::Round, StrokeCap::Round));
+ assert_eq!(s.stroke.join, StrokeJoin::Bevel);
+ assert_eq!(s.stroke.dashes, vec![4.0, 2.0]);
+ assert!(close(s.stroke.miter_limit, 2.0), "60Β° β 1/sin(30Β°)");
+ assert_eq!(s.stroke.sides, Some([1.0, 2.0, 3.0, 4.0]));
+ assert_eq!(s.effects.len(), 4);
+ let EffectKind::DropShadow(d) = &s.effects[0].kind else { panic!() };
+ assert_eq!((d.offset, d.radius, d.spread, d.show_behind), ((2.0, 6.0), 8.0, 3.0, true));
+ assert!(close(f64::from(d.color.a), 0.5));
+ assert!(matches!(&s.effects[1].kind, EffectKind::InnerShadow(i) if i.radius == 2.0) && !s.effects[1].visible);
+ assert!(matches!(s.effects[2].kind, EffectKind::LayerBlur { radius } if radius == 5.0));
+ assert!(matches!(s.effects[3].kind, EffectKind::BackgroundBlur { radius } if radius == 9.0));
+ assert!(warnings.iter().any(|w| w.contains("SPARKLE")), "{warnings:?}");
+ let l = named(&doc, "L");
+ assert!(matches!(l.kind, NodeKind::Line));
+ assert_eq!((l.width, l.height), (100.0, 0.0));
+ assert_eq!(l.stroke.cap_end, StrokeCap::TriangleArrow);
+ assert_eq!(l.stroke.align, StrokeAlign::Center, "lines keep their centred stroke");
+}
+
+#[test]
+fn vectors_ellipses_stars_and_booleans() {
+ let base = |name: &str, ty: &str| json!({"id": name, "name": name, "type": ty, "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}});
+ let mut tri = base("Tri", "VECTOR");
+ tri["fills"] = json!([solid(1.0, 0.0, 0.0)]);
+ tri["fillGeometry"] = json!([{"path": "M0 0L10 0L10 10Z", "windingRule": "EVENODD"}]);
+ let mut wire = base("Wire", "VECTOR");
+ wire["fills"] = json!([]);
+ wire["strokes"] = json!([solid(0.0, 0.0, 1.0)]);
+ wire["strokeWeight"] = json!(2);
+ wire["strokeGeometry"] = json!([{"path": "M0 0L10 0L10 2L0 2Z", "windingRule": "NONZERO"}]);
+ let mut empty = base("Empty", "VECTOR");
+ empty["fillGeometry"] = json!([{"path": "M 0 0 L x y"}]);
+ let mut arc = base("Arc", "ELLIPSE");
+ arc["arcData"] = json!({"startingAngle": 0, "endingAngle": std::f64::consts::PI, "innerRadius": 0.5});
+ let mut star = base("Star", "STAR");
+ star["fillGeometry"] = json!([{"path": "M5 0L6 4L10 4L7 6L8 10L5 7L2 10L3 6L0 4L4 4Z"}]);
+ let plain_star = base("PlainStar", "STAR");
+ let mut counted = base("Counted", "REGULAR_POLYGON");
+ counted["pointCount"] = json!(6);
+ let mut boolean = base("Bool", "BOOLEAN_OPERATION");
+ boolean["booleanOperation"] = json!("SUBTRACT");
+ boolean["children"] =
+ json!([base("A", "RECTANGLE"), {"id": "B", "name": "B", "type": "ELLIPSE", "relativeTransform": at(5.0, 5.0), "size": {"x": 10, "y": 10}}]);
+ let (doc, warnings) = one(json!([tri, wire, empty, arc, star, plain_star, counted, boolean]));
+ let NodeKind::Vector { network } = &named(&doc, "Tri").kind else { panic!() };
+ assert_eq!(network.vertices.len(), 3);
+ assert_eq!(network.regions.len(), 1);
+ assert_eq!(network.regions[0].winding, WindingRule::EvenOdd);
+ let w = named(&doc, "Wire");
+ assert!(matches!(&w.kind, NodeKind::Vector { network } if !network.regions.is_empty()), "stroke outline becomes the shape");
+ assert!(w.strokes.is_empty() && matches!(w.fills[0].kind, PaintKind::Solid { color } if color.b == 1.0));
+ assert!(matches!(&named(&doc, "Empty").kind, NodeKind::Vector { network } if network.vertices.is_empty()));
+ assert!(warnings.iter().any(|w| w.contains("invalid or oversized vector path")), "{warnings:?}");
+ let NodeKind::Ellipse { start, sweep, inner } = named(&doc, "Arc").kind else { panic!() };
+ assert!(close(start, 0.0) && close(sweep, std::f64::consts::PI) && close(inner, 0.5));
+ assert!(matches!(named(&doc, "Star").kind, NodeKind::Vector { .. }), "stars with geometry keep their exact outline");
+ assert!(matches!(named(&doc, "PlainStar").kind, NodeKind::Star { count: 5, .. }));
+ assert!(matches!(named(&doc, "Counted").kind, NodeKind::Polygon { count: 6 }));
+ let b = named(&doc, "Bool");
+ assert!(matches!(b.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }));
+ assert_eq!(b.children.len(), 2);
+ assert!(close(b.width, 15.0), "booleans wrap their children");
+}
+
+#[test]
+fn text_styles_and_runs() {
+ let (doc, warnings) = one(json!([
+ {"id": "1", "name": "Title", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 40},
+ "characters": "a\u{1F600}b c",
+ "fills": [solid(0.2, 0.2, 0.2)],
+ "style": {"fontFamily": "Inter", "fontPostScriptName": "Inter-Regular", "fontStyle": "Regular", "fontWeight": 400, "fontSize": 24,
+ "lineHeightPx": 29, "lineHeightUnit": "INTRINSIC_%", "letterSpacing": 0, "textAlignHorizontal": "CENTER",
+ "textAlignVertical": "CENTER", "textAutoResize": "HEIGHT", "textCase": "UPPER", "textDecoration": "UNDERLINE",
+ "paragraphSpacing": 8, "paragraphIndent": 4, "openTypeFlags": {"LIGA": 0, "ss01": 1}},
+ // UTF-16 code units: the emoji takes two.
+ "characterStyleOverrides": [0, 1, 1, 2],
+ "styleOverrideTable": {"1": {"fontWeight": 700}, "2": {"fills": [solid(1.0, 0.0, 0.0)], "hyperlink": {"type": "URL", "url": "https://example.com"}}},
+ "lineTypes": ["UNORDERED"]},
+ {"id": "2", "name": "Body", "type": "TEXT", "relativeTransform": at(0.0, 50.0), "size": {"x": 100, "y": 20}, "characters": "xy",
+ "style": {"fontFamily": "Roboto", "fontWeight": 600, "italic": true, "fontSize": 16, "lineHeightPx": 32, "lineHeightUnit": "PIXELS",
+ "letterSpacing": 1.5, "textTruncation": "ENDING", "maxLines": 2},
+ "characterStyleOverrides": [0, 3], "styleOverrideTable": {"3": {"lineHeightPercentFontSize": 150, "lineHeightUnit": "FONT_SIZE_%", "textCase": "SMALL_CAPS_FORCED", "textDecoration": "STRIKETHROUGH"}}},
+ {"id": "3", "name": "NoFills", "type": "TEXT", "relativeTransform": at(0.0, 80.0), "size": {"x": 10, "y": 10}, "characters": "z", "style": {}}
+ ]));
+ assert!(warnings.is_empty(), "{warnings:?}");
+ let t = named(&doc, "Title");
+ let NodeKind::Text(td) = &t.kind else { panic!() };
+ assert_eq!(td.characters, "a\u{1F600}b c");
+ assert_eq!((td.style.font_family.as_str(), td.style.font_style.as_str(), td.style.font_size), ("Inter", "Regular", 24.0));
+ assert_eq!(td.style.line_height, LineHeight::Auto);
+ assert_eq!(td.style.letter_spacing, LetterSpacing::Percent(0.0));
+ assert_eq!((td.style.case, td.style.decoration), (TextCase::Upper, TextDecoration::Underline));
+ assert_eq!((td.align_h, td.align_v, td.auto_resize), (TextAlignH::Center, TextAlignV::Middle, TextAutoResize::Height));
+ assert_eq!((td.paragraph_spacing, td.paragraph_indent), (8.0, 4.0));
+ assert_eq!(td.features.get("liga"), Some(&false));
+ assert_eq!(td.features.get("ss01"), Some(&true));
+ assert_eq!(td.list, ListStyle::Bulleted);
+ assert_eq!(td.runs.len(), 2, "{:?}", td.runs);
+ assert_eq!((td.runs[0].start, td.runs[0].end, td.runs[0].style.font_style.as_str()), (1, 2, "Bold"));
+ assert_eq!((td.runs[1].start, td.runs[1].end), (2, 3));
+ assert!(td.runs[1].style.fills.as_ref().is_some_and(|f| matches!(f[0].kind, PaintKind::Solid { color } if color.r == 1.0)));
+ assert_eq!(td.runs[1].style.link.as_deref(), Some("https://example.com"));
+ assert!(matches!(t.fills[0].kind, PaintKind::Solid { color } if (color.r - 0.2).abs() < 1e-6));
+ let NodeKind::Text(body) = &named(&doc, "Body").kind else { panic!() };
+ assert_eq!(body.style.font_style, "Semi Bold Italic");
+ assert_eq!(body.style.line_height, LineHeight::Pixels(32.0));
+ assert_eq!(body.style.letter_spacing, LetterSpacing::Pixels(1.5));
+ assert_eq!((body.auto_resize, body.max_lines), (TextAutoResize::Truncate, 2));
+ assert_eq!(body.runs.len(), 1);
+ assert_eq!(body.runs[0].style.line_height, LineHeight::Percent(150.0));
+ assert_eq!((body.runs[0].style.case, body.runs[0].style.decoration), (TextCase::SmallCaps, TextDecoration::Strikethrough));
+ assert_eq!(body.runs[0].style.font_style, "Semi Bold Italic", "overrides inherit the base");
+ assert!(!named(&doc, "NoFills").fills.is_empty(), "text keeps its default fill");
+}
+
+#[test]
+fn auto_layout_constraints_and_grids() {
+ let (doc, _) = one(json!([
+ {"id": "1", "name": "Stack", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 300},
+ "layoutMode": "VERTICAL", "primaryAxisAlignItems": "SPACE_BETWEEN", "counterAxisAlignItems": "CENTER",
+ "paddingTop": 1, "paddingRight": 2, "paddingBottom": 3, "paddingLeft": 4, "itemSpacing": 8, "counterAxisSpacing": 5,
+ "layoutWrap": "WRAP", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "FIXED", "strokesIncludedInLayout": true,
+ "itemReverseZIndex": true,
+ "layoutGrids": [{"pattern": "COLUMNS", "sectionSize": 20, "visible": true, "color": {"r": 1, "g": 0, "b": 0, "a": 0.1},
+ "alignment": "CENTER", "gutterSize": 12, "offset": 3, "count": 4},
+ {"pattern": "GRID", "sectionSize": 8, "visible": false, "color": {"r": 0, "g": 0, "b": 1, "a": 0.2}}],
+ "children": [
+ {"id": "2", "name": "Grow", "type": "RECTANGLE", "relativeTransform": at(4.0, 1.0), "size": {"x": 94, "y": 50}, "layoutGrow": 1, "layoutAlign": "STRETCH"},
+ {"id": "3", "name": "Abs", "type": "FRAME", "relativeTransform": at(10.0, 10.0), "size": {"x": 30, "y": 30}, "layoutPositioning": "ABSOLUTE",
+ "layoutSizingHorizontal": "HUG", "minWidth": 10, "maxWidth": 200, "minHeight": 5, "maxHeight": null,
+ "constraints": {"horizontal": "LEFT_RIGHT", "vertical": "BOTTOM"}}
+ ]},
+ {"id": "4", "name": "Grid", "type": "FRAME", "relativeTransform": at(200.0, 0.0), "size": {"x": 300, "y": 200},
+ "layoutMode": "GRID", "gridColumnCount": 3, "gridRowCount": 2, "gridColumnGap": 4, "gridRowGap": 6,
+ "children": [{"id": "5", "name": "Cell", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
+ "gridColumnAnchorIndex": 1, "gridRowAnchorIndex": 0, "gridColumnSpan": 2, "gridRowSpan": 1}]}
+ ]));
+ let stack = named(&doc, "Stack");
+ let al = stack.layout.as_ref().unwrap();
+ assert_eq!(al.mode, LayoutMode::Vertical);
+ assert_eq!((al.main_align, al.cross_align), (MainAlign::SpaceBetween, CrossAlign::Center));
+ assert_eq!(al.padding, [1.0, 2.0, 3.0, 4.0]);
+ assert_eq!((al.gap, al.cross_gap, al.wrap, al.strokes_included, al.reverse_z), (8.0, 5.0, true, true, true));
+ assert_eq!((stack.child_layout.sizing_h, stack.child_layout.sizing_v), (Sizing::Fixed, Sizing::Hug), "vertical: primary axis hugs");
+ assert_eq!(stack.grids.len(), 2);
+ assert_eq!((stack.grids[0].kind, stack.grids[0].count, stack.grids[0].gutter, stack.grids[0].offset), (GridKind::Columns, 4, 12.0, 3.0));
+ assert_eq!(stack.grids[0].align, figcraft_doc::GridAlign::Center);
+ assert!(!stack.grids[1].visible && stack.grids[1].size == 8.0);
+ let grow = named(&doc, "Grow");
+ assert_eq!((grow.child_layout.sizing_h, grow.child_layout.sizing_v), (Sizing::Fill, Sizing::Fill));
+ let abs = named(&doc, "Abs");
+ assert!(abs.child_layout.absolute);
+ assert_eq!(abs.child_layout.sizing_h, Sizing::Hug);
+ assert_eq!(
+ (abs.child_layout.min_w, abs.child_layout.max_w, abs.child_layout.min_h, abs.child_layout.max_h),
+ (Some(10.0), Some(200.0), Some(5.0), None)
+ );
+ assert_eq!((abs.constraints.horizontal, abs.constraints.vertical), (HConstraint::LeftRight, VConstraint::Bottom));
+ let grid = named(&doc, "Grid").layout.clone().unwrap();
+ assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (LayoutMode::Grid, 3, 2, 4.0, 6.0));
+ let cell = &named(&doc, "Cell").child_layout;
+ assert_eq!((cell.grid_column, cell.grid_row, cell.grid_column_span, cell.grid_row_span), (Some(1), Some(0), 2, 1));
+}
+
+fn components_file() -> Value {
+ json!({
+ "name": "Kit",
+ "document": {"id": "0:0", "type": "DOCUMENT", "children": [page("Components", json!([
+ {"id": "1:1", "name": "Button", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 100},
+ "strokes": [solid(0.6, 0.3, 1.0)], "strokeDashes": [10, 5],
+ "componentPropertyDefinitions": {
+ "State": {"type": "VARIANT", "defaultValue": "Default", "variantOptions": ["Default", "Hover"]},
+ "Label#1:9": {"type": "TEXT", "defaultValue": "OK"},
+ "Icon#2:0": {"type": "INSTANCE_SWAP", "defaultValue": "3:1", "preferredValues": []}},
+ "children": [
+ {"id": "1:2", "name": "State=Default", "type": "COMPONENT", "relativeTransform": at(10.0, 10.0), "size": {"x": 80, "y": 40},
+ "children": [{"id": "1:3", "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 30, "y": 20},
+ "characters": "OK", "style": {"fontSize": 14}, "componentPropertyReferences": {"characters": "Label#1:9"}}]},
+ {"id": "1:4", "name": "State=Hover", "type": "COMPONENT", "relativeTransform": at(110.0, 10.0), "size": {"x": 80, "y": 40}}
+ ]},
+ {"id": "3:1", "name": "Icon", "type": "COMPONENT", "relativeTransform": at(300.0, 0.0), "size": {"x": 24, "y": 24},
+ "componentPropertyDefinitions": {"Show#4:1": {"type": "BOOLEAN", "defaultValue": true}}},
+ {"id": "5:1", "name": "Button", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(0.0, 200.0), "size": {"x": 80, "y": 40},
+ "componentProperties": {"Label#1:9": {"type": "TEXT", "value": "Go"}, "State": {"type": "VARIANT", "value": "Default"},
+ "Icon#2:0": {"type": "INSTANCE_SWAP", "value": "3:1"}},
+ "overrides": [{"id": "I5:1;1:3", "overriddenFields": ["characters", "fills", "unknownField"]}],
+ "children": [{"id": "I5:1;1:3", "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 30, "y": 20},
+ "characters": "Go", "style": {"fontSize": 14}, "componentPropertyReferences": {"characters": "Label#1:9"}}]},
+ {"id": "6:1", "name": "Remote", "type": "INSTANCE", "componentId": "99:1", "relativeTransform": at(0.0, 300.0), "size": {"x": 10, "y": 10}}
+ ]))]},
+ "components": {"1:2": {"key": "k1", "name": "State=Default", "description": "Primary button", "componentSetId": "1:1"},
+ "3:1": {"key": "k3", "name": "Icon", "description": "An icon"},
+ "99:1": {"key": "k99", "name": "Remote", "description": "", "remote": true}},
+ "componentSets": {"1:1": {"key": "s1", "name": "Button", "description": "Buttons"}},
+ "styles": {}
+ })
+}
+
+#[test]
+fn components_sets_instances_and_properties() {
+ let (doc, warnings) = imp(&components_file());
+ let set = named(&doc, "Button");
+ assert!(matches!(&set.kind, NodeKind::ComponentSet { description } if description == "Buttons"));
+ assert_eq!(set.stroke.dashes, vec![10.0, 5.0]);
+ let def = named(&doc, "State=Default");
+ let icon = named(&doc, "Icon");
+ let NodeKind::Component { description, props } = &def.kind else { panic!() };
+ assert_eq!(description, "Primary button");
+ assert_eq!(props.get("Label"), Some(&PropDef::Text { default: "OK".into() }), "{props:?}");
+ assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: icon.id }));
+ assert!(!props.contains_key("State"), "variant properties come from the variant names");
+ let NodeKind::Component { props: hover_props, .. } = &named(&doc, "State=Hover").kind else { panic!() };
+ assert!(hover_props.contains_key("Label"), "variants share the set's definitions");
+ assert!(
+ matches!(&icon.kind, NodeKind::Component { description, props } if description == "An icon" && props.get("Show") == Some(&PropDef::Boolean { default: true }))
+ );
+ let label = doc.node(def.children[0]).unwrap();
+ assert_eq!(label.prop_refs.get("characters").map(String::as_str), Some("Label"));
+ let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap();
+ let NodeKind::Instance { component, props } = &inst.kind else { panic!() };
+ assert_eq!(*component, def.id);
+ assert_eq!(props.get("Label"), Some(&json!("Go")));
+ assert_eq!(props.get("Icon"), Some(&json!(icon.id.0)));
+ assert!(!props.contains_key("State"));
+ let inst_label = doc.node(inst.children[0]).unwrap();
+ assert!(inst_label.overrides.contains("characters") && inst_label.overrides.contains("fills") && inst_label.overrides.len() == 2);
+ assert!(matches!(named(&doc, "Remote").kind, NodeKind::Frame));
+ assert!(warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
+}
+
+#[test]
+fn component_cycles_stay_frames() {
+ let comp = |id: &str, name: &str, inst_of: &str| {
+ json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
+ "children": [{"id": format!("{id}i"), "name": format!("{name} inner"), "type": "INSTANCE", "componentId": inst_of,
+ "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}}]})
+ };
+ let (doc, warnings) = one(json!([comp("a", "A", "b"), comp("b", "B", "a"), comp("c", "C", "c"), comp("d", "D", "a")]));
+ for n in ["A inner", "B inner", "C inner"] {
+ assert!(matches!(named(&doc, n).kind, NodeKind::Frame), "{n}");
+ }
+ // D uses A, which doesn't lead back to D: a fine instance.
+ assert!(matches!(named(&doc, "D inner").kind, NodeKind::Instance { .. }));
+ assert!(warnings.iter().any(|w| w.contains("inside itself")), "{warnings:?}");
+ assert!(warnings.iter().any(|w| w.contains("inside their own main")), "{warnings:?}");
+}
+
+#[test]
+fn shared_styles_link_with_metadata_names() {
+ let mut v = file(json!([page(
+ "P",
+ json!([
+ {"id": "1", "name": "R", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
+ "fills": [solid(0.0, 0.5, 1.0)], "effects": [{"type": "LAYER_BLUR", "radius": 2}], "styles": {"fill": "S:1", "effect": "S:4"}},
+ {"id": "2", "name": "T", "type": "TEXT", "relativeTransform": at(0.0, 20.0), "size": {"x": 10, "y": 10}, "characters": "Hi",
+ "style": {"fontFamily": "Inter", "fontSize": 32}, "styles": {"text": "S:2"}}
+ ])
+ )]));
+ v["styles"] = json!({"S:1": {"key": "a", "name": "Brand/Primary", "styleType": "FILL", "description": "Main"},
+ "S:2": {"key": "b", "name": "Heading", "styleType": "TEXT"},
+ "S:3": {"key": "c", "name": "Unused", "styleType": "FILL"},
+ "S:4": {"key": "d", "name": "Soft", "styleType": "EFFECT"}});
+ let (doc, warnings) = imp(&v);
+ assert_eq!(doc.styles.len(), 3);
+ let fill = doc.style("S:1").unwrap();
+ assert_eq!((fill.name.as_str(), fill.description.as_str()), ("Brand/Primary", "Main"));
+ assert!(matches!(&fill.kind, StyleKind::Paint { paints } if paints.len() == 1));
+ assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style } if style.font_size == 32.0));
+ assert!(matches!(&doc.style("Soft").unwrap().kind, StyleKind::Effect { effects } if effects.len() == 1));
+ let r = named(&doc, "R");
+ assert_eq!((r.styles.fill.as_deref(), r.styles.effect.as_deref()), (Some("S:1"), Some("S:4")));
+ assert_eq!(named(&doc, "T").styles.text.as_deref(), Some("S:2"));
+ assert!(warnings.iter().any(|w| w.starts_with("1 style that no layer uses was skipped")), "{warnings:?}");
+}
+
+#[test]
+fn nodes_response_flows_and_detection() {
+ let v = json!({"name": "Partial", "nodes": {
+ "1:2": {"document": {"id": "1:2", "name": "Screen", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 50, "y": 40},
+ "absoluteBoundingBox": {"x": 500, "y": 300, "width": 50, "height": 40}}, "components": {}, "styles": {}},
+ "9:9": null}});
+ assert!(is_rest_json(&v));
+ let (doc, _) = imp(&v);
+ let screen = named(&doc, "Screen");
+ let b = doc.world_bounds(screen.id).unwrap();
+ assert_eq!((b.x0, b.y0), (500.0, 300.0), "roots are placed by their absolute bounds");
+ assert_eq!(doc.node(doc.page_of(screen.id).unwrap()).unwrap().name, "Imported layers");
+
+ let mut f = file(json!([page(
+ "Flows",
+ json!([{"id": "7:1", "name": "Home", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
+ "interactions": [{"trigger": {"type": "ON_CLICK"}, "actions": []}], "boundVariables": {"width": {"type": "VARIABLE_ALIAS", "id": "v"}}}])
+ )]));
+ f["document"]["children"][0]["flowStartingPoints"] = json!([{"nodeId": "7:1", "name": "Onboarding"}, {"nodeId": "nope", "name": "X"}]);
+ let (doc, warnings) = imp(&f);
+ let NodeKind::Page { flows, .. } = &doc.node(doc.pages[0]).unwrap().kind else { panic!() };
+ assert_eq!(flows.len(), 1);
+ assert_eq!((flows[0].name.as_str(), flows[0].start), ("Onboarding", named(&doc, "Home").id));
+ assert!(warnings.iter().any(|w| w.contains("interactions")) && warnings.iter().any(|w| w.contains("variable bindings")), "{warnings:?}");
+
+ // Native FigCraft JSON and other text are left to the other loaders.
+ let native = serde_json::to_vec(&crate::to_json(&Document::new("x")).unwrap()).unwrap();
+ assert!(try_import(&native, &no_images).is_none());
+ assert!(try_import(b"PK\x03\x04", &no_images).is_none());
+ assert!(try_import(b"{\"document\": {\"type\": \"FRAME\"}}", &no_images).is_none());
+ assert!(matches!(try_import(b"{\"document\": {\"type\": \"DOCUMENT\"", &no_images), Some(Err(_))));
+ assert!(import(&json!({"nodes": {}}), &no_images).is_err());
+}
+
+#[test]
+fn unknown_types_and_missing_fields_warn() {
+ let (doc, warnings) = one(json!([
+ {"id": "1", "type": "STICKY", "name": "Note", "characters": "hi"},
+ {"id": "2", "name": "NoType"},
+ {"id": "3", "name": "Bare", "type": "RECTANGLE"},
+ {"id": "4", "name": "Leafy", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 1, "y": 1},
+ "children": [{"id": "5", "name": "Orphan", "type": "RECTANGLE"}]},
+ {"id": "6", "name": "Bare2", "type": "ELLIPSE"}
+ ]));
+ assert_eq!(doc.node_count(), 4, "page + Bare + Leafy + Bare2");
+ for w in ["STICKY layers", "without a type", "without a position", "children of RECTANGLE"] {
+ assert!(warnings.iter().any(|x| x.contains(w)), "{w}: {warnings:?}");
+ }
+ assert!(warnings.iter().any(|x| x.ends_with("(Γ2)")), "repeats are counted: {warnings:?}");
+}
+
+// ---- robustness ----
+
+/// Every value of `v` (depth first), as JSON pointers.
+fn pointers(v: &Value, at: String, out: &mut Vec) {
+ out.push(at.clone());
+ match v {
+ Value::Object(m) => m.iter().for_each(|(k, c)| pointers(c, format!("{at}/{}", k.replace('~', "~0").replace('/', "~1")), out)),
+ Value::Array(a) => a.iter().enumerate().for_each(|(i, c)| pointers(c, format!("{at}/{i}"), out)),
+ _ => {}
+ }
+}
+
+#[test]
+fn junk_values_never_panic() {
+ let junk = [
+ json!(null),
+ json!("NaN"),
+ json!("Infinity"),
+ json!(1e308),
+ json!(-1e308),
+ json!(-1),
+ json!([]),
+ json!({}),
+ json!(true),
+ json!("x"),
+ json!([[1e308, 0, 0], [0, 1, 0]]),
+ ];
+ let base = kitchen_sink();
+ let mut ptrs = Vec::new();
+ pointers(&base, String::new(), &mut ptrs);
+ for p in ptrs.iter().filter(|p| !p.is_empty()) {
+ for j in &junk {
+ let mut v = base.clone();
+ if let Some(slot) = v.pointer_mut(p) {
+ *slot = j.clone();
+ }
+ if let Ok((doc, _)) = import(&v, &no_images) {
+ assert!(doc.validate().is_empty(), "{p} = {j}");
+ for n in doc.nodes() {
+ assert!(n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{p} = {j}");
+ }
+ }
+ }
+ }
+}
+
+/// The components fixture plus one layer of every other mapped kind.
+fn kitchen_sink() -> Value {
+ let mut v = components_file();
+ let extra = json!([
+ {"id": "k1", "name": "Styled", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "styles": {"fill": "S:1"},
+ "fills": [{"type": "GRADIENT_LINEAR", "gradientHandlePositions": [{"x": 0, "y": 0}, {"x": 1, "y": 1}, {"x": 0, "y": 1}],
+ "gradientStops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]},
+ {"type": "IMAGE", "imageRef": "r", "scaleMode": "STRETCH", "imageTransform": [[1, 0, 0], [0, 1, 0]], "filters": {"tint": 0.5}}],
+ "strokes": [solid(0.0, 0.0, 0.0)], "strokeWeight": 2, "individualStrokeWeights": {"top": 1, "right": 1, "bottom": 1, "left": 1},
+ "strokeDashes": [1, 2], "strokeMiterAngle": 30,
+ "effects": [{"type": "DROP_SHADOW", "color": {"r": 0, "g": 0, "b": 0, "a": 1}, "offset": {"x": 1, "y": 1}, "radius": 1, "spread": 1}],
+ "rectangleCornerRadii": [1, 2, 3, 4], "constraints": {"horizontal": "SCALE", "vertical": "CENTER"},
+ "exportSettings": [{"suffix": "", "format": "JPG", "constraint": {"type": "HEIGHT", "value": 10}}]},
+ {"id": "k2", "name": "Txt", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "characters": "ab\u{1F600}c",
+ "style": {"fontFamily": "Inter", "fontWeight": 500, "fontSize": 12, "lineHeightPx": 14, "lineHeightUnit": "PIXELS", "letterSpacing": 1,
+ "openTypeFlags": {"liga": 1}, "hyperlink": {"type": "URL", "url": "https://example.com"}},
+ "characterStyleOverrides": [1, 1, 2, 2, 1], "styleOverrideTable": {"1": {"fontStyle": "Bold"}, "2": {"fills": [solid(1.0, 0.0, 0.0)]}},
+ "lineTypes": ["ORDERED", "NONE"]},
+ {"id": "k3", "name": "Vec", "type": "VECTOR", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "fills": [solid(0.0, 1.0, 0.0)],
+ "fillGeometry": [{"path": "M0 0C5 0 10 5 10 10L0 10Z", "windingRule": "NONZERO"}],
+ "strokeGeometry": [{"path": "M0 0L1 0L1 1Z", "windingRule": "EVENODD"}]},
+ {"id": "k4", "name": "Auto", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 100}, "layoutMode": "HORIZONTAL",
+ "layoutWrap": "WRAP", "itemSpacing": 4, "counterAxisSpacing": 4, "paddingLeft": 2, "primaryAxisSizingMode": "AUTO",
+ "layoutGrids": [{"pattern": "ROWS", "sectionSize": 10, "count": 3, "gutterSize": 2, "alignment": "MIN", "offset": 1, "visible": true,
+ "color": {"r": 0, "g": 0, "b": 1, "a": 0.1}}],
+ "children": [
+ {"id": "k5", "name": "G", "type": "GROUP", "relativeTransform": at(1.0, 1.0), "size": {"x": 5, "y": 5}, "layoutGrow": 1,
+ "absoluteBoundingBox": {"x": 1, "y": 1, "width": 5, "height": 5},
+ "children": [{"id": "k6", "name": "E", "type": "ELLIPSE", "relativeTransform": at(1.0, 1.0), "size": {"x": 5, "y": 5},
+ "absoluteBoundingBox": {"x": 1, "y": 1, "width": 5, "height": 5},
+ "arcData": {"startingAngle": 1, "endingAngle": 2, "innerRadius": 0.2}}]},
+ {"id": "k7", "name": "B", "type": "BOOLEAN_OPERATION", "booleanOperation": "EXCLUDE", "relativeTransform": at(0.0, 0.0),
+ "size": {"x": 5, "y": 5}, "children": [{"id": "k8", "name": "S", "type": "STAR", "relativeTransform": at(0.0, 0.0), "size": {"x": 5, "y": 5}}]}
+ ]},
+ {"id": "k9", "name": "Sec", "type": "SECTION", "sectionContentsHidden": true, "relativeTransform": at(0.0, 0.0), "size": {"x": 50, "y": 50},
+ "children": [{"id": "k10", "name": "Use", "type": "INSTANCE", "componentId": "3:1", "relativeTransform": at(0.0, 0.0), "size": {"x": 24, "y": 24},
+ "componentProperties": {"Show#4:1": {"type": "BOOLEAN", "value": false}}}]}
+ ]);
+ if let (Some(kids), Some(more)) = (v.pointer_mut("/document/children/0/children").and_then(Value::as_array_mut), extra.as_array()) {
+ kids.extend(more.iter().cloned());
+ }
+ v["styles"] = json!({"S:1": {"key": "a", "name": "Fill", "styleType": "FILL"}});
+ v["document"]["children"][0]["flowStartingPoints"] = json!([{"nodeId": "5:1", "name": "Flow"}]);
+ v
+}
+
+#[test]
+fn kitchen_sink_imports_cleanly() {
+ let (doc, warnings) = imp(&kitchen_sink());
+ assert!(doc.validate().is_empty());
+ assert!(matches!(named(&doc, "Sec").kind, NodeKind::Section { contents_hidden: true }));
+ assert!(matches!(&named(&doc, "Use").kind, NodeKind::Instance { props, .. } if props.get("Show") == Some(&json!(false))));
+ assert!(matches!(named(&doc, "Txt").kind, NodeKind::Text(ref t) if t.list == ListStyle::Numbered && t.runs.len() == 3));
+ assert!(warnings.iter().all(|w| !w.contains("panick")), "{warnings:?}");
+}
+
+#[test]
+fn truncated_and_mutated_bytes_never_panic() {
+ let bytes = serde_json::to_vec(&components_file()).unwrap();
+ for cut in (0..bytes.len()).step_by(37) {
+ if let Some(Ok((doc, _))) = try_import(&bytes[..cut], &no_images) {
+ assert!(doc.validate().is_empty());
+ }
+ }
+ let mut seed = 0x9E37_79B9_7F4A_7C15u64;
+ let mut next = || {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed
+ };
+ for _ in 0..400 {
+ let mut b = bytes.clone();
+ for _ in 0..(next() % 8 + 1) {
+ let i = (next() as usize) % b.len();
+ b[i] = b"0123456789-.e{}[]\":,xNnul "[(next() as usize) % 26];
+ }
+ let _ = try_import(&b, &no_images);
+ }
+}
+
+#[test]
+fn deep_nesting_and_huge_values_are_capped() {
+ let mut node = json!({"id": "leaf", "name": "Leaf", "type": "RECTANGLE", "relativeTransform": at(1.0, 1.0), "size": {"x": 1, "y": 1}});
+ for i in 0..400 {
+ node = json!({"id": format!("g{i}"), "name": "G", "type": "FRAME", "relativeTransform": at(1.0, 1.0), "size": {"x": 1e300, "y": -5},
+ "cornerRadius": 1e300, "strokeWeight": 1e300, "itemSpacing": -1e300, "layoutMode": "HORIZONTAL", "children": [node]});
+ }
+ let (doc, warnings) = one(json!([node]));
+ assert!(doc.node_count() <= MAX_DEPTH + 3);
+ assert!(warnings.iter().any(|w| w.contains("nested deeper")), "{warnings:?}");
+ for n in doc.nodes() {
+ assert!(n.width <= MAX_SIZE && n.height >= 0.0 && n.corners.radii[0] <= MAX_SIZE && n.stroke.weight <= MAX_SIZE);
+ }
+ // serde_json's own recursion limit turns very deep text into an error, not a crash.
+ let deep = format!("{{\"document\":{{\"type\":\"DOCUMENT\",\"children\":{}{}}}}}", "[".repeat(5000), "]".repeat(5000));
+ assert!(matches!(try_import(deep.as_bytes(), &no_images), Some(Err(_))));
+}
+
+#[test]
+fn layer_count_is_capped() {
+ let kids: Vec = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect();
+ let (doc, warnings) = one(Value::Array(kids));
+ assert_eq!(doc.node_count(), MAX_NODES + 1);
+ assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
+}
diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs
new file mode 100644
index 0000000..6faaa33
--- /dev/null
+++ b/crates/format/src/rest/text.rs
@@ -0,0 +1,210 @@
+//! TEXT nodes: characters, the base `style` (TypeStyle) and per-character overrides
+//! (`characterStyleOverrides` + `styleOverrideTable`) β styled runs.
+
+use std::collections::BTreeMap;
+
+use figcraft_doc::{
+ LetterSpacing, LineHeight, ListStyle, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle,
+};
+use serde_json::Value;
+
+use super::Ctx;
+use super::props::{flag, len, list, num, paints, text};
+
+/// Most characters read from one text node.
+const MAX_CHARS: usize = 1 << 20;
+
+/// The text content of a TEXT node.
+pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData {
+ let raw = text(v, "characters").unwrap_or("");
+ let characters: String = raw.chars().take(MAX_CHARS).collect();
+ if characters.len() < raw.len() {
+ cx.warn("very long text was cut to 1,048,576 characters");
+ }
+ let style_json = v.get("style").unwrap_or(&Value::Null);
+ let base = type_style(cx, style_json, &TextStyle::default());
+ let mut td = TextData { characters, style: base, ..TextData::default() };
+ paragraph(style_json, &mut td);
+ td.runs = runs(cx, v, &td);
+ td.normalize();
+ td.list = list_style(v);
+ td
+}
+
+/// Node-level text settings that live in the base TypeStyle.
+fn paragraph(s: &Value, td: &mut TextData) {
+ td.align_h = match text(s, "textAlignHorizontal") {
+ Some("CENTER") => TextAlignH::Center,
+ Some("RIGHT") => TextAlignH::Right,
+ Some("JUSTIFIED") => TextAlignH::Justified,
+ _ => TextAlignH::Left,
+ };
+ td.align_v = match text(s, "textAlignVertical") {
+ Some("CENTER") => TextAlignV::Middle,
+ Some("BOTTOM") => TextAlignV::Bottom,
+ _ => TextAlignV::Top,
+ };
+ td.auto_resize = match text(s, "textAutoResize") {
+ Some("HEIGHT") => TextAutoResize::Height,
+ Some("WIDTH_AND_HEIGHT") => TextAutoResize::WidthAndHeight,
+ Some("TRUNCATE") => TextAutoResize::Truncate,
+ // Missing means a fixed box.
+ _ => TextAutoResize::None,
+ };
+ if text(s, "textTruncation") == Some("ENDING") {
+ td.auto_resize = TextAutoResize::Truncate;
+ }
+ td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
+ td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0);
+ td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0);
+ if let Some(flags) = s.get("openTypeFlags").and_then(Value::as_object) {
+ for (tag, on) in flags.iter().take(64) {
+ if figcraft_doc::valid_feature_tag(&tag.to_ascii_lowercase()) {
+ td.features.insert(tag.to_ascii_lowercase(), on.as_f64().is_some_and(|x| x != 0.0) || on.as_bool() == Some(true));
+ }
+ }
+ }
+}
+
+/// `base` with the fields `s` sets (override table entries only carry what differs).
+fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
+ let mut t = base.clone();
+ if let Some(f) = text(s, "fontFamily").filter(|f| !f.trim().is_empty()) {
+ t.font_family = super::props::clip_str(f).to_string();
+ }
+ if let Some(st) = text(s, "fontStyle").filter(|f| !f.trim().is_empty()) {
+ t.font_style = super::props::clip_str(st).to_string();
+ } else if s.get("fontWeight").is_some() || s.get("italic").is_some() {
+ let weight = num(s, "fontWeight").map_or_else(|| t.weight(), |w| w.clamp(1.0, 1000.0) as u16);
+ t.font_style = style_name(weight, flag(s, "italic").unwrap_or_else(|| t.italic()));
+ }
+ if let Some(size) = num(s, "fontSize").filter(|x| *x > 0.0) {
+ t.font_size = size.clamp(0.5, 10_000.0);
+ }
+ let px = len(s, "lineHeightPx");
+ let pct = len(s, "lineHeightPercentFontSize");
+ match text(s, "lineHeightUnit") {
+ Some("PIXELS") => t.line_height = px.map_or(t.line_height, LineHeight::Pixels),
+ Some("FONT_SIZE_%") => t.line_height = pct.map_or(t.line_height, LineHeight::Percent),
+ Some("INTRINSIC_%") => t.line_height = LineHeight::Auto,
+ _ => {
+ if let Some(p) = pct {
+ t.line_height = LineHeight::Percent(p);
+ } else if let Some(p) = px {
+ t.line_height = LineHeight::Pixels(p);
+ }
+ }
+ }
+ // Letter spacing is in pixels.
+ if let Some(ls) = num(s, "letterSpacing") {
+ t.letter_spacing = if ls == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(ls.clamp(-1e4, 1e4)) };
+ }
+ match text(s, "textCase") {
+ Some("ORIGINAL") => t.case = TextCase::Original,
+ Some("UPPER") => t.case = TextCase::Upper,
+ Some("LOWER") => t.case = TextCase::Lower,
+ Some("TITLE") => t.case = TextCase::Title,
+ Some("SMALL_CAPS" | "SMALL_CAPS_FORCED") => t.case = TextCase::SmallCaps,
+ _ => {}
+ }
+ match text(s, "textDecoration") {
+ Some("NONE") => t.decoration = TextDecoration::None,
+ Some("UNDERLINE") => t.decoration = TextDecoration::Underline,
+ Some("STRIKETHROUGH") => t.decoration = TextDecoration::Strikethrough,
+ _ => {}
+ }
+ if let Some(f) = paints(cx, s, "fills") {
+ t.fills = Some(f);
+ }
+ if let Some(h) = s.get("hyperlink") {
+ match (text(h, "type"), text(h, "url")) {
+ (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()),
+ _ => {}
+ }
+ }
+ t
+}
+
+/// The style name for a numeric weight ("Semi Bold Italic").
+fn style_name(weight: u16, italic: bool) -> String {
+ let w = match weight {
+ 0..=149 => "Thin",
+ 150..=249 => "Extra Light",
+ 250..=349 => "Light",
+ 350..=449 => "Regular",
+ 450..=549 => "Medium",
+ 550..=649 => "Semi Bold",
+ 650..=749 => "Bold",
+ 750..=849 => "Extra Bold",
+ _ => "Black",
+ };
+ match (w, italic) {
+ ("Regular", true) => "Italic".into(),
+ (w, true) => format!("{w} Italic"),
+ (w, false) => w.into(),
+ }
+}
+
+/// Styled runs from the per-character override ids (trailing characters without an id use the
+/// base style). The ids count UTF-16 code units, like the JavaScript strings the file comes from:
+/// a character outside the Basic Multilingual Plane takes two.
+fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec {
+ let ids: Vec = v
+ .get("characterStyleOverrides")
+ .and_then(Value::as_array)
+ .map(|a| a.iter().take(MAX_CHARS * 2).map(|x| x.as_u64().unwrap_or(0)).collect())
+ .unwrap_or_default();
+ let Some(table) = v.get("styleOverrideTable").and_then(Value::as_object) else { return Vec::new() };
+ if ids.iter().all(|i| *i == 0) || table.is_empty() {
+ return Vec::new();
+ }
+ let mut per_char: Vec = Vec::with_capacity(td.char_count());
+ let mut unit = 0usize;
+ for ch in td.characters.chars() {
+ per_char.push(ids.get(unit).copied().unwrap_or(0));
+ unit += ch.len_utf16();
+ }
+ let mut styles: BTreeMap = BTreeMap::new();
+ let mut out: Vec = Vec::new();
+ let mut i = 0;
+ while i < per_char.len() {
+ let Some(&id) = per_char.get(i) else { break };
+ let mut j = i + 1;
+ while per_char.get(j) == Some(&id) {
+ j += 1;
+ }
+ if id != 0 {
+ let style = match styles.get(&id) {
+ Some(s) => Some(s.clone()),
+ None => match table.get(&id.to_string()) {
+ Some(o) => {
+ let s = type_style(cx, o, &td.style);
+ styles.insert(id, s.clone());
+ Some(s)
+ }
+ None => {
+ cx.warn("text style overrides refer to missing table entries");
+ None
+ }
+ },
+ };
+ if let Some(style) = style.filter(|s| *s != td.style) {
+ out.push(StyleRun { start: i, end: j, style });
+ }
+ }
+ i = j;
+ }
+ out
+}
+
+/// A list style for the whole node from `lineTypes` (bullets win over numbers).
+fn list_style(v: &Value) -> ListStyle {
+ let types = list(v, "lineTypes");
+ if types.iter().any(|t| t.as_str() == Some("UNORDERED")) {
+ ListStyle::Bulleted
+ } else if types.iter().any(|t| t.as_str() == Some("ORDERED")) {
+ ListStyle::Numbered
+ } else {
+ ListStyle::None
+ }
+}
diff --git a/docs/cli.md b/docs/cli.md
index 02401a5..ee61cf2 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -10,7 +10,13 @@ Bad arguments print usage and exit with code 2; failures exit with 1. The CLI ne
### `info [--json]`
-Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts.
+Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts. Repairs
+of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`).
+
+Every subcommand that reads a file also takes the public REST API file JSON (the response of
+`GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly.
+Image fills come from `images/.png` (or `.jpg`, β¦) next to the JSON when present, else a
+grey placeholder.
```text
$ figcraft-cli info app.figcraft
@@ -36,7 +42,7 @@ figcraft-cli export app.figcraft -o hero.svg --node "Hero"
### `convert `
-By extension. Inputs: `.figcraft`, `.json`, `.svg` (becomes vectors), `.png/.jpg/.webp/.gif/.bmp`
+By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.svg` (becomes vectors), `.png/.jpg/.webp/.gif/.bmp`
(an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`,
`.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (when PDF export is
available).
diff --git a/docs/mcp.md b/docs/mcp.md
index 744795b..85ea53b 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -104,7 +104,7 @@ Or in a project's `.mcp.json`:
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale, path?}` | png, jpg, svg, webp (pdf when available). Without `path`, images come back as MCP image content, SVG as text. |
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window when attached. |
-| `open_file {path}` / `save_file {path?}` | Open `.figcraft`, `.json`, `.svg` or an image; save as `.figcraft`. |
+| `open_file {path}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON, see below), `.svg` or an image; save as `.figcraft`. |
| `undo` / `redo` | Step through history. |
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. |
| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (menu shortcuts, tool keys, Escape, Delete, arrows; text editing keys while editing text). |
@@ -127,6 +127,20 @@ from the pen's open end; clicking a point connects or branches), `vector.edit.pe
`vector.edit.cornerRadius`, `vector.edit.end`. Point indices refer to `network.vertices` in
`vector.edit.state`; local coordinates are the layer's own.
+### Importing REST API file JSON
+
+The JSON returned by the public REST API's file endpoint (`GET /v1/files/:key`, best with
+`geometry=paths` so vectors carry their outlines) and its nodes endpoint opens like any document:
+`open_file {path}` (a new, untitled document; the JSON is never saved over) or
+`run_command {command: "file.import", params: {path}}` (pages appended to the current document).
+The result's `warnings` list what was skipped or approximated (unsupported layer, paint and effect
+types, prototype interactions, variable bindings, instances of components outside the file, which
+come in as frames). Pages, the layer tree, geometry, paints, strokes, effects, corners, constraints,
+auto layout, layout grids, exports, text with styled runs, vectors, components, component sets,
+instances and component properties are mapped. Image fills keep their `imageRef` and show a grey
+placeholder unless the image files sit in an `images` folder next to the JSON, named by `imageRef`
+(`images/.png`); nothing is fetched from the network.
+
Tool failures come back as results with `isError: true` and a message; protocol problems (unknown
tool, malformed JSON, wrong parameter types) are JSON-RPC errors. Nothing crashes the server.
|