mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
file.saveAs and document.save wrote a PDF-compatible .ai, but document.export (and with it the CLI's run --export x.ai and convert in.svg out.ai, and MCP's export) refused it as a format that can only be opened, and --help didn't list it as writable. The .ai encoder is now shared: save.rs's ai_params and encode_ai (a PDF of every artboard with the PDF options, carrying the native document, blank pages with pdfCompatible: false) serve Save As and the exports alike. The .ai format is writable, so document.formats, --help and the MCP export tool list it; an export leaves the document's own path alone. Export As and Export for Screens keep leaving .ai to Save As. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
606 lines
29 KiB
Rust
606 lines
29 KiB
Rust
//! 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> {
|
||
[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<String>,
|
||
pub format: &'static Format,
|
||
/// The format's options (only those its encoder reads).
|
||
pub options: Map<String, Value>,
|
||
/// Suggested file name: `<name>.<ext>`, `<name> copy.<ext>` or `<name> template.vctemplate`.
|
||
pub name: String,
|
||
/// Suggested folder: the document's own, or the Templates folder for a template.
|
||
pub folder: Option<String>,
|
||
/// 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<Option<i64>>,
|
||
}
|
||
|
||
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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<SavePlan> {
|
||
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<String, Value> = 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<Document> {
|
||
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<String> {
|
||
(!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<Document>,
|
||
/// The encoder's params.
|
||
params: Value,
|
||
/// The document when the job was made: what counts as saved once the file is written.
|
||
snapshot: Arc<Document>,
|
||
/// The artboards also saved to files of their own (`separateArtboards`).
|
||
boards: Vec<usize>,
|
||
}
|
||
|
||
/// 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<Vec<usize>> {
|
||
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<vectorcraft_doc::Node>, 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<Encoded> {
|
||
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<SaveJob> {
|
||
// 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<SaveJob> {
|
||
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<Document> {
|
||
&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<Encoded> {
|
||
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<Encoded> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<CommandSpec> {
|
||
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 <name>-<artboard>.<ext> 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: \"<name> copy.<ext>\", 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: \"<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
|
||
),
|
||
]
|
||
}
|