Files
figcraft/crates/engine/src/cmd/file.rs
T
BFlat 2f49fcc490 M6: .fig import (best effort) (#132)
.fig files open through file.open, file.import, drag and drop, the dialogs, the CLI and MCP. A generic reader written from the public format description decodes the zip, the deflate/zstd chunks and the kiwi schema and message, then maps nodes by field name onto the REST importer: layer tree, geometry, paints with styles resolved, strokes, effects, text runs, vectors as outlines, auto layout including grid, constraints, components, sets, instances expanded from their main with overrides, styles, variables with modes and bindings, prototyping and image fills. Unsupported layers come back as warnings. Fuzzed; tested on fixtures generated in code. New dependency: ruzstd (pure-Rust zstd decoder).
2026-10-11 18:59:59 +07:00

1173 lines
53 KiB
Rust

//! File menu: documents, save/open, import/export, versions.
use std::sync::Arc;
use base64::Engine as _;
use figcraft_doc::*;
use figcraft_format::Version;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{DocState, EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("file.new", "New design file", "{name?}", false, |s, p| {
let name = string(p, "name").unwrap_or_else(|| "Untitled".into());
s.docs.push(DocState::new(Document::new(name), None));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}),
spec!(
"file.open",
"Open",
"{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`",
false,
open
),
spec!(
"file.openRecent",
"Open recent",
"{path? | index? (into the list), clear? (forget them all)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
false,
open_recent
),
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!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
false,
export
),
spec!(
"file.exportLayers",
"Export layers",
"{ids? (default the selection, else every layer on the page with export settings), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}",
false,
export_layers
),
spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("format".into(), json!("svg"));
}
export(s, &q)
}),
spec!(
"file.exportFramesPdf",
"Export frames to PDF",
"{path?, page? (page id, default the current page), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)} every top-level frame in reading order, one PDF page each; returns base64 `data` without a path",
false,
export_frames_pdf
),
spec!(
"file.import",
"Import",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)",
true,
import
),
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
spec!(
"file.recover",
"Recover file",
"{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
false,
|s, p| {
let path = req_string("file.recover", p, "path")?;
let loaded =
read(&path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved());
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}
),
spec!("file.rename", "Rename file", "{name}", true, |s, p| {
let name = req_string("file.rename", p, "name")?;
s.doc_mut()?.name = name;
ok()
}),
spec!(
"file.colorProfile",
"Color profile",
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
true,
color_profile
),
spec!("file.duplicate", "Duplicate file", "{}", false, |s, _| {
let ds = s.ds()?;
let mut d = (*ds.doc).clone();
d.name = format!("{} (Copy)", d.name);
s.docs.push(DocState::new(d, None));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}),
spec!("file.revert", "Revert to saved", "{}", false, |s, _| {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
let uid = s.ds()?.uid;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions).with_branches(loaded.branches);
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
ok()
}),
spec!(
"file.saveVersion",
"Save to version history",
"{name?, description?} snapshot the document into the file's version history (kept when the file is saved); returns the version",
false,
save_version
),
spec!("file.versionHistory", "Show version history", "{} opens the panel; returns the versions like `version.list`", false, |s, _| {
let list = version_list(s.ds()?);
s.request_ui("panel.versionHistory", json!({}));
Ok(list)
}),
spec!(
"version.list",
"List versions",
"{} the version history, oldest first: [{id, index, name, description, time (Unix seconds), auto?, previewing?}]",
false,
|s, _| Ok(version_list(s.ds()?))
),
spec!(
"version.restore",
"Restore version",
"{id | index} replace the document with the version (one undo step; the file name stays)",
true,
restore_version
),
spec!("version.rename", "Rename version", "{id | index, name, description?}", false, |s, p| {
const CMD: &str = "version.rename";
let name = req_string(CMD, p, "name")?;
if name.trim().is_empty() {
return Err(bad(CMD, "`name` can't be empty"));
}
let ds = s.ds_mut()?;
let i = version_index(CMD, ds, p)?;
let description = string(p, "description");
let v = ds.versions_mut().get_mut(i).ok_or_else(|| bad(CMD, "no such version"))?;
v.name = figcraft_format::clip(name.trim(), figcraft_format::MAX_VERSION_NAME);
if let Some(d) = description {
v.description = figcraft_format::clip(d.trim(), figcraft_format::MAX_VERSION_DESCRIPTION);
}
Ok(version_json(i, v))
}),
spec!("version.delete", "Delete version", "{id | index}", false, |s, p| {
let ds = s.ds_mut()?;
let i = version_index("version.delete", ds, p)?;
let v = ds.versions_mut().remove(i);
if ds.preview.as_ref().is_some_and(|pv| pv.id == v.id) {
ds.end_preview();
}
ok()
}),
spec!(
"version.preview",
"View version",
"{id | index} show the version on the canvas in place of the document (read-only; `version.restore` keeps it, `version.exitPreview` goes back)",
false,
|s, p| {
let ds = s.ds_mut()?;
let i = version_index("version.preview", ds, p)?;
let v = ds.versions.get(i).ok_or_else(|| bad("version.preview", "no such version"))?;
let (id, name, time, out) = (v.id.clone(), v.name.clone(), v.time, version_json(i, v));
let doc = read_version(v)?;
ds.start_preview(&id, &name, time, doc);
Ok(out)
}
),
spec!(
"version.autosave",
"Autosave version",
"{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}",
false,
autosave
),
spec!("version.exitPreview", "Back to current version", "{} end a version preview", false, |s, _| {
Ok(json!({"ended": s.ds_mut()?.end_preview()}))
}),
spec!(
"version.duplicate",
"Duplicate version",
"{id | index} open the version as a new document; returns its tab `index`",
false,
|s, p| {
let ds = s.ds()?;
let v = ds.versions.get(version_index("version.duplicate", ds, p)?).ok_or_else(|| bad("version.duplicate", "no such version"))?;
let mut doc = (*read_version(v)?).clone();
doc.name = format!("{} ({})", ds.doc.name, v.name);
doc.take_dirty();
s.docs.push(DocState::new(doc, None));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}
),
spec!("doc.switch", "Switch document", "{index}", false, |s, p| {
let i = req_num("doc.switch", p, "index")? as usize;
if i >= s.docs.len() {
return Err(bad("doc.switch", "no such document"));
}
s.active = i;
ok()
}),
spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)),
spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)),
spec!("doc.list", "List documents", "{}", false, |s, _| {
Ok(Value::Array(
s.docs
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.collect(),
))
}),
spec!("doc.home", "Home", "{}", false, |s, _| {
s.request_ui("home", json!({}));
ok()
}),
]
}
fn version_json(index: usize, v: &Version) -> Value {
let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time});
if v.auto {
j["auto"] = json!(true);
}
j
}
/// Autosaves kept per file; older ones make room for new ones.
pub const MAX_AUTOSAVES: usize = 20;
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
let ds = s.ds_mut()?;
// A branch's versions would have nowhere to be saved.
if ds.preview.is_some() || ds.branch_of.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
return Ok(json!({"saved": false}));
}
// Make room: the oldest autosave goes first; user versions are never dropped.
let autos = ds.versions.iter().filter(|v| v.auto).count();
if (autos >= MAX_AUTOSAVES || ds.versions.len() >= figcraft_format::MAX_VERSIONS)
&& let Some(oldest) = ds.versions.iter().position(|v| v.auto)
{
ds.versions_mut().remove(oldest);
}
if ds.versions.len() >= figcraft_format::MAX_VERSIONS {
return Ok(json!({"saved": false}));
}
let mut v = snapshot_version(ds, "Autosave", "");
v.auto = true;
let out = version_json(ds.versions.len(), &v);
ds.versions_mut().push(v);
ds.mark_checkpoint();
Ok(json!({"saved": true, "version": out}))
}
/// A version of `ds`'s document as it is now, opening on the page shown.
fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version {
let mut snap = ds.doc.clone();
if snap.current_page != Some(ds.page) {
Arc::make_mut(&mut snap).current_page = Some(ds.page);
}
Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, super::comment::now_secs(), snap)
}
fn version_list(ds: &DocState) -> Value {
let previewing = ds.preview.as_ref().map(|p| p.id.as_str());
Value::Array(
ds.versions
.iter()
.enumerate()
.map(|(i, v)| {
let mut j = version_json(i, v);
if previewing == Some(v.id.as_str()) {
j["previewing"] = json!(true);
}
j
})
.collect(),
)
}
/// The version named by `id`, else by `index` (validated: never negative, never past the end).
fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result<usize> {
if let Some(id) = string(p, "id") {
return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`")));
}
if get(p, "index").is_none() {
return Err(bad(cmd, "pass the version's `id` or `index`"));
}
let i = req_index(cmd, p, "index")?;
if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) }
}
fn read_version(v: &Version) -> Result<Arc<Document>> {
v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name)))
}
/// A new version or branch id: the time in milliseconds (hex), not `taken`.
pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String {
let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
loop {
let id = format!("{n:x}");
if !taken(&id) {
return id;
}
n = n.wrapping_add(1);
}
}
fn save_version(s: &mut Session, p: &Value) -> Result<Value> {
let ds = s.ds_mut()?;
if ds.branch_of.is_some() {
return Err(EngineError::Disabled("file.saveVersion".into(), "versions are saved on the main file, not on a branch".into()));
}
if ds.versions.len() >= figcraft_format::MAX_VERSIONS {
return Err(EngineError::Disabled(
"file.saveVersion".into(),
format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS),
));
}
let name =
string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1));
let description = string(p, "description").unwrap_or_default();
// The version opens on the page shown when it was saved.
let v = snapshot_version(ds, &name, description.trim());
let out = version_json(ds.versions.len(), &v);
ds.versions_mut().push(v);
ds.mark_checkpoint();
Ok(out)
}
fn restore_version(s: &mut Session, p: &Value) -> Result<Value> {
let ds = s.ds_mut()?;
let i = version_index("version.restore", ds, p)?;
let v = ds.versions.get(i).ok_or_else(|| bad("version.restore", "no such version"))?;
let mut doc = read_version(v)?;
let out = version_json(i, v);
let d = Arc::make_mut(&mut doc);
d.name = ds.doc.name.clone();
// What the file published belongs to the file, not to the version.
d.published = ds.doc.published.clone();
// The snapshot is already consistent: nothing to re-normalise.
d.take_dirty();
// Stay on the current page when the version has it.
ds.page = Some(ds.page)
.filter(|pg| d.pages.contains(pg))
.or_else(|| d.current_page.filter(|pg| d.pages.contains(pg)))
.or_else(|| d.first_page())
.unwrap_or(ds.page);
ds.doc = doc;
ds.selection.clear();
ds.text_edit = None;
ds.vector_edit = None;
Ok(out)
}
#[cfg(not(target_arch = "wasm32"))]
fn read(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn read(path: &str) -> Result<Vec<u8>> {
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
/// The bytes of file `path`, refused when it's larger than `max`.
pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes")));
}
read(path)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
let tmp = format!("{path}.tmp-figcraft");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
fn ext(path: &str) -> String {
std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}
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 (REST API file JSON images come from an `images`
/// folder next to the file).
pub fn load(path: &str) -> Result<(Document, Vec<String>)> {
load_path(path, None).map(|(l, _)| (l.doc, l.issues))
}
pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec<String>)> {
load_with(bytes, name_hint, None, &figcraft_format::rest::no_images).map(|(l, _)| (l.doc, l.issues))
}
/// [`load`] with the version history, also telling whether the file was REST API file JSON. A REST
/// import reads its variables from `variables`, else from a variables JSON next to the file.
pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> {
let bytes = read(path)?;
let sibling = if variables.is_none() { sibling_variables(path) } else { None };
load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path))
}
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig`
/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON.
fn load_with(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, 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)?;
let notes = figcraft_svg::import_svg_with_notes(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint))
.map_err(|e| EngineError::Other(e.to_string()))?
.notes;
Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false))
}
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => {
let mut doc = Document::new(file_stem(name_hint));
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
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.into(), false))
}
_ => {
if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
link_instances(&mut doc);
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
}
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
link_instances(&mut doc);
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
}
let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
if loaded.doc.name.is_empty() {
loaded.doc.name = file_stem(name_hint);
}
Ok((loaded, 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
}
/// Largest variables JSON read from disk.
#[cfg(not(target_arch = "wasm32"))]
const MAX_VARIABLES_FILE: u64 = 256 * 1024 * 1024;
/// A local variables JSON next to a REST API file JSON: `<name>.variables.json`, else
/// `variables.json`.
#[cfg(not(target_arch = "wasm32"))]
fn sibling_variables(path: &str) -> Option<Value> {
if ext(path) != "json" {
return None;
}
let file = std::path::Path::new(path);
let dir = file.parent()?;
[format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| {
let meta = std::fs::metadata(&c).ok()?;
if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE {
return None;
}
let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?;
figcraft_format::rest::is_variables_json(&v).then_some(v)
})
}
#[cfg(target_arch = "wasm32")]
fn sibling_variables(_path: &str) -> Option<Value> {
None
}
/// The local variables JSON a command passes as `variables`: an object, JSON text or a file path.
pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
let parse = |text: &[u8]| serde_json::from_slice::<Value>(text).map_err(|e| bad(cmd, format!("variables: {e}")));
let v = match get(p, "variables") {
None | Some(Value::Null) => return Ok(None),
Some(v @ Value::Object(_)) => v.clone(),
Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?,
Some(Value::String(path)) => parse(&read(path)?)?,
Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")),
};
if !figcraft_format::rest::is_variables_json(&v) {
return Err(bad(cmd, "variables: expected the local variables JSON (`meta.variableCollections` and `meta.variables`)"));
}
Ok(Some(v))
}
/// How many files File ▸ Open recent keeps.
pub const MAX_RECENT: usize = 20;
/// Put `path` at the top of the recent files.
fn remember(s: &mut Session, path: &str) {
let r = &mut s.prefs.recent_files;
r.retain(|p| p != path);
r.insert(0, path.to_string());
r.truncate(MAX_RECENT);
}
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.openRecent";
if p.get("clear").and_then(Value::as_bool) == Some(true) {
s.prefs.recent_files.clear();
}
let path = match (string(p, "path"), num(p, "index")) {
(Some(path), _) => Some(path),
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
_ => None,
};
if let Some(path) = path {
if !std::path::Path::new(&path).exists() {
s.prefs.recent_files.retain(|r| *r != path);
return Err(bad(CMD, format!("{path} no longer exists")));
}
return open(s, &json!({"path": path}));
}
let files: Vec<Value> = s
.prefs
.recent_files
.iter()
.map(|r| {
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
})
.collect();
Ok(json!({"files": files}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = req_string("file.open", p, "path")?;
let variables = variables_param("file.open", p)?;
// Already open: switch to it.
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
s.active = i;
remember(s, &path);
return Ok(json!({"index": i}));
}
let (loaded, rest) = load_path(&path, variables.as_ref())?;
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
let warnings = rest || ext(&path) == "svg";
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
Ok(out)
}
/// Thumbnail PNG of the first page's content (for file browsers / the home screen).
pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
let page = doc.first_page()?;
let b = doc.page_content_bounds(page)?;
let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0);
let view = figcraft_render::View {
origin: (b.x0, b.y0),
scale,
width: (b.width() * scale).ceil().max(1.0) as u32,
height: (b.height() * scale).ceil().max(1.0) as u32,
};
let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?;
figcraft_render::encode_png(&pix).ok()
}
/// The `.figcraft` file for a document: content, thumbnail and version history.
pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
let mut doc = (*ds.doc).clone();
doc.current_page = Some(ds.page);
let thumb = thumbnail(&doc);
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string()))
}
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.colorProfile";
let Some(name) = string(p, "profile") else {
s.request_ui("dialog.colorProfile", json!({}));
return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false}));
};
let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?;
let convert = boolean(p, "convert").unwrap_or(true);
let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert);
Ok(json!({"profile": profile.id(), "changed": changed}))
}
/// Save the file of the active tab (the main file for a branch tab, with its branches).
fn save_to(s: &mut Session, path: String) -> Result<Value> {
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
let bytes = file_bytes(ds)?;
write(&path, &bytes)?;
ds.path = Some(path.clone());
ds.mark_saved();
ds.revision += 1;
let uid = ds.uid;
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
tab.mark_saved();
}
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = string(p, "path") {
return save_to(s, path);
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
Some(path) => save_to(s, path),
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))
}
}
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
match string(p, "path") {
Some(mut path) => {
if ext(&path).is_empty() {
path.push_str(".figcraft");
}
save_to(s, path)
}
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into()))
}
}
}
/// Encode `ids` (or the page) in `format` at `scale`.
pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result<Vec<u8>> {
export_bytes_with(doc, ids, format, scale, &PrintSettings::default())
}
/// [`export_bytes`], PDFs with `print`'s bleed, marks and colour.
pub fn export_bytes_with(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64, print: &PrintSettings) -> Result<Vec<u8>> {
if ids.is_empty() {
return Err(EngineError::Disabled("file.export".into(), "nothing to export".into()));
}
match format {
// SVG and PDF colours are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
ExportFormat::Svg => figcraft_svg::export_svg(&doc.in_srgb(), ids, &Default::default())
.map(String::into_bytes)
.ok_or_else(|| EngineError::Other("nothing to export".into())),
ExportFormat::Pdf => {
let doc = &*doc.in_srgb();
// Frames export one page each; anything else shares one page.
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
let groups: Vec<Vec<NodeId>> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![super::edit::z_sorted(doc, ids)] };
pdf_bytes(doc, &groups, print)
}
_ => {
let pix = if ids.len() == 1 {
ids.first().and_then(|i| figcraft_render::render_node(doc, *i, scale))
} else {
let b = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b));
b.and_then(|b| {
let view = figcraft_render::View {
origin: (b.x0, b.y0),
scale,
width: (b.width() * scale).ceil().max(1.0) as u32,
height: (b.height() * scale).ceil().max(1.0) as u32,
};
let mut pix = figcraft_render::Pixmap::new(view.width.clamp(1, 16384), view.height.clamp(1, 16384))?;
let ordered = super::edit::z_sorted(doc, ids);
figcraft_render::render_nodes_into(doc, &ordered, &view, &mut pix);
Some(pix)
})
};
let pix = pix.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
figcraft_render::encode_export(&pix, format, doc.color_profile).map_err(EngineError::Other)
}
}
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc()?;
let mut ids = targets(s, p);
if ids.is_empty() {
ids = doc.get(s.page()?)?.children.clone();
}
let format = string(p, "format")
.and_then(|f| ExportFormat::from_ext(&f))
.or_else(|| string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))))
.unwrap_or_default();
let scale = match size_param("file.export", p)? {
Some(size) => export_scale(doc, &ids, size),
None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE),
};
let print = print_param("file.export", p, PrintSettings::default())?;
let bytes = export_bytes_with(doc, &ids, format, scale, &print)?;
match string(p, "path") {
Some(path) => {
write(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format.ext()}))
}
None => Ok(json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
}
}
/// The scale that gives `ids` (their bounds together) the export size `size`.
pub fn export_scale(doc: &Document, ids: &[NodeId], size: ExportSize) -> f64 {
let b = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b));
b.map_or(1.0, |b| size.scale_for(b.width(), b.height()))
}
/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets.
pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result<ExportSetting> {
if let Some(size) = size_param(cmd, p)? {
e.size = size;
}
if let Some(s) = string(p, "suffix") {
e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect();
}
if let Some(f) = string(p, "format") {
e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp"))?;
}
e.print = print_param(cmd, p, e.print)?;
Ok(e)
}
/// Whether `p` sets any PDF print option.
fn has_print_param(p: &Value) -> bool {
["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null()))
}
/// `base` with the PDF print options `p` sets: `bleed` (points, or a length like "3mm" or
/// "0.125in"), `cropMarks`, `bleedMarks`, `cmyk`.
pub(super) fn print_param(cmd: &str, p: &Value, mut base: PrintSettings) -> Result<PrintSettings> {
match get(p, "bleed") {
None | Some(Value::Null) => {}
Some(v) => {
let pt = match v {
Value::String(s) => PrintSettings::parse_length(s),
v => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0),
};
base.bleed = pt
.filter(|b| *b <= PrintSettings::MAX_BLEED)
.ok_or_else(|| bad(cmd, "bleed: points, or a length like \"3mm\" or \"0.125in\" (up to 144 pt, 2 in)"))?;
}
}
for (key, slot) in [("cropMarks", &mut base.crop_marks), ("bleedMarks", &mut base.bleed_marks), ("cmyk", &mut base.cmyk)] {
match get(p, key) {
None | Some(Value::Null) => {}
Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?,
}
}
Ok(base)
}
/// An export size passed as `size`: "2x", "512w", "512h", a number (scale) or `{by, value}`.
fn size_param(cmd: &str, p: &Value) -> Result<Option<ExportSize>> {
let size = match get(p, "size") {
None | Some(Value::Null) => return Ok(None),
Some(Value::String(s)) => ExportSize::parse(s),
Some(Value::Number(n)) => n.as_f64().and_then(|value| ExportSize::Scale { value }.checked()),
Some(v) => serde_json::from_value::<ExportSize>(v.clone()).ok().and_then(ExportSize::checked),
};
size.map(Some).ok_or_else(|| bad(cmd, "size: 2x, 512w or 512h (or {by: scale|width|height, value})"))
}
/// Most files one `file.exportLayers` writes.
const MAX_EXPORT_FILES: usize = 1000;
/// One file of a layer export: the layer, its setting and the file name.
pub struct ExportJob {
pub id: NodeId,
pub setting: ExportSetting,
pub file: String,
}
/// The files exporting `ids` with their export settings makes, with unique names (repeats get
/// " 2", " 3"…). Layers without settings make none.
pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec<ExportJob> {
let mut used = std::collections::BTreeSet::new();
let mut out = Vec::new();
for id in ids {
let Some(n) = doc.node(*id) else { continue };
let name = safe_file_name(&n.name);
let base = Some(name.trim().trim_end_matches('.')).filter(|b| !b.is_empty()).unwrap_or("Untitled");
for e in &n.exports {
if out.len() >= MAX_EXPORT_FILES {
return out;
}
let stem = format!("{base}{}", safe_file_name(&e.file_suffix()));
let ext = e.format.ext();
let file =
(1..).map(|k| if k == 1 { format!("{stem}.{ext}") } else { format!("{stem} {k}.{ext}") }).find(|f| used.insert(f.to_lowercase()));
if let Some(file) = file {
out.push(ExportJob { id: *id, setting: e.clone(), file });
}
}
}
out
}
/// `name` without path separators and characters file systems reject.
fn safe_file_name(name: &str) -> String {
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
}
/// Layers on `page` that have export settings (document order).
pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec<NodeId> {
doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect()
}
fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.exportLayers";
let doc = s.doc()?;
let mut ids = targets(s, p);
if ids.is_empty() {
ids = exportable_layers(doc, s.page()?);
}
let jobs = export_jobs(doc, &ids);
if jobs.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into()));
}
let dir = string(p, "dir");
// Print options passed here stand in for every PDF setting's own.
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
let mut files = Vec::with_capacity(jobs.len());
for job in &jobs {
let scale = export_scale(doc, &[job.id], job.setting.size);
let bytes = export_bytes_with(doc, &[job.id], job.setting.format, scale, print.as_ref().unwrap_or(&job.setting.print))?;
let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()});
match &dir {
Some(d) => {
let path = std::path::Path::new(d).join(&job.file).to_string_lossy().to_string();
write(&path, &bytes)?;
f["path"] = json!(path);
}
None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)),
}
files.push(f);
}
let with_settings: std::collections::BTreeSet<NodeId> = jobs.iter().map(|j| j.id).collect();
Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}))
}
/// A PDF with one page per group of layers (an sRGB document), titled after the file.
fn pdf_bytes(doc: &Document, groups: &[Vec<NodeId>], print: &PrintSettings) -> Result<Vec<u8>> {
let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, ..Default::default() };
figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string()))
}
/// Every top-level frame of `page` (reading order) as one PDF page each, and the page count.
pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec<u8>, usize)> {
let mut frames = doc.top_frames(page);
frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible));
if frames.is_empty() {
return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into()));
}
let groups: Vec<Vec<NodeId>> = frames.iter().map(|f| vec![*f]).collect();
Ok((pdf_bytes(&doc.in_srgb(), &groups, print)?, frames.len()))
}
/// Every top-level frame of a page (reading order), one PDF page each.
fn export_frames_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc()?;
let page = match id(p, "page") {
Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg,
Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")),
None => s.page()?,
};
let print = print_param("file.exportFramesPdf", p, PrintSettings::default())?;
let (bytes, pages) = frames_pdf(doc, page, &print)?;
match string(p, "path") {
Some(path) => {
write(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
}
None => Ok(json!({"format": "pdf", "pages": pages, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
}
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let path = string(p, "path");
let variables = variables_param("file.import", p)?;
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);
(
base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?,
string(p, "name").unwrap_or_else(|| "import.png".into()),
)
} else {
return Err(bad("file.import", "pass `path` or `data`"));
};
let page = s.page()?;
let (x, y) = (num(p, "x"), num(p, "y"));
let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0));
match ext(&name).as_str() {
"svg" => {
let doc = s.doc_mut()?;
let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1));
let imported =
figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?;
let id = imported.frame;
// Centred on the point (a paste lands in the middle of the view).
if boolean(p, "center").unwrap_or(false)
&& let Some(n) = doc.node_mut(id)
{
n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round()));
}
s.set_selection(vec![id]);
let mut out = json!({"id": id});
if !imported.notes.is_empty() {
out["warnings"] = json!(imported.notes);
}
Ok(out)
}
"figcraft" | "json" | "fig" => {
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 sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None };
let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
let pages = append_pages(s.doc_mut()?, &other.doc)?;
let mut out = json!({"pages": pages});
if rest {
out["warnings"] = json!(other.issues);
}
Ok(out)
}
_ => {
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
super::create::place_image(s, &json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)}))
}
}
}
/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to
/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
/// their source and interactions and flows their targets wherever those are. Styles, variables and
/// collections whose id the document already has are kept as they 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 have: std::collections::BTreeSet<String> = doc.variables.collections.iter().map(|c| c.id.clone()).collect();
doc.variables.collections.extend(other.variables.collections.iter().filter(|c| !have.contains(&c.id)).cloned());
let have: std::collections::BTreeSet<String> = doc.variables.variables.iter().map(|v| v.id.clone()).collect();
doc.variables.variables.extend(other.variables.variables.iter().filter(|v| !have.contains(&v.id)).cloned());
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);
let mut kind = pn.kind.clone();
if let NodeKind::Page { flows, .. } = &mut kind {
flows.retain_mut(|f| map.get(&f.start).map(|n| f.start = *n).is_some());
}
if let Some(n) = doc.node_mut(np) {
n.kind = kind;
}
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);
}
}
crate::library::merge_store(doc, other, &map)?;
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")) };
// A branch tab's edits are in its main file already (which then has unsaved changes).
if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
s.request_ui("dialog.unsaved", json!({"index": i}));
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
}
let uid = ds.uid;
s.docs.remove(i);
if s.active >= s.docs.len() {
s.active = s.docs.len().saturating_sub(1);
}
crate::branches::close_tabs(s, uid, None);
ok()
}
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
let n = s.docs.len() as i64;
if n == 0 {
return Err(crate::EngineError::NoDocument);
}
s.active = (s.active as i64 + dir).rem_euclid(n) as usize;
Ok(json!({"index": s.active}))
}
#[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);
}
}