//! 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 { vec![ spec!("file.new", "New design file", "{name?}", false, |s, p| { let name = string(p, "name").unwrap_or_else(|| "Untitled".into()); let mut doc = Document::new(name); // A property of the page from here on: a later change of theme leaves it alone. let background = s.new_page_background(); for page in doc.pages.clone() { if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) { *b = background; } } s.docs.push(DocState::new(doc, 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 `.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, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), 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), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), 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 `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). 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")?; s.docs.push(recovered(&path)?); 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 (on a branch tab: the branch's own 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 design with the version (one undo step; the file name and the comments stay; the design as it was is kept in the history first, unless a version of it was just saved or restored)", true, restore_version ), spec!("version.rename", "Rename version", "{id | index, name, description?} a titled autosave becomes a named version", 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); // A titled autosave is a named version (never dropped to make room). v.auto = false; 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 { let ds = s.ds_mut()?; if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { return Ok(json!({"saved": false})); } Ok(match add_version(ds, AUTOSAVE, "", true) { Some(v) => json!({"saved": true, "version": v}), None => json!({"saved": false}), }) } /// What checkpoints are called. const AUTOSAVE: &str = "Autosave"; /// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest /// autosave makes room when the autosaves or the history are full; versions the user named are /// never dropped, so a history full of them takes nothing (None). pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option { let autos = ds.versions.iter().filter(|v| v.auto).count(); if ((auto && 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 None; } let mut v = snapshot_version(ds, name, description); v.auto = auto; let out = version_json(ds.versions.len(), &v); ds.versions_mut().push(v); ds.mark_checkpoint(); Some(out) } /// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless /// it is what the last version saved or restored holds. pub(crate) fn keep_in_history(ds: &mut DocState) { if ds.branch_of.is_none() && ds.changed_since_checkpoint() { add_version(ds, AUTOSAVE, "", true); } } /// 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 { 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> { 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); } } /// `file.saveVersion`; on a branch tab the version goes into the branch's own history. fn save_version(s: &mut Session, p: &Value) -> Result { 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. add_version(ds, &name, description.trim(), false).ok_or_else(|| { EngineError::Disabled( "file.saveVersion".into(), format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), ) }) } fn restore_version(s: &mut Session, p: &Value) -> Result { const CMD: &str = "version.restore"; // Refused before anything goes into the history. if let Some(why) = s.live_read_only(s.ds()?.uid) { return Err(EngineError::Disabled(CMD.into(), why.into())); } let ds = s.ds_mut()?; let i = version_index(CMD, ds, p)?; let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?; let (id, mut doc) = (v.id.clone(), read_version(v)?); // Restoring never loses work: the document as it is goes into the history first. keep_in_history(ds); let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| 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(); // Comments are a conversation about the file, kept apart from its design history: they stay, // and those on layers the version doesn't have stay where they were. d.comments = ds.doc.comments.clone(); super::comment::follow_anchors(d, &ds.doc); // 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; // The restored design is in the history already: restoring it again keeps nothing new. ds.mark_checkpoint(); Ok(out) } /// The recovery copy at `path` as a document with unsaved changes and no path. pub(crate) fn recovered(path: &str) -> Result { let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) } #[cfg(not(target_arch = "wasm32"))] fn read(path: &str) -> Result> { std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] fn read(path: &str) -> Result> { 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> { 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| { // Leave nothing behind when the file can't take its place. let _ = std::fs::remove_file(&tmp); 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)> { load_path(path, None).map(|(l, _)| (l.doc, l.issues)) } pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> { 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) { crate::style_vars::after_load(doc); let mut insts: Vec<(usize, NodeId)> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); insts.sort_by_key(|a| std::cmp::Reverse(a.0)); for (_, i) in insts { crate::components::rebase_instance(doc, i); } } /// Largest image file read for a REST API file JSON import. #[cfg(not(target_arch = "wasm32"))] const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024; /// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to /// the file. #[cfg(not(target_arch = "wasm32"))] fn rest_images(path: &str) -> impl Fn(&str) -> Option> { let dir = std::path::Path::new(path).parent().map(|d| d.join("images")); move |r: &str| { // Image refs are content hashes; anything else never becomes a path. if r.is_empty() || r.len() > 128 || !r.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { return None; } let dir = dir.as_ref()?; ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| { let p = dir.join(format!("{r}{e}")); let meta = std::fs::metadata(&p).ok()?; if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None } }) } } #[cfg(target_arch = "wasm32")] fn rest_images(_path: &str) -> impl Fn(&str) -> Option> { figcraft_format::rest::no_images } /// 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: `.variables.json`, else /// `variables.json`. #[cfg(not(target_arch = "wasm32"))] fn sibling_variables(path: &str) -> Option { 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 { 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> { let parse = |text: &[u8]| serde_json::from_slice::(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 { 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 = 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})) } pub(crate) fn open(s: &mut Session, p: &Value) -> Result { 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> { 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> { 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 { 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 { 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 { 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 { 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> { export_bytes_with(doc, ids, &ExportSetting { format, ..Default::default() }, scale) } /// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own /// area), or their boxes with "include bounding box" and for slices. pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { ids.iter() .filter_map(|i| { let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) } }) .reduce(|a, b| a.union(b)) } /// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off, /// and always for slices) every top-level layer of their page, so whatever shows in the area is in. fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice))); match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) { Some(page) if slice || !setting.contents_only => page.children.clone(), _ => super::edit::z_sorted(doc, ids), } } /// [`export_bytes`] with an export setting's format and options (its size is `scale`: see /// [`export_scale`]): contents only, include bounding box, the SVG options and PDF print settings. pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result> { let nothing = || EngineError::Disabled("file.export".into(), "nothing to export".into()); if ids.is_empty() { return Err(nothing()); } let area = export_area(doc, ids, setting).ok_or_else(nothing)?; let drawn = drawn_layers(doc, ids, setting); // The layers' own area unless an option or a slice says otherwise. let own_area = setting.contents_only && !setting.include_bounding_box && !ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice))); match setting.format { // SVG and PDF colours are sRGB: a Display P3 file exports converted (out-of-gamut colours clip). ExportFormat::Svg => { let opts = figcraft_svg::SvgOptions { include_ids: setting.svg_id_attribute, outline_text: setting.svg_outline_text, simplify_stroke: setting.svg_simplify_stroke, bounds: Some(area), }; figcraft_svg::export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).ok_or_else(nothing) } ExportFormat::Pdf => { let doc = &*doc.in_srgb(); // Frames export one page each; anything else shares one page. let all_frames = own_area && ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); let groups: Vec> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![drawn] }; pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area)) } _ => { let pix = figcraft_render::render_area(doc, &drawn, area, scale) .ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?; figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other) } } } /// What Copy as PNG puts on the clipboard: the layers together, at 2x (in the document's colour /// profile; the clipboard wants sRGB). pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option { let area = export_area(doc, ids, &ExportSetting::default())?; figcraft_render::render_area(doc, &super::edit::z_sorted(doc, ids), area, 2.0) } fn export(s: &mut Session, p: &Value) -> Result { let cmd = "file.export"; let fig = match string(p, "format") { Some(f) => f.eq_ignore_ascii_case("fig"), None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"), }; if fig { return export_fig(s, p); } let doc = s.doc()?; let mut ids = targets(s, p); if ids.is_empty() { ids = doc.get(s.page()?)?.children.clone(); } let mut setting = export_setting(cmd, ExportSetting::default(), p)?; if string(p, "format").is_none() && let Some(f) = string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))) { setting.format = f; } let scale = match size_param(cmd, p)? { Some(_) => export_scale(doc, &ids, &setting), None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), }; let format = setting.format; let bytes = export_bytes_with(doc, &ids, &setting, scale)?; 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 whole document as a `.fig` file (experimental: only round trips through our own importer /// are verified), with the warnings about what was left out. fn export_fig(s: &mut Session, p: &Value) -> Result { let doc = s.doc()?; let (bytes, warnings) = fig_bytes(doc)?; match string(p, "path") { Some(path) => { write(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "format": "fig", "warnings": warnings})) } None => Ok(json!({"format": "fig", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})), } } /// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out. pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string())) } /// The scale that gives `ids` (their export area together) `setting`'s export size. pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 { export_area(doc, ids, setting).map_or(1.0, |b| setting.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 { 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)?; for (key, slot) in [ ("contentsOnly", &mut e.contents_only), ("includeBoundingBox", &mut e.include_bounding_box), ("svgOutlineText", &mut e.svg_outline_text), ("svgIdAttribute", &mut e.svg_id_attribute), ("svgSimplifyStroke", &mut e.svg_simplify_stroke), ] { 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(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 { 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> { 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::(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 { jobs_for(ids.iter().filter_map(|i| Some((*i, doc.node(*i)?.exports.clone()))), doc) } /// The files exporting each layer with the settings beside it makes (see [`export_jobs`]). pub fn jobs_for(items: impl IntoIterator)>, doc: &Document) -> Vec { let mut used = std::collections::BTreeSet::new(); let mut out = Vec::new(); for (id, settings) in items { let Some(n) = doc.node(id) else { continue }; let base = export_path(&n.name); for e in settings { 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, setting: e, file }); } } } out } /// The file path a layer name exports to, without extension: each `/` makes a folder; `.` and /// `..` segments are dropped, so it stays inside the export folder. fn export_path(name: &str) -> String { let parts: Vec = name.split('/').map(|seg| safe_file_name(seg).trim().trim_end_matches('.').to_string()).filter(|seg| !seg.is_empty()).take(32).collect(); if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } /// `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 { 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 { let cmd = "file.exportLayers"; let doc = s.doc()?; let assets = boolean(p, "assets").unwrap_or(false); let mut ids = targets(s, p); if ids.is_empty() { ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) }; } let jobs = if assets { crate::devinspect::asset_jobs(doc, &ids) } else { 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 setting = ExportSetting { print: print.unwrap_or(job.setting.print), ..job.setting.clone() }; let scale = export_scale(doc, &[job.id], &setting); let bytes = export_bytes_with(doc, &[job.id], &setting, scale)?; 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); // A layer name with slashes exports into folders. if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } let path = path.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 = 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; `area` /// sizes a single page to that page area instead of the layers' bounds. fn pdf_bytes(doc: &Document, groups: &[Vec], print: &PrintSettings, area: Option) -> Result> { let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, area, ..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, 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> = frames.iter().map(|f| vec![*f]).collect(); Ok((pdf_bytes(&doc.in_srgb(), &groups, print, None)?, 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 { 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 { 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> { 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 = 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 = 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 = 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 { 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 { 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); } }