//! File menu: documents, save/open, import/export, versions. use std::sync::Arc; use base64::Engine as _; use figcraft_doc::{ BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle, VdCompat, }; use figcraft_format::Version; use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_string, string, targets}; use crate::{DocState, EngineError, Result, Session, bad, spec}; mod export; mod load; mod open; mod save; mod version; pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS; #[cfg(test)] use export::add_member; pub(super) use export::export_setting; pub use export::{ ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes, frames_pdf, jobs_for, safe_file_name, }; use export::{export, export_frames_pdf, export_layers, pdf_bytes}; #[cfg(test)] pub(crate) use load::{installed_family, sketch_line}; pub use load::{load, load_bytes, load_path}; pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write}; pub use open::MAX_RECENT; #[cfg(test)] use open::append_pages; use open::{import, open, open_recent}; pub(crate) use save::save_ends_preview; pub use save::{FileSnapshot, file_bytes, thumbnail}; use save::{color_profile, mark_saved, save, save_as}; pub use version::MAX_AUTOSAVES; pub(crate) use version::{add_version, keep_in_history, read_version, unique_id}; use version::{autosave, restore_version, save_version, version_index, version_json, version_list}; /// A new file of `kind` in a tab of its own. fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result { let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into()); let mut doc = Document::new(name); doc.kind = kind; // A property of the page from here on: a later change of theme leaves it alone. Boards are // light whatever the theme. let background = if kind == FileKind::Board { BOARD_PAGE } else { 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, "kind": kind.id()})) } pub fn specs() -> Vec { vec![ spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)), spec!( "file.newBoard", "New board", "{name?} a board: sticky notes, shapes with text, connectors, marker and stamps on a light page", false, |s, p| new_file(s, p, FileKind::Board) ), spec!( "file.open", "Open", "{path | data (base64) + name (tells the format; opens as a new unsaved document), 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), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", false, open ), spec!( "file.openRecent", "Open recent", "{path? | index? (into the list), clear? (forget them all), remove? (a path: forget that one)} 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?, overwrite? (replace what another app wrote to the file since it was opened or saved: without it that save is refused)} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save ), spec!( "file.saveAs", "Save as", "{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save_as ), spec!( "file.markSaved", "Mark saved", "{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)", false, mark_saved ), spec!( "file.export", "Export", "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), 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), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}", 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`), zip? (no dir: the files come back together as one base64 zip `data` instead), 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, vdCompat? (bestEffort|exact|ignore: for every XML 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, warnings (XML only)}], skipped (layers without settings), data? (zip)}", 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, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", 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.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| { let from = req_index("file.moveTab", p, "from")?; let to = req_index("file.moveTab", p, "to")?; if from >= s.docs.len() || to >= s.docs.len() { return Err(bad("file.moveTab", "no such document")); } if from != to { let active_uid = s.docs.get(s.active).map(|d| d.uid); let doc = s.docs.remove(from); s.docs.insert(to, doc); s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0); } Ok(json!({"index": to})) }), 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", "{} leave the live session first, if any", false, |s, _| { let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?; // Stamped before reading: a change made during the read makes the next save refuse. let on_disk = crate::library::stat(&path).ok(); 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.clone())) .with_versions(loaded.versions) .with_branches(loaded.branches) .with_images_left_in_file(&path, loaded.images_past_budget); slot.on_disk = on_disk; } // 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 (with `pref.autoSave` on: changes made since it was opened); 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_index("doc.switch", p, "index")?; 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() }), ] } /// [`export_bytes_with`] and what the export changed or left out (Android Vector Drawable only; /// empty for the other formats). pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<(Vec, Vec)> { let nothing = || EngineError::Disabled("file.export".into(), "nothing to export".into()); if ids.is_empty() { return Err(nothing()); } let drawn = drawn_layers(doc, ids, setting); if setting.format.is_vector() { figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?; } let area = export_area(doc, ids, setting).ok_or_else(nothing)?; // 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), }; let svg = figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e { figcraft_svg::SvgError::Empty => nothing(), other => EngineError::Other(other.to_string()), })?; Ok((svg.into_bytes(), Vec::new())) } // Android colours are sRGB too. ExportFormat::VectorDrawable => { let compat = match setting.vd_compat { VdCompat::BestEffort => figcraft_vectordrawable::Compat::BestEffort, VdCompat::Exact => figcraft_vectordrawable::Compat::Exact, VdCompat::Ignore => figcraft_vectordrawable::Compat::Ignore, }; let opts = figcraft_vectordrawable::VdOptions { bounds: Some(area), include_names: setting.svg_id_attribute, compat }; let out = figcraft_vectordrawable::try_export(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e { figcraft_vectordrawable::VdError::Empty => nothing(), other => EngineError::Other(other.to_string()), })?; Ok((out.xml.into_bytes(), out.warnings)) } 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] }; Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new())) } _ => { let pix = figcraft_render::render_area(doc, &drawn, area, scale) .ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?; Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new())) } } } /// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error). fn vd_compat_param(cmd: &str, p: &Value) -> Result> { match get(p, "vdCompat") { None | Some(Value::Null) => Ok(None), Some(v) => v.as_str().and_then(VdCompat::from_key).map(Some).ok_or_else(|| bad(cmd, "vdCompat: bestEffort|exact|ignore")), } } fn close(s: &mut Session, p: &Value) -> Result { let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? }; 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; let active_uid = s.docs.get(s.active).map(|d| d.uid); s.remove_tabs(|d| d.uid == uid); // Removing a tab before the active one shifts its index; keep the same document selected. s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1))); crate::branches::close_tabs(s, uid, None); ok() } #[cfg(test)] mod tab_order_tests; /// 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; #[cfg(test)] mod staging_write_tests; #[cfg(test)] mod zip_cap_tests;