diff --git a/README.md b/README.md index c98aab0..2523eee 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@

πŸ”Œ Offline and private

- Your files are plain .figcraft archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out. + Your files are plain .figcraft archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; designs exported as the public REST API file JSON import too.

πŸ€– Agent-ready

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