811 lines
39 KiB
Rust
811 lines
39 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::io::Write 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,
|
|
};
|
|
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) {
|
|
stamp_doc_dates(std::sync::Arc::make_mut(&mut st.doc), at);
|
|
}
|
|
|
|
fn stamp_doc_dates(d: &mut Document, at: i64) {
|
|
d.metadata.created.get_or_insert(at);
|
|
d.metadata.modified = Some(at);
|
|
}
|
|
|
|
/// The Templates folder: the `templatesFolder` preference, else `VectorCraft Templates` in the
|
|
/// user's documents folder (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(documents_folder()?.join("VectorCraft Templates").to_string_lossy().to_string())
|
|
}
|
|
|
|
/// The Templates folder for a file dialog to start in (#703): made when it's missing; where it
|
|
/// can't be, the nearest folder above it that exists, else the home folder. A dialog asked to
|
|
/// start in a folder that isn't there shows an error on some desktops.
|
|
pub fn templates_dialog_folder(prefs: &Prefs) -> Option<String> {
|
|
let folder = std::path::PathBuf::from(templates_folder(prefs)?);
|
|
if !folder.is_dir() {
|
|
// Best effort: a folder that can't be made leaves the nearest one that exists.
|
|
let _ = std::fs::create_dir_all(&folder);
|
|
}
|
|
folder.ancestors().find(|a| a.is_dir()).map(|a| a.to_string_lossy().into_owned()).or_else(home_folder)
|
|
}
|
|
|
|
/// The user's documents folder: on Linux and the BSDs the one the desktop names
|
|
/// (`XDG_DOCUMENTS_DIR` in `user-dirs.dirs`: `~/Documenti`, `~/Dokumente`…), elsewhere `Documents`
|
|
/// in the home folder.
|
|
fn documents_folder() -> Option<std::path::PathBuf> {
|
|
let home = std::path::PathBuf::from(home_folder()?);
|
|
if cfg!(all(unix, not(target_os = "macos"))) {
|
|
let config =
|
|
std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| home.join(".config"));
|
|
if let Some(dir) = std::fs::read_to_string(config.join("user-dirs.dirs")).ok().and_then(|t| xdg_documents(&t, &home)) {
|
|
return Some(dir);
|
|
}
|
|
}
|
|
Some(home.join("Documents"))
|
|
}
|
|
|
|
/// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't
|
|
/// set, is relative, or is the home folder itself (the spec's way of turning it off).
|
|
fn xdg_documents(text: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
|
|
let line = text.lines().map(str::trim).find(|l| l.starts_with("XDG_DOCUMENTS_DIR="))?;
|
|
let value = line.split_once('=')?.1.trim().trim_matches('"');
|
|
let dir = match value.strip_prefix("$HOME") {
|
|
Some(rest) => home.join(rest.trim_start_matches('/')),
|
|
None => std::path::PathBuf::from(value),
|
|
};
|
|
(dir.has_root() && dir != home).then_some(dir)
|
|
}
|
|
|
|
/// 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_dialog_folder(&s.prefs),
|
|
_ => st.path.as_deref().and_then(parent_folder),
|
|
};
|
|
let modified = date_param(p, "modified", cmd)?;
|
|
if let Some(path) = &path {
|
|
super::check_not_lossy_overwrite(st, path, p, 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 and the Layers panel's open
|
|
/// rows.
|
|
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
|
|
if !is_native(f) {
|
|
return st.doc.clone();
|
|
}
|
|
let open = st.layers_open.saved(&st.doc);
|
|
if st.doc.last_view == st.view && st.doc.layers_open == open {
|
|
return st.doc.clone();
|
|
}
|
|
let mut d = (*st.doc).clone();
|
|
d.last_view = st.view.clone();
|
|
d.layers_open = open;
|
|
Arc::new(d)
|
|
}
|
|
|
|
/// 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 live document `snapshot` was made from, and the date stamped on `snapshot` (not on the
|
|
/// live document until the file is written).
|
|
source: Arc<Document>,
|
|
stamped: Option<i64>,
|
|
/// The artboards also saved to files of their own (`separateArtboards`).
|
|
boards: Vec<usize>,
|
|
}
|
|
|
|
/// Writes the file at a path with what the filler it is given writes into it
|
|
/// ([`SaveJob::write_streamed`]).
|
|
pub type StreamWrite<'a> = &'a mut dyn FnMut(&str, &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()>;
|
|
|
|
/// Bytes gathered before a streamed save writes them to the file.
|
|
const STREAM_BUFFER: usize = 256 << 10;
|
|
|
|
/// A writer that counts the bytes written through it.
|
|
struct Counted<W> {
|
|
inner: W,
|
|
n: u64,
|
|
}
|
|
|
|
impl<W: std::io::Write> std::io::Write for Counted<W> {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
let n = self.inner.write(buf)?;
|
|
self.n += n as u64;
|
|
Ok(n)
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
self.inner.flush()
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
d.layers_open = 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 shared Save and Export encoder for PDF-compatible `.ai` files.
|
|
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
|
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
|
if let Some(o) = params.as_object_mut() {
|
|
o.insert("preserveEditing".into(), json!(true));
|
|
// Keep upstream AI PDF layers, including hidden layers, unless a standard decides.
|
|
if o.get("standard").is_none_or(Value::is_null) {
|
|
o.entry("createLayers").or_insert(json!(true));
|
|
}
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?;
|
|
let pdf_compatible = bool_or(¶ms, "pdfCompatible", true);
|
|
if !pdf_compatible {
|
|
let picked: ArtboardPick = super::encode::options(f, ¶ms)?;
|
|
let picked = picked.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
|
if picked.is_some_and(|v| !v.into_iter().eq(0..doc.artboards.len())) {
|
|
return Err(bad(cmd, "pdfCompatible: false needs every artboard to keep the editable artwork"));
|
|
}
|
|
}
|
|
let pages = doc.without_edit_modes();
|
|
// Resolve placed files only for the drawn pages. Missing files become preview images there;
|
|
// the native attachment keeps the original placed objects and their relinkable metadata.
|
|
let (pages, placed_warnings) =
|
|
if pdf_compatible { crate::cmd::place::document::full_documents(&pages) } else { (std::borrow::Cow::Owned(blank_pages(&pages)), Vec::new()) };
|
|
let native = || super::native::ai_native(cmd, doc, &native_options);
|
|
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native)?;
|
|
warnings.extend(placed_warnings);
|
|
if !pdf_compatible {
|
|
warnings.push(NOT_PDF_COMPATIBLE.to_string());
|
|
}
|
|
Ok(Encoded { warnings, ..Encoded::one(bytes) })
|
|
}
|
|
|
|
/// Snapshot the active document for `plan` (stamping File Info's dates on the snapshot when the
|
|
/// file becomes the document's own; the live document gets them in [`SaveJob::finish`]). 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.
|
|
let at = if plan.path.is_some() && plan.retargets() { clock_date(s, "modified", plan.modified) } else { None };
|
|
job_for_stamped(s, s.doc()?, plan, at)
|
|
}
|
|
|
|
/// 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> {
|
|
job_for_stamped(s, st, plan, None)
|
|
}
|
|
|
|
fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i64>) -> Result<SaveJob> {
|
|
let cmd = plan.mode.command();
|
|
let mut own = doc_to_save(st, plan.format);
|
|
let mut snapshot = st.doc.clone();
|
|
if let Some(at) = stamped {
|
|
stamp_doc_dates(Arc::make_mut(&mut own), at);
|
|
stamp_doc_dates(Arc::make_mut(&mut snapshot), at);
|
|
}
|
|
// 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 let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
|
|
// Save keeps every artboard; exports may choose a subset instead.
|
|
o.insert("range".into(), json!("all"));
|
|
}
|
|
Ok(SaveJob { plan, doc, params, snapshot, source: st.doc.clone(), stamped, 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
|
|
}
|
|
|
|
/// The document the job writes (the snapshot, as the file keeps it).
|
|
pub(crate) fn doc(&self) -> &Arc<Document> {
|
|
&self.doc
|
|
}
|
|
|
|
/// Encode `doc` (the job's document, changed) as the job encodes its own: one file.
|
|
pub(crate) fn encode_doc(&self, doc: &Document) -> Result<Encoded> {
|
|
self.encode_one(doc)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Is the file written as it is encoded by [`SaveJob::write_streamed`]? A native file (or
|
|
/// template) to a path, without a file per artboard.
|
|
pub fn streams(&self) -> bool {
|
|
self.plan.path.is_some() && matches!(self.plan.format.id, "vectorcraft" | "template") && self.boards.is_empty()
|
|
}
|
|
|
|
/// [`SaveJob::write`] through `stream`, which writes the file at a path with what the filler
|
|
/// it is given writes (atomically, as [`vectorcraft_format::write_atomic_with`]). A native
|
|
/// file goes into it as it is encoded ([`SaveJob::streams`]), never held whole in memory;
|
|
/// other files are encoded first, then written into it.
|
|
pub fn write_streamed(&self, stream: StreamWrite) -> Result<Value> {
|
|
let (Some(path), true) = (&self.plan.path, self.streams()) else {
|
|
return self.write(|p, b| stream(p, &mut |w| w.write_all(b)));
|
|
};
|
|
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
|
let doc = self.doc.without_edit_modes();
|
|
let doc = match f.id {
|
|
// A native file flagged so opening it starts a new untitled document (as
|
|
// `encode_all` writes it).
|
|
"template" => {
|
|
let mut d = doc.into_owned();
|
|
d.template = true;
|
|
std::borrow::Cow::Owned(d)
|
|
}
|
|
_ => doc,
|
|
};
|
|
// The encoder's failure, kept apart from the writer's (which `stream` reports).
|
|
let mut failed = None;
|
|
let mut written = 0u64;
|
|
let mut fill = |w: &mut dyn std::io::Write| {
|
|
let mut out = Counted { inner: std::io::BufWriter::with_capacity(STREAM_BUFFER, w), n: 0 };
|
|
let r = super::native::encode_to(cmd, f, &doc, &self.params, &mut out);
|
|
written = out.n;
|
|
match r {
|
|
Ok(()) => out.inner.flush(),
|
|
Err(e) => {
|
|
failed = Some(e);
|
|
Err(std::io::Error::other("the file couldn't be encoded"))
|
|
}
|
|
}
|
|
};
|
|
let r = stream(path, &mut fill);
|
|
r.map_err(|e| failed.take().unwrap_or(e))?;
|
|
let warnings: Vec<String> = fidelity_warning(f).into_iter().collect();
|
|
Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings }))
|
|
}
|
|
|
|
/// 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;
|
|
if let Some(at) = self.stamped {
|
|
if Arc::ptr_eq(&st.doc, &self.source) {
|
|
st.doc = self.snapshot.clone();
|
|
} else {
|
|
// Edited while the save ran: the dates still go on, the edits stay unsaved. The
|
|
// undo history holds the document as it was before the dates, so that is the
|
|
// saved state: undoing back to it leaves the document unmodified.
|
|
stamp_save_dates(st, at);
|
|
return st.mark_saved_as(&self.source);
|
|
}
|
|
}
|
|
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 (a native file as it is encoded).
|
|
fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value> {
|
|
let plan = save_plan(s, mode, p)?;
|
|
let job = save_job(s, plan)?;
|
|
let uid = s.doc()?.uid;
|
|
let out = job.write_streamed(&mut super::write_file_with)?;
|
|
job.complete(s, uid);
|
|
Ok(out)
|
|
}
|
|
|
|
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)?)?.with_grid_prefs(&s.prefs);
|
|
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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead, compress?: bool (native, also in options: gzip; default: the useCompression preference), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), 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; a .ai file's compress, default true, is its 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"),
|
|
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified?} 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"),
|
|
"{…as document.save} {path?, format?, options?} 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,
|
|
"{…as document.save} {path?, compress?, version?, preview?} 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"),
|
|
"{confirmed?: bool (the app's own: it asks first, then runs the command with it)} 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
|
|
),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests_folders {
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// #703: the documents folder a desktop names in `user-dirs.dirs`, not an English `Documents`.
|
|
#[test]
|
|
fn the_desktops_documents_folder_is_read_from_user_dirs() {
|
|
let home = Path::new("/home/david");
|
|
let file = "# comment\nXDG_DESKTOP_DIR=\"$HOME/Scrivania\"\nXDG_DOCUMENTS_DIR=\"$HOME/Documenti\"\n";
|
|
assert_eq!(super::xdg_documents(file, home), Some(PathBuf::from("/home/david/Documenti")));
|
|
assert_eq!(super::xdg_documents("XDG_DOCUMENTS_DIR=\"/data/docs\"", home), Some(PathBuf::from("/data/docs")));
|
|
// "$HOME/" turns it off; a relative or missing one isn't used.
|
|
assert_eq!(super::xdg_documents("XDG_DOCUMENTS_DIR=\"$HOME/\"", home), None);
|
|
assert_eq!(super::xdg_documents("XDG_DOCUMENTS_DIR=\"docs\"", home), None);
|
|
assert_eq!(super::xdg_documents("XDG_MUSIC_DIR=\"$HOME/Musica\"", home), None);
|
|
}
|
|
|
|
/// #703: the Templates folder a dialog starts in exists: made when missing, else the nearest
|
|
/// folder above it.
|
|
#[test]
|
|
fn the_templates_dialog_folder_exists() {
|
|
let base = std::env::temp_dir().join(format!("vc-templates-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
std::fs::create_dir_all(&base).unwrap();
|
|
let mut prefs = crate::Prefs::default();
|
|
let wanted = base.join("Modelli").join("VectorCraft Templates");
|
|
prefs.templates_folder = wanted.to_string_lossy().into_owned();
|
|
assert_eq!(super::templates_dialog_folder(&prefs).map(PathBuf::from), Some(wanted.clone()));
|
|
assert!(wanted.is_dir(), "made");
|
|
// A file in the way: the nearest folder that exists.
|
|
std::fs::write(base.join("blocked"), b"x").unwrap();
|
|
prefs.templates_folder = base.join("blocked").join("Templates").to_string_lossy().into_owned();
|
|
assert_eq!(super::templates_dialog_folder(&prefs).map(PathBuf::from), Some(base.clone()));
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
}
|