//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, //! `file.newFromTemplate`, `file.revert` and `file.formatOptions`. //! //! Every frontend saves the same way: [`save_plan`] resolves where and how (path, format, options, //! a suggested name and folder when there is no path), then [`save_with`] encodes, writes through //! the caller's writer (the file system here; a save panel or a browser download in the apps) and //! makes the file the document's own (path, format, options, saved state) for Save and Save As. use std::sync::Arc; use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; use super::super::*; use super::{ ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, write_file, }; use crate::{DocState, Prefs}; /// The formats Save As offers, in menu order (append-only). The other writable formats are exports: /// they never become the document's own file. pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]; /// Save-panel filters `(label, [extension])` for `first` (a format id, or a file name whose /// extension names one): when Save writes that format, one per [`SAVE_FORMATS`] entry with it /// leading (the panel's default type), else none (an export picks its own). Only the extension a /// save writes (never the former native name). pub fn save_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> { let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] }; let mut v: Vec<&'static Format> = SAVE_FORMATS.iter().filter_map(|id| format(id)).collect(); v.sort_by_key(|f| f.id != first.id); v.into_iter().map(|f| (f.label, &f.extensions[..1])).collect() } /// How a save treats the document. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SaveMode { /// File → Save: the document's own path and format. Save, /// File → Save As: a new path or format, which the document takes on. SaveAs, /// File → Save a Copy: the document keeps its path, title and modified state. Copy, /// File → Save as Template: a template copy; the document keeps its path and state too. Template, } impl SaveMode { /// The engine command of this mode. pub fn command(self) -> &'static str { match self { Self::Save => "document.save", Self::SaveAs => "file.saveAs", Self::Copy => "file.saveCopy", Self::Template => "file.saveAsTemplate", } } /// The mode an engine command id runs. pub fn of(command: &str) -> Option { [Self::Save, Self::SaveAs, Self::Copy, Self::Template].into_iter().find(|m| m.command() == command) } } /// A save resolved against the active document, before anything is encoded or written. #[derive(Clone, Debug)] pub struct SavePlan { pub mode: SaveMode, /// Where to write. `None` when no path is known (never saved, converted from an older version, /// restored by Data Recovery, or a format other than the document's own): the caller asks for one or takes the bytes. pub path: Option, pub format: &'static Format, /// The format's options (only those its encoder reads). pub options: Map, /// Suggested file name: `.`, ` copy.` or ` template.vctemplate`. pub name: String, /// Suggested folder: the document's own, or the Templates folder for a template. pub folder: Option, /// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file /// stamps; `None`: the time it is written. pub modified: Option>, } impl SavePlan { /// Does writing this make the file the document's own (Save and Save As, except to a /// template)? pub fn retargets(&self) -> bool { matches!(self.mode, SaveMode::Save | SaveMode::SaveAs) && self.format.id != "template" } } /// File Info's dates for a save to a file: the modified date (and the created date, when it has /// none) become `at`. Not an undo step. pub fn stamp_save_dates(st: &mut DocState, at: i64) { let d = std::sync::Arc::make_mut(&mut st.doc); d.metadata.created.get_or_insert(at); d.metadata.modified = Some(at); } /// The Templates folder: the `templatesFolder` preference, else `Documents/VectorCraft Templates` /// in the user's home (none where there is no home folder, as on the web). pub fn templates_folder(prefs: &Prefs) -> Option { if !prefs.templates_folder.is_empty() { return Some(prefs.templates_folder.clone()); } Some(std::path::Path::new(&home_folder()?).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string()) } /// The user's home folder (`HOME`, else `USERPROFILE` on Windows; none on the web). fn home_folder() -> Option { std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().filter(|h| !h.is_empty()) } /// Where exports go by default (Export for Screens): the user's Desktop when there is one, else /// their home folder (none on the web, which downloads). pub fn export_folder() -> Option { let home = home_folder()?; let desktop = std::path::Path::new(&home).join("Desktop"); Some(if desktop.is_dir() { desktop.to_string_lossy().into_owned() } else { home }) } /// The folder of a path (`None` for a bare file name). fn parent_folder(path: &str) -> Option { std::path::Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) } /// A save format by id or extension, or why it isn't one. fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> { match format(f) { Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f), Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)), None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))), } } /// The format Save writes for `format` (an id or extension), else the format `path`'s extension /// names, else native (see [`SAVE_FORMATS`]); other formats are exports. pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft")) } /// [`save_format_of`] for command `cmd`. fn checked(cmd: &str, f: &str) -> Result<&'static Format> { save_format_of(f).map_err(|e| bad(cmd, e)) } /// Resolve a save of the active document from `{path?, format?, options?}` (see the commands' /// params docs). pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { let cmd = mode.command(); let st = s.doc()?; let path = str_param(p, "path").map(str::to_string); let format = match (mode, str_param(p, "format"), path.as_deref().and_then(format_for_name)) { (SaveMode::Template, ..) => checked(cmd, "template")?, (_, Some(f), _) => checked(cmd, f)?, (_, None, Some(f)) => checked(cmd, f.id)?, (_, None, None) => checked(cmd, st.format)?, }; let mut given: Map = match p.get("options") { Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(), None | Some(Value::Null) => Map::new(), Some(_) => return Err(bad(cmd, "options must be an object (see file.formatOptions)")), }; if is_svg(format) { // SVG options also come flat or as `svg: {…}` beside the path; kept flat. let mut svg = super::svg_options(&Value::Object(given)).map_err(|e| bad(cmd, e))?; svg.extend(super::svg_options(p).map_err(|e| bad(cmd, e))?); given = svg; } else { // So do PDF options (as the Save PDF dialog and document.exportPdf name them) and the // native ones (compress, version, preview). if let Some(o) = p.as_object() { given.extend( o.iter().filter(|(k, v)| !v.is_null() && k.as_str() != "path" && reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())), ); } } // None given: the ones the document was last saved with in this format. let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given }; // Save writes the document's own file; the other modes only where they are told to. let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title)); let ext = format.extensions[0]; let name = match mode { SaveMode::Copy => format!("{stem} copy.{ext}"), SaveMode::Template => format!("{stem} template.{ext}"), SaveMode::Save | SaveMode::SaveAs => format!("{stem}.{ext}"), }; let folder = match mode { SaveMode::Template => templates_folder(&s.prefs), _ => st.path.as_deref().and_then(parent_folder), }; let modified = date_param(p, "modified", cmd)?; Ok(SavePlan { mode, path, format, options, name, folder, modified }) } /// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General /// settings (`createLayers`…) and presets. fn reads_option(f: &Format, key: &str) -> bool { f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key))) } /// Formats that carry the whole native document: they lose nothing, and record links relative to /// where they are written. A `.ai` file is a PDF carrying it. fn is_native(f: &Format) -> bool { matches!(f.id, "vectorcraft" | "template" | "ai") } fn is_pdf(f: &Format) -> bool { matches!(f.id, "pdf" | "ai") } fn is_svg(f: &Format) -> bool { matches!(f.id, "svg" | "svgz") } /// The document as written: native files carry the view to reopen at. fn doc_to_save(st: &DocState, f: &Format) -> Arc { if is_native(f) && st.doc.last_view != st.view { let mut d = (*st.doc).clone(); d.last_view = st.view.clone(); Arc::new(d) } else { st.doc.clone() } } /// What a format loses against a native file (reported whenever a save writes it). fn fidelity_warning(f: &Format) -> Option { (!is_native(f)).then(|| { format!( "{} keeps the artwork but not everything a VectorCraft document holds (editable effects, symbols, swatches, styles): save as VectorCraft to keep it all editable", f.label ) }) } /// A save of the active document, snapshotted and ready to encode and write anywhere (Background /// Save does both on a worker thread): [`save_job`], then [`SaveJob::write`], then /// [`SaveJob::finish`]. Cheap to clone (the documents are shared). #[derive(Clone)] pub struct SaveJob { plan: SavePlan, /// The document as written (with the view to reopen at, links relative to the file). doc: Arc, /// The encoder's params. params: Value, /// The document when the job was made: what counts as saved once the file is written. snapshot: Arc, /// The artboards also saved to files of their own (`separateArtboards`). boards: Vec, } /// The artboards a native or `.ai` save also writes to files of their own: with /// `separateArtboards`, those `range` names (default all). fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { if !is_native(f) || !bool_or(p, "separateArtboards", false) { return Ok(vec![]); } let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string); let n = doc.artboards.len(); let picked = ArtboardPick { range, ..Default::default() }.resolve(n).map_err(|e| bad(cmd, e))?; Ok(picked.unwrap_or_else(|| (0..n).collect())) } /// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file): /// objects whose bounds touch the artboard, in their layers; resources are kept. fn artboard_doc(doc: &Document, i: usize) -> Document { let mut d = doc.clone(); let Some(a) = doc.artboards.get(i) else { return d }; let r = a.rect; d.artboards = vec![a.clone()]; // It opens fitted to its artboard, not at the master file's view. d.last_view = None; for layer in &mut d.layers { keep_on(layer, r); } d } /// Keep in layer `n` (and its sublayers) only the objects touching `r`. fn keep_on(n: &mut Arc, r: vectorcraft_geom::Rect) { let touches = |b: vectorcraft_geom::Rect| b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0; if let Some(children) = Arc::make_mut(n).children_mut() { children.retain(|c| c.is_layer() || c.visual_bounds().is_some_and(touches)); for c in children.iter_mut().filter(|c| c.is_layer()) { keep_on(c, r); } } } /// `doc` with empty layers: the pages of a `.ai` file saved without PDF content. fn blank_pages(doc: &Document) -> Document { let mut d = doc.clone(); for layer in &mut d.layers { if let Some(children) = Arc::make_mut(layer).children_mut() { children.clear(); } } d } /// Why a `.ai` file saved without PDF content looks empty elsewhere. const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; /// The encoder's params for a `.ai` file, from a save's or an export's: a PDF of every artboard /// with the PDF options, always carrying the native document; Use Compression compresses the /// PDF's content. pub(super) fn ai_params(params: &mut Value) { let Some(o) = params.as_object_mut() else { return }; o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); if let Some(c) = o.get("compress").and_then(Value::as_bool) { let compression = o.entry("compression").or_insert_with(|| json!({})); if let Some(m) = compression.as_object_mut() { m.insert("compressText".into(), json!(c)); } } } /// Encode `doc` as a PDF-compatible `.ai` file (`params` as [`ai_params`] makes them) for command /// `cmd`: its pages (blank with `pdfCompatible: false`, which it then notes), carrying the native /// document. File › Save As writes it, and so do the exports (`document.export`, the CLI). pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, params: &Value) -> Result { let compatible = bool_or(params, "pdfCompatible", true); let pages = doc.without_edit_modes(); let pages = if compatible { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) }; let native = || super::native::ai_native(cmd, f, doc, params); let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, params, native)?; if !compatible { warnings.insert(0, NOT_PDF_COMPATIBLE.to_string()); } Ok(Encoded { warnings, ..Encoded::one(bytes) }) } /// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the /// document's own). Bad options fail here, before anything is encoded. pub fn save_job(s: &mut Session, plan: SavePlan) -> Result { // The web build, without a clock, leaves the dates unless given one. if plan.path.is_some() && plan.retargets() && let Some(at) = clock_date(s, "modified", plan.modified) { stamp_save_dates(s.doc_mut()?, at); } job_for(s, s.doc()?, plan) } /// Snapshot `st` (any open document of `s`) for `plan`, without stamping dates (Data Recovery /// snapshots documents that aren't active). pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result { let cmd = plan.mode.command(); let own = doc_to_save(st, plan.format); // A native file records its links' paths relative to where it is written. let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p)); let doc = relative.map_or(own, Arc::new); let mut params = plan.options.clone(); if is_svg(plan.format) { // A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out. params.entry("hiddenLayers").or_insert(Value::Bool(true)); } let mut params = Value::Object(params); if matches!(plan.format.id, "vectorcraft" | "template") { // Not remembered with the options: the preference decides each time it isn't given. params = with_compression_pref(&s.prefs, ¶ms); super::native::save_options(cmd, plan.format, &doc, ¶ms)?; } let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?; let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned(); if plan.format.id == "ai" { ai_params(&mut params); } Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards }) } impl SaveJob { pub fn plan(&self) -> &SavePlan { &self.plan } /// The document when the job was made (what counts as saved once the file is written). pub(crate) fn snapshot(&self) -> &Arc { &self.snapshot } /// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with /// the warnings (what the format loses first, then the encoder's own notes: PDF options not /// applied yet…). pub fn encode(&self) -> Result { let mut enc = self.encode_one(&self.doc)?; for &b in &self.boards { let one = self.encode_one(&artboard_doc(&self.doc, b))?; enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); } enc.warnings.splice(0..0, fidelity_warning(self.plan.format)); Ok(enc) } /// Encode one file of `doc` in the job's format. fn encode_one(&self, doc: &Document) -> Result { let (cmd, f) = (self.plan.mode.command(), self.plan.format); let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? }; if enc.files.len() != 1 { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } Ok(enc) } /// Encode the file and write it with `write` (the file, then its artboards' files and the /// images an SVG links to, beside it). Without a path → `{dataBase64, bytes, format, name, /// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several), /// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?: /// [path…], linked?: [path…]}`. pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let (plan, doc) = (&self.plan, &*self.doc); let mut enc = self.encode()?; let warnings = std::mem::take(&mut enc.warnings); let extra = json!({ "format": plan.format.id, "warnings": warnings }); let Some(path) = &plan.path else { let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?; out["name"] = json!(plan.name); if let Some(folder) = &plan.folder { out["folder"] = json!(folder); } return Ok(out); }; let files = enc.named(doc, path); for (p, bytes) in &files { write(p, bytes)?; } let bytes = files.first().map_or(0, |f| f.1.len()); let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra); let (main, linked) = files.split_at(enc.files.len().min(files.len())); if main.len() > 1 { out["files"] = main.iter().map(|(p, _)| json!(p)).collect(); } if !linked.is_empty() { out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect(); } Ok(out) } /// Once written: Save and Save As (except to a template) make the file `st`'s own: path, /// title, format, options, and the snapshot as the saved state (edits made since the job was /// made keep the document modified). pub fn finish(self, st: &mut DocState) { let retargets = self.plan.retargets(); if let (true, Some(path)) = (retargets, self.plan.path) { st.path = Some(path); st.format = self.plan.format.id; st.save_options = self.plan.options; st.converted = false; st.recovered = false; st.mark_saved_as(&self.snapshot); } } /// [`SaveJob::finish`] for document `uid` of `s`, if it is still open; a file that became /// its own also ends its Data Recovery copy. pub fn complete(self, s: &mut Session, uid: u64) { let saved = self.plan.retargets() && self.plan.path.is_some(); // The other open documents that place this one show the new version. let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); if let Some(st) = s.document_mut(uid) { self.finish(st); } if saved { crate::cmd::recovery::forget(s, uid); } if let Some(path) = written { crate::cmd::links::refresh_placed(s, uid, &path); } } } /// Encode `plan` and write it with `write`, then make the file the document's own (see /// [`SaveJob`]). Without a path the document is unchanged. pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let job = save_job(s, plan)?; let uid = s.doc()?.uid; let out = job.write(write)?; job.complete(s, uid); Ok(out) } /// A save command: plan, then write with the file system. fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result { let plan = save_plan(s, mode, p)?; save_with(s, plan, write_file) } fn can_revert(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; match (&st.path, st.is_dirty()) { (None, _) => Err("the document has never been saved".into()), (_, false) => Err("no changes since the last save".into()), _ => Ok(()), } } /// File → Revert: read and decode the saved file first (on any failure the document is left as it /// is), then replace the document in its tab. fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?; doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); Ok(json!({ "path": path })) } /// `file.formatOptions`: a writable format's options with the values Save would use. fn format_options(s: &mut Session, p: &Value) -> Result { const C: &str = "file.formatOptions"; let st = s.active(); let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft"); let f = format(id) .filter(|f| f.write || SAVE_FORMATS.contains(&f.id)) .ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); let mut v = f.to_json(); if let Some(options) = v["options"].as_object_mut() { for (name, o) in options.iter_mut() { let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]); o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(default).clone(); } } v["saveFormats"] = SAVE_FORMATS.iter().filter_map(|id| format(id)).map(|f| json!({"id": f.id, "label": f.label, "extensions": f.extensions})).collect(); Ok(v) } pub(super) fn specs() -> Vec { vec![ cmd!( "document.save", "Save Document", [], None, "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), cmd!( "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified? (as document.save)} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", has_doc, |s, p| save(s, SaveMode::SaveAs, p) ), cmd!( "file.saveCopy", "Save a Copy…", ["File"], Some("Cmd+Alt+S"), "{path?, format?, options?} (as file.saveAs) write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \" copy.\", folder?, warnings}", has_doc, |s, p| save(s, SaveMode::Copy, p) ), cmd!( "file.saveAsTemplate", "Save as Template…", ["File"], None, "{path?, compress?, version?, preview? (as document.save)} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \" template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}", has_doc, |s, p| save(s, SaveMode::Template, p) ), cmd!( "file.newFromTemplate", "New from Template…", ["File"], Some("Cmd+Shift+N"), "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document → {index, title, format, warnings}", always, super::load::new_from_template ), cmd!( "file.revert", "Revert", ["File"], Some("F12"), "{} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true", can_revert, revert ), cmd!( query "file.formatOptions", "Format Options", [], None, "{format?: a writable format id (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default", always, format_options ), ] }