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