M6: import REST API file JSON: pages, layers, paints, text runs, vectors, auto layout, components (#13)

- New `figcraft_format::rest`: imports the public REST API file JSON (`GET /v1/files/:key`, and
  the nodes endpoint's response) using the published reference only. Pages (name, background,
  flow starting points), the layer tree, names, visibility, lock, opacity, blend mode, masks.
- Geometry from `relativeTransform` (children of groups/booleans checked against
  `absoluteBoundingBox` to settle their space), else absolute bounds + rotation; sizes, corner
  radii (uniform and per corner), smoothing, clip content, constraints, export settings, layout
  grids.
- Paints: solid, linear/radial/angular/diamond gradients (handles map 1:1), image fills (fill,
  fit, tile, stretch, rotation, filters) with bytes from a resolver or a grey placeholder keyed by
  the imageRef; paint opacity, visibility and blend. Strokes: weight, per-side weights, align,
  caps, join, dashes, miter angle. Effects: drop/inner shadow (offset, radius, spread, show behind),
  layer and background blur.
- Vectors from `fillGeometry` (stroke-only vectors use the `strokeGeometry` outline), ellipse arc
  data, stars/polygons, boolean operations. Text: characters, type style (family, style or weight
  + italic, size, line height px/%/auto, letter spacing, case, decoration, alignment, auto resize,
  truncation, paragraph spacing/indent, OpenType flags, links), per-character overrides → styled
  runs (UTF-16 aware), list type.
- Auto layout: direction incl. grid, alignment, padding, gaps, wrap, hug/fixed/fill sizing (legacy
  and per-axis fields), absolute positioning, min/max, grid cells.
- Components and sets with descriptions and property definitions (names lose their `#id`
  suffix); instances of components in the file become instances with their property values and
  listed overrides; outside components and component cycles stay frames. Shared styles are created
  from the layers that use them, named from the metadata.
- Unknown types and fields are skipped with a counted warning list; sizes, depth (256), layer count
  (200,000), path length and list lengths are capped; nothing panics on junk.
- Engine: `file.open` / `file.import` / CLI detect the JSON by content, link imported instances to
  their mains (sources and overrides), read image fills from an `images` folder next to the file,
  never save over the JSON, and return `warnings`. `file.import` now remaps ids across all
  imported pages at once (instances keep their main and layer sources) and brings styles and page
  backgrounds along. `figcraft-cli info` prints load notes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 03:36:30 +07:00 committed by GitHub
parent afbe195f72
commit e718518a08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2514 additions and 42 deletions

View File

@ -61,7 +61,7 @@
</td>
<td width="25%" valign="top">
<h3>🔌 Offline and private</h3>
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out.
Your files are plain <code>.figcraft</code> 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.
</td>
<td width="25%" valign="top">
<h3>🤖 Agent-ready</h3>

View File

@ -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 |

View File

@ -10,7 +10,12 @@ use serde_json::{Value, json};
use crate::{CliError, CliResult};
pub fn load(path: &str) -> CliResult<Document> {
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<String>)> {
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<ExitCode> {
let doc = load(path)?;
let (doc, notes) = load_noted(path)?;
let pages: Vec<Value> = doc
.pages
.iter()
@ -137,11 +142,15 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
"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 &notes {
eprintln!("note: {n}");
}
println!(
"{} — {} ({} nodes, {} styles, {} variables, {} images)",
path,

View File

@ -18,7 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<String>)> {
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<String>)> {
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<String>, 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<String>, 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<String
let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?;
let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?;
doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)];
Ok((doc, vec![]))
Ok((doc, vec![], false))
}
_ => {
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/<imageRef>[.png|.jpg|…]` next to
/// the file.
#[cfg(not(target_arch = "wasm32"))]
fn rest_images(path: &str) -> impl Fn(&str) -> Option<Vec<u8>> {
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<Vec<u8>> {
figcraft_format::rest::no_images
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Node> = 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<Value> {
}
}
/// 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<Vec<NodeId>> {
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<Node> = 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<Value> {
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<Value> {
}
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);
}
}

View File

@ -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

View File

@ -4,8 +4,12 @@
//! header), `images/<sha256>` 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;

View File

@ -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<Vec<u8>>;
/// An [`ImageResolver`] for when no image files are at hand.
pub fn no_images(_: &str) -> Option<Vec<u8>> {
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<Item = &Value> {
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<Result<(Document, Vec<String>), 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::<Value>(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<String>), 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<NodeId> = 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<String, usize>,
}
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<String> {
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<String, String>,
missing_images: usize,
warnings: Warnings,
count: usize,
/// REST node id → imported node.
ids: BTreeMap<String, NodeId>,
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<NodeId, BTreeMap<String, PropDef>>,
/// 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<String>) {
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<LayoutMode>, 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<NodeKind> {
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<LayoutMode>) {
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<String, PropDef> {
let mut out = BTreeMap::new();
for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) {
let default = d.get("defaultValue");
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<NodeId> {
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<NodeId, BTreeSet<NodeId>> = 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<String, Value> {
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<String, BTreeSet<String>> = BTreeMap::new();
let ids: Vec<NodeId> = 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<String, String> = 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<NodeId, BTreeSet<NodeId>>, 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<bool> {
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<Rect> {
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<Vec<u8>> {
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)
}

View File

@ -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<f64> {
v.get(k)?.as_f64().filter(|x| x.is_finite())
}
/// A non-negative length, capped.
pub(super) fn len(v: &Value, k: &str) -> Option<f64> {
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<bool> {
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<Color> {
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<Affine> {
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<BlendMode> {
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<Sizing> {
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<Vec<Paint>> {
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<Paint> {
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<ColorStop> = 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<Effect> {
list(v, "effects").iter().filter_map(|e| effect(cx, e)).collect()
}
fn effect(cx: &mut Ctx, e: &Value) -> Option<Effect> {
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<f64> =
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<Constraints> {
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<AutoLayout> {
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<LayoutMode>, parent_mode: Option<LayoutMode>) -> 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<LayoutGrid> {
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<ExportSetting> {
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)
}

View File

@ -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<String>) {
import(v, &no_images).unwrap()
}
/// One page holding `children`.
fn one(children: Value) -> (Document, Vec<String>) {
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<String>) {
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<Value> = (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:?}");
}

View File

@ -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<StyleRun> {
let ids: Vec<u64> = 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<u64> = 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<u64, TextStyle> = BTreeMap::new();
let mut out: Vec<StyleRun> = 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
}
}

View File

@ -10,7 +10,13 @@ Bad arguments print usage and exit with code 2; failures exit with 1. The CLI ne
### `info <file> [--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/<imageRef>.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 <in> <out>`
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).

View File

@ -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/<imageRef>.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.