diff --git a/Cargo.lock b/Cargo.lock
index ea3394025..f87398f6e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4533,6 +4533,7 @@ dependencies = [
"log",
"rfd",
"vectorcraft-engine",
+ "vectorcraft-format",
"vectorcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml
index c3e927588..cedc22825 100644
--- a/apps/vectorcraft-web/Cargo.toml
+++ b/apps/vectorcraft-web/Cargo.toml
@@ -16,6 +16,7 @@ path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
+vectorcraft-format = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -26,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage"] }
diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs
index d3f966d73..4dc0bb27a 100644
--- a/apps/vectorcraft-web/src/web.rs
+++ b/apps/vectorcraft-web/src/web.rs
@@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
+use vectorcraft_engine::cmd::recovery::RecoveryStore;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
@@ -172,10 +173,52 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic
}
})),
inbox: Some(inbox),
+ recovery_store: Some(Arc::new(BrowserStore)),
..Default::default()
}
}
+/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
+/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`/`. It has no
+/// locks: each tab holds its area with a heartbeat, judged by the browser's clock.
+struct BrowserStore;
+
+const RECOVERY_PREFIX: &str = "vectorcraft-recovery/";
+
+fn js_err(e: wasm_bindgen::JsValue) -> String {
+ format!("{e:?}")
+}
+
+fn local_storage() -> Result {
+ web_sys::window().ok_or("no window")?.local_storage().map_err(js_err)?.ok_or_else(|| "browser storage is turned off".into())
+}
+
+impl RecoveryStore for BrowserStore {
+ fn list(&self) -> Result, String> {
+ let s = local_storage()?;
+ let n = s.length().map_err(js_err)?;
+ Ok((0..n).filter_map(|i| s.key(i).ok().flatten()).filter_map(|k| k.strip_prefix(RECOVERY_PREFIX).map(str::to_string)).collect())
+ }
+ fn read(&self, name: &str) -> Result, String> {
+ let text = local_storage()?.get_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)?;
+ text.and_then(|t| vectorcraft_format::base64_decode(&t)).ok_or_else(|| format!("{name}: no such recovery entry"))
+ }
+ fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
+ local_storage()?
+ .set_item(&format!("{RECOVERY_PREFIX}{name}"), &vectorcraft_format::base64_encode(bytes))
+ .map_err(|e| format!("browser storage is full or turned off: {}", js_err(e)))
+ }
+ fn remove(&self, name: &str) -> Result<(), String> {
+ local_storage()?.remove_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)
+ }
+ fn location(&self) -> String {
+ "browser storage".into()
+ }
+ fn now(&self) -> Option {
+ Some((js_sys::Date::now() / 1000.0) as i64)
+ }
+}
+
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs
index db1aed4ef..b544c55fc 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -252,6 +252,12 @@ fn main() -> eframe::Result {
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
+ // Data Recovery copies live next to the preferences too (none for runs without
+ // preferences, such as agents' test runs, unless the recoveryFolder preference is set).
+ if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
+ let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
+ app.session.recovery.set_default_folder(recovery);
+ }
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs
index 70029d84d..fa0af5355 100644
--- a/crates/engine/src/cmd/fileio/encode.rs
+++ b/crates/engine/src/cmd/fileio/encode.rs
@@ -85,8 +85,9 @@ impl Encoded {
}
/// Every file to write for the destination `path` (a path or a file name): the file itself
- /// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images
- /// beside it under their own names, then the image maps as `{image stem}.html` or `.map`.
+ /// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard
+ /// among several, as Save's master file, is the file itself), then the linked images beside it
+ /// under their own names, then the image maps as `{image stem}.html` or `.map`.
pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> {
// Siblings keep the path's own separators (this runs on every platform and on the web).
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
@@ -94,15 +95,19 @@ impl Encoded {
let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() {
[(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))],
files => {
- let boards: Vec = files.iter().map(|(b, _)| b.unwrap_or(0)).collect();
+ let boards: Vec = files.iter().filter_map(|(b, _)| *b).collect();
let file = std::path::Path::new(&path[dir.len()..]);
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let ext = file.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
- super::artboard_file_names(doc, &boards)
- .into_iter()
- .zip(files)
- .map(|(board, (_, bytes))| {
- (sibling(&format!("{stem}{}{board}{ext}", self.joiner.unwrap_or("-"))), Cow::Borrowed(bytes.as_slice()))
+ let mut names = super::artboard_file_names(doc, &boards).into_iter();
+ files
+ .iter()
+ .map(|(b, bytes)| {
+ let name = match b {
+ None => path.to_string(),
+ Some(_) => sibling(&format!("{stem}{}{}{ext}", self.joiner.unwrap_or("-"), names.next().unwrap_or_default())),
+ };
+ (name, Cow::Borrowed(bytes.as_slice()))
})
.collect()
}
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 7c6e43cfb..dbef8d832 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -90,8 +90,8 @@ fn restore_or_import(
let mut fallback = None;
if let Some((intact, bytes)) = editing {
let native = if intact { bytes() } else { None };
- match native.and_then(|b| vectorcraft_format::load(&b).ok()) {
- Some(doc) => return Ok((doc, vec![], true)),
+ match native.and_then(|b| native_file(&b).ok()) {
+ Some((doc, _, warnings)) => return Ok((doc, warnings, true)),
None => fallback = Some(if intact { EDITING_DAMAGED } else { EDITING_STALE }),
}
}
@@ -102,6 +102,14 @@ fn restore_or_import(
Ok((doc, warnings, false))
}
+/// A native file's document and details, the colour profiles it carries installed where missing
+/// (with a warning for each that can't be used).
+pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec)> {
+ let f = vectorcraft_format::load_file(bytes).map_err(err)?;
+ let warnings = super::native::install_profiles(f.profiles);
+ Ok((f.doc, f.info, warnings))
+}
+
/// Read a file of any readable format (`name`: its file name or path, for the extension and title;
/// a path's folder is where an SVG's relative image links are found).
pub fn load(name: &str, bytes: &[u8]) -> Result {
@@ -123,9 +131,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
let mut converted = false;
let (mut doc, warnings, restored) = match format.id {
"vectorcraft" | "template" => {
- let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?;
+ let (d, info, warnings) = native_file(bytes)?;
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
- (d, vec![], false)
+ (d, warnings, false)
}
"svg" | "svgz" => {
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs
index 756e74368..4fdf8083a 100644
--- a/crates/engine/src/cmd/fileio/mod.rs
+++ b/crates/engine/src/cmd/fileio/mod.rs
@@ -43,10 +43,12 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub use export::export_source;
use load::err;
pub(crate) use load::import_svg;
+pub(crate) use load::native_file;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
+pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
templates_folder,
@@ -359,7 +361,17 @@ pub const FORMATS: &[Format] = &[
options: svg::OPTIONS,
},
Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS },
- reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
+ Format {
+ id: "ai",
+ label: "PDF-compatible .ai",
+ extensions: &["ai"],
+ mime: "application/pdf",
+ read: true,
+ // Saved (Save As), not exported.
+ write: false,
+ raster: false,
+ options: native::AI_OPTIONS,
+ },
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
Format {
id: "png",
@@ -648,7 +660,7 @@ fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document
}
/// `a` with the fields of `b`.
-fn merge(mut a: Value, b: Value) -> Value {
+pub(crate) fn merge(mut a: Value, b: Value) -> Value {
if let (Some(a), Value::Object(b)) = (a.as_object_mut(), b) {
a.extend(b);
}
diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs
index aef6dd8a0..dda393864 100644
--- a/crates/engine/src/cmd/fileio/native.rs
+++ b/crates/engine/src/cmd/fileio/native.rs
@@ -1,8 +1,13 @@
//! The native format's options: compression, the version to write (older apps read older
-//! versions) and an embedded preview.
+//! versions), an embedded preview, and the save options native and `.ai` files share: each
+//! artboard to a separate file, Include Linked Files, Embed ICC Profiles and Create
+//! PDF-Compatible File.
+
+use std::collections::BTreeMap;
use serde::Deserialize;
-use serde_json::Value;
+use serde_json::{Value, json};
+use vectorcraft_color::cms;
use vectorcraft_doc::Document;
use vectorcraft_format::{PREVIEW_MAX, SaveOptions};
@@ -29,33 +34,146 @@ pub const OPTIONS: &[FormatOption] = &[
default: "false",
description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers",
},
+ SEPARATE_ARTBOARDS,
+ SEPARATE_RANGE,
+ INCLUDE_LINKED,
+ EMBED_PROFILES,
+ FormatOption {
+ name: "pdfCompatible",
+ ty: "boolean",
+ default: "false",
+ description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)",
+ },
+];
+
+const SEPARATE_ARTBOARDS: FormatOption = FormatOption {
+ name: "separateArtboards",
+ ty: "boolean",
+ default: "false",
+ description: "also save each artboard (of range) to a file of its own beside this one, -., holding that artboard and the art on it",
+};
+const SEPARATE_RANGE: FormatOption = FormatOption {
+ name: "range",
+ ty: "string",
+ default: "null",
+ description: "with separateArtboards: the artboards saved separately, 1-based such as \"1-3, 5\", or \"all\" (default)",
+};
+const INCLUDE_LINKED: FormatOption = FormatOption {
+ name: "includeLinked",
+ ty: "boolean",
+ default: "false",
+ description: "keep the linked files' own pixels in the file (they stay linked), not just a preview: the document shows them in full where the files can't be found",
+};
+const EMBED_PROFILES: FormatOption = FormatOption {
+ name: "embedProfiles",
+ ty: "boolean",
+ default: "true",
+ description: "carry the ICC profiles the document is tagged with that were loaded from files (Edit → Assign Profile), so the document keeps them where they aren't installed",
+};
+
+/// The save options of a `.ai` file (a PDF carrying the native document; its PDF options come on
+/// top, see document.exportPdf).
+pub const AI_OPTIONS: &[FormatOption] = &[
+ SEPARATE_ARTBOARDS,
+ SEPARATE_RANGE,
+ INCLUDE_LINKED,
+ EMBED_PROFILES,
+ FormatOption {
+ name: "pdfCompatible",
+ ty: "boolean",
+ default: "true",
+ description: "write the artwork as PDF pages that other apps show; false keeps only the native document (smaller, faster; other apps show empty pages)",
+ },
+ FormatOption {
+ name: "compress",
+ ty: "boolean",
+ default: "true",
+ description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)",
+ },
];
#[derive(Default, Deserialize)]
-#[serde(default)]
+#[serde(default, rename_all = "camelCase")]
struct NativeOptions {
compress: Option,
version: Option,
preview: bool,
+ include_linked: bool,
+ embed_profiles: Option,
+ pdf_compatible: bool,
}
-/// The options in `p` for saving `doc`, checked (the preview is left to [`encode`]) → them and
-/// whether to embed a preview.
-pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, bool)> {
+/// What [`save_options`] leaves to [`encode`] (rendering and encoding take time).
+pub(super) struct Extras {
+ preview: bool,
+ pdf: bool,
+}
+
+/// The options in `p` for saving `doc`, checked, with the profiles to embed (the preview and PDF
+/// are left to [`encode`]).
+pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, Extras)> {
let o: NativeOptions = super::encode::options(f, p)?;
let mut so = SaveOptions::for_doc(doc);
so.compress = o.compress.unwrap_or(false);
so.version = o.version.unwrap_or(vectorcraft_format::VERSION);
+ so.include_linked = o.include_linked;
+ if o.embed_profiles.unwrap_or(true) {
+ so.profiles = embedded_profiles(doc);
+ }
so.check().map_err(|e| bad(cmd, e.to_string()))?;
- Ok((so, o.preview))
+ Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible }))
}
/// The `.vectorcraft` file of `doc` with the options in `p`.
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> {
- let (mut so, preview) = save_options(cmd, f, doc, p)?;
- if preview {
+ let (mut so, extras) = save_options(cmd, f, doc, p)?;
+ if extras.preview {
so.preview = preview_png(doc)?;
}
+ if extras.pdf {
+ // Every artboard as PDF pages (no editing data: the file is the native document).
+ so.pdf = Some(super::pdf::encode(cmd, doc, &json!({ "range": "all", "preserveEditing": false }))?.0);
+ }
+ vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
+}
+
+/// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in
+/// ones are there wherever VectorCraft runs).
+fn embedded_profiles(doc: &Document) -> BTreeMap> {
+ [&doc.color_profiles.rgb, &doc.color_profiles.cmyk]
+ .into_iter()
+ .flatten()
+ .filter_map(|name| cms::profile(name).filter(|p| !p.builtin))
+ .filter_map(|p| Some((p.name.clone(), cms::icc_bytes(&p.name).ok()?.to_vec())))
+ .collect()
+}
+
+/// Most profiles read from one file.
+const MAX_PROFILES: usize = 8;
+
+/// Make the profiles a file carries available (those not installed here), named as in the file →
+/// a warning per profile that can't be used.
+pub(super) fn install_profiles(profiles: BTreeMap>) -> Vec {
+ // A document is tagged with an RGB and a CMYK profile at most: more is junk.
+ profiles
+ .into_iter()
+ .take(MAX_PROFILES)
+ .filter(|(name, _)| cms::profile(name).is_none())
+ .filter_map(|(name, icc)| {
+ cms::register_icc(&icc, Some(name.clone())).err().map(|e| format!("the colour profile “{name}” the file carries can't be used: {e}"))
+ })
+ .collect()
+}
+
+/// The native document a `.ai` file carries, with the options in `p` that apply to it
+/// (`includeLinked`, `embedProfiles`; compact and uncompressed: the PDF compresses it).
+pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> {
+ let mut q = p.clone();
+ if let Some(o) = q.as_object_mut() {
+ o.retain(|k, _| !matches!(k.as_str(), "compress" | "version" | "preview" | "pdfCompatible"));
+ }
+ let (mut so, _) = save_options(cmd, f, doc, &q)?;
+ so.pretty = false;
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
}
diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs
index 49abd21c5..6aaf87390 100644
--- a/crates/engine/src/cmd/fileio/pdf.rs
+++ b/crates/engine/src/cmd/fileio/pdf.rs
@@ -197,11 +197,17 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
/// when the PDF has every artboard (a PDF of some reopens as just those).
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> {
+ encode_carrying(cmd, doc, p, || Ok(vectorcraft_format::save(doc, false)))
+}
+
+/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
+/// native document with its save options, whose pages may be left blank).
+pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result>) -> Result<(Vec, Vec)> {
let mut opts = options(cmd, doc, p)?;
let mut warnings = vec![];
if opts.settings.preserve_editing {
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
- opts.native = Some(vectorcraft_format::save(doc, false));
+ opts.native = Some(native()?);
} else {
opts.settings.preserve_editing = false;
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs
index 007399955..11ff30908 100644
--- a/crates/engine/src/cmd/fileio/save.rs
+++ b/crates/engine/src/cmd/fileio/save.rs
@@ -13,7 +13,8 @@ use vectorcraft_doc::Document;
use super::super::*;
use super::{
- Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, write_file,
+ 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};
@@ -67,7 +68,7 @@ impl SaveMode {
pub struct SavePlan {
pub mode: SaveMode,
/// Where to write. `None` when no path is known (never saved, converted from an older version,
- /// or a format other than the document's own): the caller asks for one or takes the bytes.
+ /// 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).
@@ -178,7 +179,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result {
// 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 && format.id == st.format).then(|| st.path.clone()).flatten());
+ 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 {
@@ -197,10 +199,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result {
/// 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 {
- match is_pdf(f) {
- true => super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key),
- false => f.options.iter().any(|o| o.name == key),
- }
+ 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
@@ -250,21 +249,79 @@ pub struct SaveJob {
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";
+
/// 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 {
- let cmd = plan.mode.command();
- let retargets = plan.retargets();
// The web build, without a clock, leaves the dates unless given one.
if plan.path.is_some()
- && retargets
+ && plan.retargets()
&& let Some(at) = clock_date(s, "modified", plan.modified)
{
stamp_save_dates(s.doc_mut()?, at);
}
- let st = s.doc()?;
+ 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));
@@ -280,12 +337,20 @@ pub fn save_job(s: &mut Session, plan: SavePlan) -> Result {
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) {
// A PDF of every artboard with the PDF options, always carrying the native document.
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
+ // Use Compression compresses the PDF's content.
+ 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));
+ }
+ }
}
- Ok(SaveJob { plan, doc, params, snapshot: s.doc()?.doc.clone() })
+ Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
}
impl SaveJob {
@@ -293,23 +358,54 @@ impl SaveJob {
&self.plan
}
- /// Encode the file and write it with `write` (the file, then the images an SVG links to,
- /// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?:
- /// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`.
- pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result {
- let (cmd, plan, doc) = (self.plan.mode.command(), &self.plan, &*self.doc);
- let mut enc = if plan.format.id == "ai" {
- let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &self.params)?;
+ /// 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)));
+ }
+ let pdf_less = self.plan.format.id == "ai" && !bool_or(&self.params, "pdfCompatible", true);
+ let notes = fidelity_warning(self.plan.format).into_iter().chain(pdf_less.then(|| NOT_PDF_COMPATIBLE.to_string()));
+ enc.warnings.splice(0..0, notes);
+ 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" {
+ // The pages (blank without PDF content), carrying the native document.
+ let pages = doc.without_edit_modes();
+ let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
+ let native = || super::native::ai_native(cmd, f, doc, &self.params);
+ let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?;
Encoded { warnings, ..Encoded::one(bytes) }
} else {
- encode_all(doc, plan.format.id, &self.params)?
+ 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"));
}
- // What the format loses first, then the encoder's own notes (PDF options not applied yet…).
- let mut warnings = std::mem::take(&mut enc.warnings);
- warnings.splice(0..0, fidelity_warning(plan.format));
+ 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)?;
@@ -325,7 +421,11 @@ impl SaveJob {
}
let bytes = files.first().map_or(0, |f| f.1.len());
let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra);
- if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) {
+ 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)
@@ -341,17 +441,31 @@ impl SaveJob {
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();
+ if let Some(st) = s.document_mut(uid) {
+ self.finish(st);
+ }
+ if saved {
+ crate::cmd::recovery::forget(s, uid);
+ }
+ }
}
/// 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.finish(s.doc_mut()?);
+ job.complete(s, uid);
Ok(out)
}
@@ -377,6 +491,9 @@ fn revert(s: &mut Session, _: &Value) -> Result {
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 }))
}
@@ -386,7 +503,9 @@ 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).ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
+ 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!({}));
@@ -409,7 +528,7 @@ pub(super) fn specs() -> Vec {
"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), 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, 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. 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, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
+ "{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)
),
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index b0bdffbc4..a56a048e7 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -47,6 +47,7 @@ pub mod prefscmds;
pub mod print;
pub mod rasterfx;
mod recolor;
+pub mod recovery;
mod select;
pub(crate) mod slices;
mod stroke;
@@ -230,6 +231,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(package::specs());
v.extend(slices::specs());
v.extend(print::specs());
+ v.extend(recovery::specs());
v
})
}
diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs
new file mode 100644
index 000000000..c0a511833
--- /dev/null
+++ b/crates/engine/src/cmd/recovery.rs
@@ -0,0 +1,840 @@
+//! Data Recovery: copies of modified documents kept in a recovery store, so a crash loses at most
+//! the last few minutes of work.
+//!
+//! - `file.recovery.save` writes a copy of every modified document changed since its last copy
+//! (the app runs it every Preferences → File Handling → Data Recovery interval, in the
+//! background like Background Save: [`jobs`] snapshots, [`RecoveryJob::write`] encodes and
+//! stores, [`RecoveryJob::finish`] records the copy).
+//! - A copy is removed when its document is saved, reverted or closed ([`forget`]), so copies
+//! left in the store after the app has quit are what a crash left behind:
+//! `file.recovery.list` lists them, `file.recovery.restore` opens them as
+//! " [Recovered]" (modified, never saved, the original path remembered for Save As) and
+//! `file.recovery.discard` deletes them.
+//!
+//! Every running app (each browser tab) keeps its copies in an area of its own, `/…`, and
+//! holds it while it runs: a lock on the area for its lifetime where the store has locks (folders:
+//! [`FolderStore`]), else a heartbeat it refreshes ([`heartbeat`]; browser storage). Only areas
+//! nobody holds any more (their app is gone) are offered, and while their copies are restored or
+//! discarded the area is held, so two apps launched together never both take it.
+//!
+//! Each copy is two entries: `/.vectorcraft` (the native document, written as a save
+//! writes it) and `/.json` (its title, original path and format, and when it was
+//! written). The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder`
+//! preference, else the folder the app sets), browser storage on the web, or any
+//! [`RecoveryStore`] the host installs (tests use [`MemoryStore`]).
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::{Arc, Mutex};
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use vectorcraft_doc::Document;
+
+use super::fileio::{self, SAVE_FORMATS, SaveJob, SaveMode, SavePlan};
+use super::*;
+use crate::DocState;
+
+/// Documents with more objects than this are complex: with Preferences → Turn off Data Recovery
+/// for complex documents, they get no recovery copy.
+pub const COMPLEX_OBJECTS: usize = 20_000;
+
+/// The extension of a copy's document entry.
+const DOC_EXT: &str = ".vectorcraft";
+/// The extension of a copy's details entry.
+const META_EXT: &str = ".json";
+/// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix
+/// seconds, as text).
+const BEAT: &str = "heartbeat";
+/// The least time without a heartbeat after which an area's app counts as gone (seconds); else
+/// three Data Recovery intervals.
+const MIN_STALE: i64 = 180;
+/// How often a running app refreshes its heartbeat (seconds).
+pub const HEARTBEAT_EVERY: f64 = 60.0;
+
+/// Holds an area while it lives (dropping it releases the lock).
+pub type Hold = Box;
+
+/// What [`RecoveryStore::lock`] got.
+pub enum Lock {
+ /// The area is ours until the hold is dropped.
+ Held(Hold),
+ /// A running app holds it.
+ Busy,
+ /// The store has no locks: areas are held with heartbeats.
+ Unsupported,
+}
+
+/// Where recovery copies are kept: named entries of bytes, `/`. Called from the UI
+/// thread and the background worker.
+pub trait RecoveryStore: Send + Sync {
+ /// The names of the entries (`/`).
+ fn list(&self) -> std::result::Result, String>;
+ fn read(&self, name: &str) -> std::result::Result, String>;
+ /// Create or replace an entry (a failed write must not leave a damaged entry behind).
+ fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String>;
+ /// Delete an entry (one that isn't there is no error).
+ fn remove(&self, name: &str) -> std::result::Result<(), String>;
+ /// Where the entries are, as the user knows it (a folder, "browser storage").
+ fn location(&self) -> String;
+ /// The areas in the store (default: those with entries).
+ fn areas(&self) -> std::result::Result, String> {
+ let names = self.list()?;
+ let set: BTreeSet = names.iter().filter_map(|n| n.split_once('/')).map(|(a, _)| a.to_string()).collect();
+ Ok(set.into_iter().collect())
+ }
+ /// Hold area `area` exclusively, if the store has locks (default: it hasn't).
+ fn lock(&self, _area: &str) -> std::result::Result {
+ Ok(Lock::Unsupported)
+ }
+ /// Tidy away area `area` once it holds no copies and nobody holds it (its lock file and folder).
+ fn remove_area(&self, _area: &str) {}
+ /// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone).
+ fn now(&self) -> Option {
+ vectorcraft_doc::metadata::now_unix()
+ }
+}
+
+/// A store in memory (tests, and hosts without storage). Areas are locked in memory, so sessions
+/// sharing one store behave like apps sharing a folder; [`MemoryStore::without_locks`] uses
+/// heartbeats like browser storage, judged by a clock tests set ([`MemoryStore::set_now`]).
+pub struct MemoryStore {
+ entries: Mutex>>,
+ held: Arc>>,
+ locks: bool,
+ now: Mutex