Files
figcraft/crates/engine/src/cmd/file.rs
T
BFlat ee82cdd138 M3: extended variable collections, variables import/export, gradient stop and variant bindings (#114)
Library collections can be extended in a consuming file (inherited modes, per-mode overrides with reset, extra variables; library updates keep overrides). Variables export to the REST JSON shape or CSS custom properties and import back (merged by name, validated by the REST reader, one undo step). Gradient stops and variant properties bind to variables. Also fixes clicking a gradient stop adding a new stop.
2026-10-11 10:49:16 +07:00

1079 lines
49 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, .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!(
"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 and REST API file JSON pages are appended with their variables and flows (REST: 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.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;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions);
}
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()?;
if ds.preview.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(new_version_id(&ds.versions), 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 id: the time in milliseconds (hex), unique in the history.
fn new_version_id(versions: &[Version]) -> 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 !versions.iter().any(|v| v.id == 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.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")))
}
#[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 (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" => {
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::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))
}
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;
return Ok(json!({"index": i}));
}
let (loaded, rest) = load_path(&path, variables.as_ref())?;
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));
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_versions(&doc, thumb.as_deref(), &ds.versions).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}))
}
fn save_to(s: &mut Session, path: String) -> Result<Value> {
let ds = s.ds_mut()?;
let bytes = file_bytes(ds)?;
write(&path, &bytes)?;
ds.path = Some(path.clone());
ds.mark_saved();
ds.revision += 1;
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.ds()?.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" => {
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")) };
if 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()));
}
s.docs.remove(i);
if s.active >= s.docs.len() {
s.active = s.docs.len().saturating_sub(1);
}
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);
}
}