Merge pull request #95 from storytold/pr/m4-native-format
M4.35–M4.36: native format v3 and background save
This commit is contained in:
commit
171de62fde
|
|
@ -4192,6 +4192,7 @@ dependencies = [
|
|||
name = "vectorcraft-format"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ fn save_prefs(app: &VectorcraftApp) {
|
|||
let mut ui = app.ui.clone();
|
||||
ui.engine_prefs = app.session.prefs.to_json();
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
|
||||
let _ = std::fs::write(p, bytes);
|
||||
// Preferences are best effort: a failed write keeps the previous file.
|
||||
let _ = fileio::write_atomic(&p, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +138,11 @@ fn reveal_command(path: &str) -> std::process::Command {
|
|||
c
|
||||
}
|
||||
|
||||
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
|
|
@ -157,7 +163,9 @@ fn services() -> Services {
|
|||
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
write_shared: Some(std::sync::Arc::new(write_file)),
|
||||
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
|
||||
system_clipboard: Some(clipboard::system_clipboard()),
|
||||
// Help → Discord / website / GitHub, the Discord button, About and Home links.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub mod node;
|
|||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
mod pixels;
|
||||
pub mod profiles;
|
||||
pub mod rastersettings;
|
||||
mod reach;
|
||||
pub mod selection;
|
||||
|
|
@ -55,6 +56,7 @@ pub use node::Scaling;
|
|||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
pub use selection::{AnchorRef, Selection};
|
||||
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
|
|
@ -497,6 +499,14 @@ pub struct Document {
|
|||
/// save time only, so changing the view never marks the document modified.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_view: Option<SavedView>,
|
||||
/// Edit → Assign Profile: the profiles the document is tagged with (files before format v3
|
||||
/// kept them in `unknown`, see [`Document::migrate_color_profiles`]).
|
||||
#[serde(default, skip_serializing_if = "ColorProfiles::is_empty")]
|
||||
pub color_profiles: ColorProfiles,
|
||||
/// Top-level keys this version doesn't know (written by a newer one), kept so saving doesn't
|
||||
/// lose them. Separate from [`Document::unknown`], which holds foreign data by design.
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn ppi72() -> f64 {
|
||||
|
|
@ -557,6 +567,8 @@ impl Document {
|
|||
metadata: DocMetadata::default(),
|
||||
raster_effects: RasterEffectsSettings::default(),
|
||||
last_view: None,
|
||||
color_profiles: ColorProfiles::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
//! The colour profiles a document is tagged with (Edit → Assign Profile).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// `Document::unknown` key under which files before format v3 kept the assigned profiles
|
||||
/// (`{rgb?, cmyk?}`); still written when saving for those versions.
|
||||
pub const LEGACY_PROFILES_KEY: &str = "colorProfiles";
|
||||
|
||||
/// The profiles a document is tagged with, by name (`None`: the working space).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ColorProfiles {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rgb: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cmyk: Option<String>,
|
||||
}
|
||||
|
||||
impl ColorProfiles {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rgb.is_none() && self.cmyk.is_none()
|
||||
}
|
||||
|
||||
/// The legacy JSON form (`{"rgb": name|null, "cmyk": name|null}`).
|
||||
pub fn to_legacy(&self) -> serde_json::Value {
|
||||
serde_json::json!({ "rgb": self.rgb, "cmyk": self.cmyk })
|
||||
}
|
||||
|
||||
/// Read the legacy JSON form (anything that isn't a name counts as none).
|
||||
pub fn from_legacy(v: &serde_json::Value) -> Self {
|
||||
let name = |k: &str| v.get(k).and_then(serde_json::Value::as_str).map(str::to_string);
|
||||
Self { rgb: name("rgb"), cmyk: name("cmyk") }
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Files before format v3 kept the assigned profiles under [`LEGACY_PROFILES_KEY`] in
|
||||
/// `unknown`: move them to [`Document::color_profiles`].
|
||||
pub fn migrate_color_profiles(&mut self) {
|
||||
let Some(v) = self.unknown.remove(LEGACY_PROFILES_KEY) else { return };
|
||||
if self.color_profiles.is_empty() {
|
||||
self.color_profiles = ColorProfiles::from_legacy(&v);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reverse of [`Document::migrate_color_profiles`], for saving in a version before v3.
|
||||
pub fn store_legacy_color_profiles(&mut self) {
|
||||
let profiles = std::mem::take(&mut self.color_profiles);
|
||||
if !profiles.is_empty() {
|
||||
self.unknown.insert(LEGACY_PROFILES_KEY.into(), profiles.to_legacy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_profiles_migrate_both_ways() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.unknown.insert(LEGACY_PROFILES_KEY.into(), serde_json::json!({"rgb": "Wide RGB", "cmyk": null}));
|
||||
d.migrate_color_profiles();
|
||||
assert_eq!(d.color_profiles, ColorProfiles { rgb: Some("Wide RGB".into()), cmyk: None });
|
||||
assert!(!d.unknown.contains_key(LEGACY_PROFILES_KEY));
|
||||
d.store_legacy_color_profiles();
|
||||
assert!(d.color_profiles.is_empty());
|
||||
assert_eq!(d.unknown[LEGACY_PROFILES_KEY], serde_json::json!({"rgb": "Wide RGB", "cmyk": null}));
|
||||
// Nothing assigned: nothing stored.
|
||||
let mut e = Document::new(10.0, 10.0);
|
||||
e.store_legacy_color_profiles();
|
||||
assert!(e.unknown.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@
|
|||
//!
|
||||
//! Colour settings and the proof view are process-wide (`vectorcraft_color::cms::active`,
|
||||
//! `vectorcraft_render::proof::view`). A document's assigned profiles are stored in
|
||||
//! `Document.unknown["colorProfiles"]` (`{rgb?, cmyk?}`) and become active when assigned.
|
||||
//! `Document::color_profiles` (files before format v3: `Document.unknown["colorProfiles"]`) and
|
||||
//! become active when assigned.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
|
|
@ -16,8 +17,9 @@ use vectorcraft_render::proof;
|
|||
|
||||
use super::*;
|
||||
|
||||
/// `Document.unknown` key for the assigned profiles.
|
||||
pub const PROFILES_KEY: &str = "colorProfiles";
|
||||
/// `Document.unknown` key under which files before format v3 kept the assigned profiles (migrated
|
||||
/// to `Document::color_profiles` on load).
|
||||
pub const PROFILES_KEY: &str = vectorcraft_doc::profiles::LEGACY_PROFILES_KEY;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
|
@ -188,9 +190,8 @@ fn load_profile(_: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
/// The document's assigned profiles (`None` = working space).
|
||||
pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option<String>, Option<String>) {
|
||||
let o = d.unknown.get(PROFILES_KEY);
|
||||
let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(|n| cms::canonical_name(n).to_string());
|
||||
(get("rgb"), get("cmyk"))
|
||||
let get = |n: &Option<String>| n.as_deref().map(|n| cms::canonical_name(n).to_string());
|
||||
(get(&d.color_profiles.rgb), get(&d.color_profiles.cmyk))
|
||||
}
|
||||
|
||||
fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -213,13 +214,9 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
let changed = pick("rgb", &mut rgb, cms::ProfileKind::Rgb)? | pick("cmyk", &mut cmyk, cms::ProfileKind::Cmyk)?;
|
||||
if changed {
|
||||
let (r, c) = (rgb.clone(), cmyk.clone());
|
||||
let profiles = vectorcraft_doc::ColorProfiles { rgb: rgb.clone(), cmyk: cmyk.clone() };
|
||||
s.edit("Assign Profile", |d, _| {
|
||||
if r.is_none() && c.is_none() {
|
||||
d.unknown.remove(PROFILES_KEY);
|
||||
} else {
|
||||
d.unknown.insert(PROFILES_KEY.into(), json!({"rgb": r, "cmyk": c}));
|
||||
}
|
||||
d.color_profiles = profiles;
|
||||
Ok(())
|
||||
})?;
|
||||
let mut st = cms::active_settings();
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
|
|||
r.map_err(|e| bad(C, e))
|
||||
}
|
||||
|
||||
fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||||
pub(super) fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||||
if !p.is_object() {
|
||||
return Ok(T::default());
|
||||
}
|
||||
|
|
@ -303,12 +303,12 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
return encode_all(&single_artboard(doc, bounds, "Art"), f.id, &q);
|
||||
}
|
||||
let bytes = match f.id {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
"vectorcraft" => super::native::encode(C, f, doc, p)?,
|
||||
// A native file flagged so opening it starts a new untitled document.
|
||||
"template" => {
|
||||
let mut d = doc.clone();
|
||||
d.template = true;
|
||||
vectorcraft_format::save_file(&d)
|
||||
super::native::encode(C, f, &d, p)?
|
||||
}
|
||||
"txt" => super::text::encode(doc, p)?,
|
||||
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ mod encode;
|
|||
mod export;
|
||||
mod imagemap;
|
||||
mod load;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
mod pdfimport;
|
||||
pub mod pngtext;
|
||||
|
|
@ -38,9 +39,16 @@ use load::err;
|
|||
pub(crate) use load::import_svg;
|
||||
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 use save::{SAVE_FORMATS, SaveMode, SavePlan, save_filters, save_format, save_plan, save_with, stamp_save_dates, templates_folder};
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, templates_folder,
|
||||
};
|
||||
pub use svg::options_map as svg_options;
|
||||
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
|
||||
/// file it replaces), for the apps' own writers too.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use vectorcraft_format::write_atomic;
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
|
@ -320,7 +328,7 @@ pub const FORMATS: &[Format] = &[
|
|||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[],
|
||||
options: native::OPTIONS,
|
||||
},
|
||||
Format { id: "svg", label: "SVG", extensions: &["svg"], mime: "image/svg+xml", read: true, write: true, raster: false, options: svg::OPTIONS },
|
||||
Format {
|
||||
|
|
@ -392,7 +400,7 @@ pub const FORMATS: &[Format] = &[
|
|||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[],
|
||||
options: native::OPTIONS,
|
||||
},
|
||||
Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS },
|
||||
Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS },
|
||||
|
|
@ -488,7 +496,8 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
|||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
// Never a half-written file: see [`write_atomic`].
|
||||
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
//! The native format's options: compression, the version to write (older apps read older
|
||||
//! versions) and an embedded preview.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_format::{PREVIEW_MAX, SaveOptions};
|
||||
|
||||
use super::super::*;
|
||||
use super::{Format, FormatOption};
|
||||
|
||||
/// The options `document.save` and the native encoder read.
|
||||
pub const OPTIONS: &[FormatOption] = &[
|
||||
FormatOption {
|
||||
name: "compress",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "gzip the file: smaller, not readable as text (saves default to Preferences → File Handling → Use Compression)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "version",
|
||||
ty: "integer",
|
||||
default: "3",
|
||||
description: "the format version to write: 3 (current), or 2 or 1 for older VectorCraft versions (not compressed; newer features they don't know are lost there)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "preview",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct NativeOptions {
|
||||
compress: Option<bool>,
|
||||
version: Option<u32>,
|
||||
preview: 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)> {
|
||||
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.check().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok((so, o.preview))
|
||||
}
|
||||
|
||||
/// The `.vectorcraft` file of `doc` with the options in `p`.
|
||||
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
|
||||
let (mut so, preview) = save_options(cmd, f, doc, p)?;
|
||||
if preview {
|
||||
so.preview = preview_png(doc)?;
|
||||
}
|
||||
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
|
||||
/// to show).
|
||||
fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
||||
let Some(r) = doc.artboards.first().map(|a| a.rect).or_else(|| vectorcraft_render::encode::art_bounds(doc)) else { return Ok(None) };
|
||||
let scale = f64::from(PREVIEW_MAX) / r.width().max(r.height());
|
||||
if vectorcraft_render::raster_size(r, scale).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
vectorcraft_render::Renderer::new().render_region(doc, r, scale, false).to_png().map(Some).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
/// `p` with `compress` from the Use Compression preference when it doesn't say (saves only;
|
||||
/// exports and serializing write what they're told).
|
||||
pub fn with_compression_pref(prefs: &crate::Prefs, p: &Value) -> Value {
|
||||
let mut q = if p.is_object() { p.clone() } else { Value::Object(Default::default()) };
|
||||
if let Some(o) = q.as_object_mut()
|
||||
&& o.get("compress").is_none_or(Value::is_null)
|
||||
// Older versions can't read compressed files: those saves stay plain.
|
||||
&& o.get("version").and_then(Value::as_u64).is_none_or(|v| v >= u64::from(vectorcraft_format::COMPRESSED_SINCE))
|
||||
{
|
||||
o.insert("compress".into(), Value::Bool(prefs.use_compression));
|
||||
}
|
||||
q
|
||||
}
|
||||
|
|
@ -6,13 +6,15 @@
|
|||
//! 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::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
use super::super::*;
|
||||
use super::{Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, write_encoded, write_file};
|
||||
use super::{
|
||||
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:
|
||||
|
|
@ -152,8 +154,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
|||
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 if is_pdf(format) {
|
||||
// So do PDF options (as the Save PDF dialog and document.exportPdf name them).
|
||||
} 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())),
|
||||
|
|
@ -203,13 +206,13 @@ fn is_svg(f: &Format) -> bool {
|
|||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at.
|
||||
fn doc_to_save<'a>(st: &'a DocState, f: &Format) -> Cow<'a, Document> {
|
||||
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();
|
||||
Cow::Owned(d)
|
||||
Arc::new(d)
|
||||
} else {
|
||||
Cow::Borrowed(&st.doc)
|
||||
st.doc.clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,12 +226,23 @@ fn fidelity_warning(f: &Format) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Encode `plan` 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}]}` and the document is unchanged; with one → `{path, format, bytes, warnings,
|
||||
/// linked?: [path…]}`, and Save or Save As (except to a template) make the file the document's
|
||||
/// own: path, title, format, options, saved state.
|
||||
pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let cmd = plan.mode.command();
|
||||
let retargets = plan.retargets();
|
||||
// The web build, without a clock, leaves the dates unless given one.
|
||||
|
|
@ -238,60 +252,94 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[
|
|||
{
|
||||
stamp_save_dates(s.doc_mut()?, at);
|
||||
}
|
||||
let own = doc_to_save(s.doc()?, plan.format);
|
||||
let st = s.doc()?;
|
||||
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: &Document = relative.as_ref().unwrap_or(&own);
|
||||
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 params = Value::Object(params);
|
||||
let params = super::pdf::expand_preset(s, cmd, ¶ms)?;
|
||||
let mut enc = if plan.format.id == "ai" {
|
||||
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 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.
|
||||
let mut q = params.into_owned();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
}
|
||||
Ok(SaveJob { plan, doc, params, snapshot: s.doc()?.doc.clone() })
|
||||
}
|
||||
|
||||
impl SaveJob {
|
||||
pub fn plan(&self) -> &SavePlan {
|
||||
&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<Value> {
|
||||
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)?;
|
||||
Encoded { warnings, ..Encoded::one(bytes) }
|
||||
} else {
|
||||
encode_all(doc, plan.format.id, &self.params)?
|
||||
};
|
||||
if enc.files.len() != 1 {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &q)?;
|
||||
Encoded { warnings, ..Encoded::one(bytes) }
|
||||
} else {
|
||||
encode_all(doc, plan.format.id, ¶ms)?
|
||||
};
|
||||
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));
|
||||
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);
|
||||
// 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));
|
||||
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)?;
|
||||
}
|
||||
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);
|
||||
if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) {
|
||||
out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
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()) {
|
||||
out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect();
|
||||
}
|
||||
if retargets {
|
||||
let st = s.doc_mut()?;
|
||||
st.path = Some(path.clone());
|
||||
st.format = plan.format.id;
|
||||
st.save_options = plan.options;
|
||||
st.converted = false;
|
||||
st.mark_saved();
|
||||
|
||||
/// 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.mark_saved_as(&self.snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 out = job.write(write)?;
|
||||
job.finish(s.doc_mut()?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -328,10 +376,13 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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 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() {
|
||||
o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(&o["default"]).clone();
|
||||
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"] =
|
||||
|
|
@ -346,7 +397,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
|||
"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), 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), 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",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
|
@ -373,7 +424,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
|||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?} 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}",
|
||||
"{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)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -318,6 +318,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
|||
// File Handling (continued)
|
||||
p!("templatesFolder", "File Handling", "Files", "Templates Folder", text),
|
||||
p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool),
|
||||
// Native saves (`document.save {compress}`)
|
||||
p!("useCompression", "File Handling", "Files", "Use Compression", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,11 @@ impl DocState {
|
|||
pub fn mark_saved(&mut self) {
|
||||
self.saved_doc = self.doc.clone();
|
||||
}
|
||||
/// Record `snapshot` (the document as a background save took it) as saved: edits made since
|
||||
/// keep the document modified.
|
||||
pub fn mark_saved_as(&mut self, snapshot: &Arc<Document>) {
|
||||
self.saved_doc = snapshot.clone();
|
||||
}
|
||||
pub fn title(&self) -> String {
|
||||
let name = self
|
||||
.path
|
||||
|
|
@ -394,6 +399,9 @@ pub struct Prefs {
|
|||
pub templates_folder: String,
|
||||
/// Open older native files as "<name> [Converted]" so Save asks for a new name.
|
||||
pub append_converted: bool,
|
||||
/// File Handling → Use Compression: native saves are gzip-compressed (`document.save
|
||||
/// {compress}` overrides it).
|
||||
pub use_compression: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -522,6 +530,7 @@ impl Default for Prefs {
|
|||
pdf_presets: vec![],
|
||||
templates_folder: String::new(),
|
||||
append_converted: true,
|
||||
use_compression: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -621,6 +630,10 @@ impl Session {
|
|||
pub fn documents(&self) -> &[DocState] {
|
||||
&self.docs
|
||||
}
|
||||
/// The open document with [`DocState::uid`] `uid` (it may have closed since it was looked up).
|
||||
pub fn document_mut(&mut self, uid: u64) -> Option<&mut DocState> {
|
||||
self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
}
|
||||
pub fn active_index(&self) -> Option<usize> {
|
||||
self.active
|
||||
}
|
||||
|
|
@ -1053,6 +1066,8 @@ mod tests_maskview;
|
|||
#[cfg(test)]
|
||||
mod tests_menucmds;
|
||||
#[cfg(test)]
|
||||
mod tests_nativefile;
|
||||
#[cfg(test)]
|
||||
mod tests_newart;
|
||||
#[cfg(test)]
|
||||
mod tests_newdoc;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
//! Native files: `document.save {compress, version, preview}`, the Use Compression preference,
|
||||
//! atomic writes, unused images left out, keys from newer versions and colour profiles kept.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_format::{is_compressed, sniff};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn tmp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-native-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn bytes_of(r: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(r["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
/// The file `document.save` (no path) gives back with `p`.
|
||||
fn saved(s: &mut Session, p: Value) -> Vec<u8> {
|
||||
bytes_of(&s.execute("document.save", &p).unwrap())
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap()
|
||||
}
|
||||
|
||||
/// The file's JSON (unpacked when compressed).
|
||||
fn json_of(bytes: &[u8]) -> Value {
|
||||
let mut text = Vec::new();
|
||||
if is_compressed(bytes) {
|
||||
std::io::Read::read_to_end(&mut flate2::read::GzDecoder::new(bytes), &mut text).unwrap();
|
||||
} else {
|
||||
text = bytes.to_vec();
|
||||
}
|
||||
serde_json::from_slice(&text).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_saves_open_by_their_content() {
|
||||
let mut s = session();
|
||||
let packed = saved(&mut s, json!({"compress": true}));
|
||||
assert!(is_compressed(&packed) && sniff(&packed));
|
||||
let plain = saved(&mut s, json!({}));
|
||||
assert!(!is_compressed(&plain) && packed.len() < plain.len());
|
||||
// Recognised by its content, whatever the name says.
|
||||
for name in ["a.vectorcraft", "a.drawcraft", "a.svgz", "a.bin"] {
|
||||
let r = open(&mut s, name, &packed);
|
||||
assert_eq!(r["format"], "vectorcraft", "{name}");
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_compression_preference_sets_the_default() {
|
||||
let mut s = session();
|
||||
s.execute("prefs.set", &json!({"key": "useCompression", "value": true})).unwrap();
|
||||
assert!(is_compressed(&saved(&mut s, json!({}))));
|
||||
assert!(!is_compressed(&saved(&mut s, json!({"compress": false}))), "the param wins");
|
||||
// Older versions can't read compressed files: those saves stay plain.
|
||||
assert!(!is_compressed(&saved(&mut s, json!({"version": 2}))));
|
||||
assert!(s.execute("document.save", &json!({"version": 2, "compress": true})).is_err());
|
||||
assert!(is_compressed(&bytes_of(&s.execute("file.saveAsTemplate", &json!({})).unwrap())));
|
||||
// Exports write what they're told.
|
||||
let r = s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap();
|
||||
assert!(!is_compressed(&bytes_of(&r)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_for_older_versions() {
|
||||
let mut s = session();
|
||||
for v in [1, 2] {
|
||||
let bytes = saved(&mut s, json!({"version": v}));
|
||||
let j = json_of(&bytes);
|
||||
assert_eq!((j["format"].as_str(), j["version"].as_u64()), (Some("drawcraft"), Some(v)));
|
||||
open(&mut s, "old.drawcraft", &bytes);
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), 2);
|
||||
}
|
||||
for bad in [json!({"version": 0}), json!({"version": 9}), json!({"version": "two"})] {
|
||||
assert!(s.execute("document.save", &bad).is_err(), "{bad}");
|
||||
}
|
||||
let formats = s.execute("document.formats", &json!({})).unwrap();
|
||||
let native = formats["formats"].as_array().unwrap().iter().find(|f| f["id"] == "vectorcraft").unwrap();
|
||||
for o in ["compress", "version", "preview"] {
|
||||
assert!(native["options"].get(o).is_some(), "{o} documented");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_is_at_most_256_pixels() {
|
||||
let mut s = session();
|
||||
let bytes = saved(&mut s, json!({"preview": true, "compress": true}));
|
||||
let png = vectorcraft_format::preview(&bytes).expect("a preview");
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (256, 128), "the 400×200 artboard fitted into 256 px");
|
||||
assert!(vectorcraft_format::preview(&saved(&mut s, json!({}))).is_none(), "off by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_writes_through_a_temporary_file() {
|
||||
let d = tmp_dir("atomic");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"an older version of the file").unwrap();
|
||||
let mut s = session();
|
||||
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(sniff(&std::fs::read(&path).unwrap()));
|
||||
let names: Vec<String> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
assert_eq!(names, ["doc.vectorcraft"], "no temporary file left behind");
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
// A save that can't be written fails, and the document stays modified.
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
let missing = d.join("missing").join("doc.vectorcraft");
|
||||
assert!(s.execute("document.save", &json!({"path": missing.to_string_lossy()})).is_err());
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_images_are_left_out_of_saves() {
|
||||
let mut s = session();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let id = r["ids"][0].as_u64().unwrap();
|
||||
assert_eq!(json_of(&saved(&mut s, json!({})))["images"].as_object().unwrap().len(), 1);
|
||||
s.execute("edit.clear", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.images.len(), 1, "kept in memory (undo can bring the image back)");
|
||||
assert!(json_of(&saved(&mut s, json!({})))["images"].as_object().unwrap().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let bytes = saved(&mut s, json!({}));
|
||||
open(&mut s, "back.vectorcraft", &bytes);
|
||||
assert_eq!(s.doc().unwrap().doc.images.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_keys_and_legacy_profiles_survive_open_and_save() {
|
||||
let mut s = session();
|
||||
let mut j = json_of(&saved(&mut s, json!({"version": 2})));
|
||||
j["document"]["fromTheFuture"] = json!({"x": 1});
|
||||
j["document"]["unknown"] = json!({"colorProfiles": {"rgb": "sRGB IEC61966-2.1", "cmyk": null}});
|
||||
open(&mut s, "old.vectorcraft", &serde_json::to_vec(&j).unwrap());
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.color_profiles.rgb.as_deref(), Some("sRGB IEC61966-2.1"), "profiles migrate");
|
||||
assert!(d.unknown.is_empty());
|
||||
assert_eq!(cmd::colormgmt::doc_profiles(d).0.as_deref(), Some("sRGB IEC61966-2.1"));
|
||||
let again = json_of(&saved(&mut s, json!({})));
|
||||
assert_eq!(again["document"]["fromTheFuture"], json!({"x": 1}));
|
||||
assert_eq!(again["document"]["color_profiles"]["rgb"], "sRGB IEC61966-2.1");
|
||||
// Saved for v2 again: back where v2 apps read them.
|
||||
let v2 = json_of(&saved(&mut s, json!({"version": 2})));
|
||||
assert_eq!(v2["document"]["unknown"]["colorProfiles"]["rgb"], "sRGB IEC61966-2.1");
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG and PDF input,
|
||||
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset
|
||||
//! (`.vcflattener`) libraries, must load as an error or as a document that then renders and
|
||||
//! exports, without a panic; nor may bitmaps, PDF and text pasted from other apps.
|
||||
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
|
||||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
//! bitmaps, PDF and text pasted from other apps.
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
|
|
@ -691,3 +692,57 @@ proptest! {
|
|||
paste_from_elsewhere("clipboard.importText", json!({ "text": text }))?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- native files: compressed, and saved for older versions ----------
|
||||
|
||||
/// The rich document saved compressed, as v1 and as v2 (pretty).
|
||||
fn native_samples() -> &'static [Vec<u8>] {
|
||||
use vectorcraft_format::{SaveOptions, save_with};
|
||||
static SAMPLES: std::sync::OnceLock<Vec<Vec<u8>>> = std::sync::OnceLock::new();
|
||||
SAMPLES.get_or_init(|| {
|
||||
let d = rich_doc();
|
||||
[
|
||||
SaveOptions { compress: true, ..SaveOptions::default() },
|
||||
SaveOptions { version: 1, ..SaveOptions::default() },
|
||||
SaveOptions { version: 2, pretty: true, ..SaveOptions::default() },
|
||||
]
|
||||
.iter()
|
||||
.map(|o| save_with(&d, o).unwrap())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
gz.write_all(bytes).unwrap();
|
||||
gz.finish().unwrap()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Damaged native files of every kind: flipped bytes and cuts in the compressed stream, in v1
|
||||
/// and v2 text, and in the text inside an intact compressed stream.
|
||||
#[test]
|
||||
fn native_mutated_files_never_panic(which in 0usize..4, cut in 0usize..80_000, edits in prop::collection::vec((0usize..80_000, any::<u8>()), 0..10)) {
|
||||
let samples = native_samples();
|
||||
let mut bytes = samples.get(which).cloned().unwrap_or_else(|| {
|
||||
let mut f = vectorcraft_format::save(&rich_doc(), false);
|
||||
f.truncate(cut.max(16));
|
||||
f
|
||||
});
|
||||
for &(at, b) in &edits {
|
||||
let n = bytes.len();
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
if which < samples.len() {
|
||||
bytes.truncate(cut.max(2));
|
||||
} else {
|
||||
bytes = gzip(&bytes);
|
||||
}
|
||||
let _ = vectorcraft_format::sniff(&bytes);
|
||||
let _ = vectorcraft_format::preview(&bytes);
|
||||
survive("mutated native file", || vectorcraft_engine::cmd::fileio::load("x.vectorcraft", &bytes).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ vectorcraft-doc = { workspace = true }
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
//! Safe saving: a file is written to a temporary file beside it, then renamed over it, so a failed
|
||||
//! or interrupted write never leaves a damaged file behind (the old one stays as it was).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Write `bytes` to `path` atomically (see the module docs).
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, |f| f.write_all(bytes))
|
||||
}
|
||||
|
||||
/// Write `path` atomically with `fill`, which writes the content into the temporary file. When
|
||||
/// anything fails (`fill`, flushing, the rename) the temporary file is removed and `path` is left
|
||||
/// untouched.
|
||||
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let target = resolve_link(path)?;
|
||||
let tmp = temp_path(&target)?;
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
let filled = fill(&mut f).and_then(|()| f.sync_all());
|
||||
drop(f);
|
||||
filled.and_then(|()| keep_permissions(&target, &tmp)).and_then(|()| std::fs::rename(&tmp, &target)).inspect_err(|_| {
|
||||
// Best effort: the temporary file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
}
|
||||
|
||||
/// A symbolic link keeps pointing at its file: write the file it names.
|
||||
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path),
|
||||
_ => Ok(path.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh hidden name beside `target` (the same folder, so the rename stays on one volume).
|
||||
fn temp_path(target: &Path) -> io::Result<PathBuf> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", target.display())))?;
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(target.with_file_name(format!(".{}.{}-{n}.tmp", name.to_string_lossy(), std::process::id())))
|
||||
}
|
||||
|
||||
/// The replacement keeps the replaced file's permissions (Unix; Windows files have none to keep
|
||||
/// beyond read-only, which makes the save fail as writing in place would).
|
||||
#[cfg(unix)]
|
||||
fn keep_permissions(target: &Path, tmp: &Path) -> io::Result<()> {
|
||||
match std::fs::metadata(target) {
|
||||
Ok(m) => std::fs::set_permissions(tmp, m.permissions()),
|
||||
// A new file: the default permissions.
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The other files in `d` besides `keep`.
|
||||
fn leftovers(d: &Path, keep: &str) -> Vec<String> {
|
||||
std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).filter(|n| n != keep).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_original_file_survives_a_failed_write() {
|
||||
let d = dir("fail");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let r = write_atomic_with(&path, |f| {
|
||||
f.write_all(b"half a fi")?;
|
||||
Err(io::Error::other("disk full"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
assert!(leftovers(&d, "doc.vectorcraft").is_empty(), "the temporary file is removed");
|
||||
write_atomic(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert!(leftovers(&d, "doc.vectorcraft").is_empty());
|
||||
// A folder that doesn't exist: an error, nothing written.
|
||||
assert!(write_atomic(&d.join("missing").join("x.vectorcraft"), b"x").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
//! Writing for older readers: format v1's anchor form.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Rewrite every anchor in a serialized document (`anchors` arrays of path data) the way format v1
|
||||
/// wrote it: `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`, every member present (v1 readers need
|
||||
/// them all; v2 writes `{p: [x, y], in?, out?, kind?}`).
|
||||
pub(crate) fn anchors_to_v1(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o.iter_mut() {
|
||||
match x {
|
||||
Value::Array(anchors) if k == "anchors" => anchors.iter_mut().for_each(anchor_to_v1),
|
||||
_ => anchors_to_v1(x),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(anchors_to_v1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_to_v1(a: &mut Value) {
|
||||
let point = |v: &Value| match v.as_array().map(Vec::as_slice) {
|
||||
Some([x, y]) => Some(json!({ "x": x, "y": y })),
|
||||
_ => None,
|
||||
};
|
||||
let Some(o) = a.as_object() else { return };
|
||||
// Not a v2 anchor (already a map, or something else called `anchors`): leave it alone.
|
||||
let Some(p) = o.get("p").and_then(point) else { return };
|
||||
let handle = |k: &str| o.get(k).and_then(point).unwrap_or_else(|| p.clone());
|
||||
let kind = o.get("kind").cloned().unwrap_or_else(|| json!("Corner"));
|
||||
*a = json!({ "p": p, "in": handle("in"), "out": handle("out"), "kind": kind });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn anchors_are_written_the_v1_way() {
|
||||
let mut v = json!({"layers": [{"path": {"subpaths": [{"anchors": [{"p": [1.0, 2.0]}, {"p": [3.0, 4.0], "out": [5.0, 4.0], "kind": "Smooth"}], "closed": true}]}}], "effect": {"anchors": true}});
|
||||
anchors_to_v1(&mut v);
|
||||
let a = &v["layers"][0]["path"]["subpaths"][0]["anchors"];
|
||||
assert_eq!(a[0], json!({"p": {"x": 1.0, "y": 2.0}, "in": {"x": 1.0, "y": 2.0}, "out": {"x": 1.0, "y": 2.0}, "kind": "Corner"}));
|
||||
assert_eq!(a[1], json!({"p": {"x": 3.0, "y": 4.0}, "in": {"x": 3.0, "y": 4.0}, "out": {"x": 5.0, "y": 4.0}, "kind": "Smooth"}));
|
||||
assert_eq!(v["effect"]["anchors"], json!(true));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,55 @@
|
|||
//! The native `.vectorcraft` format.
|
||||
//!
|
||||
//! A `.vectorcraft` file is UTF-8 JSON:
|
||||
//! A `.vectorcraft` file is UTF-8 JSON, gzip-compressed when saved with Use Compression (told apart
|
||||
//! by its magic bytes on open, whatever the version):
|
||||
//! ```json
|
||||
//! { "format": "vectorcraft", "version": 1, "generator": "VectorCraft 0.1.0",
|
||||
//! { "format": "vectorcraft", "version": 3, "generator": "VectorCraft 0.1.0",
|
||||
//! "preview": { "mime": "image/png", "data": "<base64>" },
|
||||
//! "document": { …vectorcraft_doc::Document… },
|
||||
//! "images": { "<key>": { "mime": "image/png", "data": "<base64>" } } }
|
||||
//! ```
|
||||
//! An image only linked images show is saved as its low-resolution preview (`"proxy": true`); the
|
||||
//! engine reads the linked file again when the document opens.
|
||||
//! It is lossless for everything in the document model and preserves unknown fields under
|
||||
//! `document.unknown`. Readers must reject files whose `version` is newer than they support.
|
||||
//! It is lossless for everything in the document model; foreign data lives in `document.unknown`
|
||||
//! and document keys a newer version added are kept (`Document::extra`). Only the images the
|
||||
//! document uses are written. `preview` (optional) is a PNG of the first artboard, at most
|
||||
//! [`PREVIEW_MAX`] pixels on its longer side, for file browsers. An image only linked images show
|
||||
//! is saved as its low-resolution preview (`"proxy": true`); the engine reads the linked file again
|
||||
//! when the document opens.
|
||||
//!
|
||||
//! Versions: v1 wrote anchors as `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`; v2 as
|
||||
//! `{p: [x, y], in?, out?, kind?}` with default-valued fields left out; v3 keeps the assigned colour
|
||||
//! profiles in `document.color_profiles` (before: `document.unknown.colorProfiles`) and may be
|
||||
//! compressed. Every version loads, and [`save_with`] writes any of them for older apps (under the
|
||||
//! format name `drawcraft`, which every version reads). Readers reject files whose `version` is
|
||||
//! newer than they support.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod atomic;
|
||||
mod legacy;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_doc::{Document, ImageBlob};
|
||||
|
||||
/// v2: anchors as `{p: [x, y], in?, out?, kind?}` and default-valued fields omitted (v1 files still load).
|
||||
pub const VERSION: u32 = 2;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use atomic::{write_atomic, write_atomic_with};
|
||||
|
||||
/// v3: colour profiles as a document field, optional compression and preview (v1 and v2 files still load).
|
||||
pub const VERSION: u32 = 3;
|
||||
/// The oldest version [`save_with`] writes.
|
||||
pub const MIN_VERSION: u32 = 1;
|
||||
/// The first version whose readers open compressed files.
|
||||
pub const COMPRESSED_SINCE: u32 = 3;
|
||||
/// Most pixels on the longer side of an embedded preview.
|
||||
pub const PREVIEW_MAX: u32 = 256;
|
||||
pub const EXTENSION: &str = "vectorcraft";
|
||||
/// Extension and format name from before the project was renamed (DrawCraft): still opened.
|
||||
pub const LEGACY_EXTENSION: &str = "drawcraft";
|
||||
/// Most bytes a compressed file may unpack to (a guard against decompression bombs).
|
||||
const MAX_UNPACKED: u64 = 512 << 20;
|
||||
|
||||
/// Is `ext` (without the dot, any case) a native document extension?
|
||||
pub fn is_native_ext(ext: &str) -> bool {
|
||||
|
|
@ -41,6 +69,14 @@ pub enum FormatError {
|
|||
TooNew(u32),
|
||||
#[error("invalid image data for `{0}`")]
|
||||
BadImage(String),
|
||||
#[error("can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION}")]
|
||||
BadVersion(u32),
|
||||
#[error("version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files")]
|
||||
CompressedTooOld(u32),
|
||||
#[error("the compressed file unpacks to more than {} MB", MAX_UNPACKED >> 20)]
|
||||
TooBig,
|
||||
#[error("can't encode the document: {0}")]
|
||||
Encode(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -52,41 +88,58 @@ struct Image {
|
|||
proxy: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Deserialize)]
|
||||
struct File {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
generator: String,
|
||||
document: Document,
|
||||
#[serde(default)]
|
||||
images: BTreeMap<String, Image>,
|
||||
}
|
||||
|
||||
/// Serialize a document (pretty = human-diffable). Editing-mode working copies are left out
|
||||
/// ([`Document::without_edit_modes`]).
|
||||
/// How [`save_with`] writes a document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SaveOptions {
|
||||
/// Indented JSON (readable, diffable); compact otherwise. Compressed files are always compact.
|
||||
pub pretty: bool,
|
||||
/// gzip the file (Use Compression), for version [`COMPRESSED_SINCE`] and later.
|
||||
pub compress: bool,
|
||||
/// The version to write ([`MIN_VERSION`]`..=`[`VERSION`]): older ones for older apps.
|
||||
pub version: u32,
|
||||
/// A PNG preview to embed (at most [`PREVIEW_MAX`] pixels on its longer side).
|
||||
pub preview: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Default for SaveOptions {
|
||||
fn default() -> Self {
|
||||
Self { pretty: false, compress: false, version: VERSION, preview: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl SaveOptions {
|
||||
/// Can a file be written this way (a version [`save_with`] writes, compressed only for readers
|
||||
/// that open compressed files)?
|
||||
pub fn check(&self) -> Result<(), FormatError> {
|
||||
if !(MIN_VERSION..=VERSION).contains(&self.version) {
|
||||
return Err(FormatError::BadVersion(self.version));
|
||||
}
|
||||
if self.compress && self.version < COMPRESSED_SINCE {
|
||||
return Err(FormatError::CompressedTooOld(self.version));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`]).
|
||||
pub fn for_doc(doc: &Document) -> Self {
|
||||
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
|
||||
Self { pretty: objects <= PRETTY_MAX_OBJECTS, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a document (pretty = human-diffable) in the current version. Editing-mode working
|
||||
/// copies are left out ([`Document::without_edit_modes`]).
|
||||
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
let doc = doc.without_edit_modes();
|
||||
let linked = doc.linked_only_images();
|
||||
let images = doc
|
||||
.images
|
||||
.iter()
|
||||
.map(|(k, b)| {
|
||||
let image = match b.proxy.as_ref().filter(|_| linked.contains(k)) {
|
||||
Some(p) => Image { mime: vectorcraft_doc::links::PROXY_MIME.into(), data: base64_encode(p), proxy: true },
|
||||
None => Image { mime: b.mime.clone(), data: base64_encode(&b.bytes), proxy: false },
|
||||
};
|
||||
(k.clone(), image)
|
||||
})
|
||||
.collect();
|
||||
let f = File {
|
||||
format: "vectorcraft".into(),
|
||||
version: VERSION,
|
||||
generator: format!("VectorCraft {}", env!("CARGO_PKG_VERSION")),
|
||||
document: doc.into_owned(),
|
||||
images,
|
||||
};
|
||||
if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() }
|
||||
save_with(doc, &SaveOptions { pretty, ..SaveOptions::default() }).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Documents up to this many objects are saved pretty-printed (readable, diffable); larger ones
|
||||
|
|
@ -95,8 +148,147 @@ pub const PRETTY_MAX_OBJECTS: usize = 2000;
|
|||
|
||||
/// Serialize for saving to disk: pretty when small, compact when large.
|
||||
pub fn save_file(doc: &Document) -> Vec<u8> {
|
||||
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
|
||||
save(doc, objects <= PRETTY_MAX_OBJECTS)
|
||||
save_with(doc, &SaveOptions::for_doc(doc)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Serialize a document as `o` says: the version, compression, layout and preview. Editing-mode
|
||||
/// working copies and images nothing uses are left out.
|
||||
pub fn save_with(doc: &Document, o: &SaveOptions) -> Result<Vec<u8>, FormatError> {
|
||||
o.check()?;
|
||||
let pretty = o.pretty && !o.compress;
|
||||
let mut d = doc.without_edit_modes().into_owned();
|
||||
let linked = d.linked_only_images();
|
||||
// The blobs go in the file's `images` (only those the document uses).
|
||||
let blobs = std::mem::take(&mut d.images);
|
||||
if o.version < 3 {
|
||||
d.store_legacy_color_profiles();
|
||||
}
|
||||
let body = if o.version == 1 {
|
||||
let mut v = serde_json::to_value(&d).map_err(encode_err)?;
|
||||
legacy::anchors_to_v1(&mut v);
|
||||
to_json(&v, pretty)?
|
||||
} else {
|
||||
to_json(&d, pretty)?
|
||||
};
|
||||
// Images are only referenced by image objects' `key` (wherever the objects are: layers,
|
||||
// symbols, patterns, masks, or foreign data).
|
||||
let used = if blobs.is_empty() { HashSet::new() } else { string_members(&body, "key") };
|
||||
let images: BTreeMap<&str, Image> = blobs
|
||||
.iter()
|
||||
.filter(|(k, _)| used.contains(k.as_str()))
|
||||
.map(|(k, b)| {
|
||||
let image = match b.proxy.as_ref().filter(|_| linked.contains(k)) {
|
||||
Some(p) => Image { mime: vectorcraft_doc::links::PROXY_MIME.into(), data: base64_encode(p), proxy: true },
|
||||
None => Image { mime: b.mime.clone(), data: base64_encode(&b.bytes), proxy: false },
|
||||
};
|
||||
(k.as_str(), image)
|
||||
})
|
||||
.collect();
|
||||
let size = body.len() + images.values().map(|i| i.data.len()).sum::<usize>() + 256;
|
||||
let mut w = Envelope { out: Vec::with_capacity(size), pretty };
|
||||
// Older apps only know the name from before the rename.
|
||||
w.field("format", &if o.version < VERSION { LEGACY_EXTENSION } else { EXTENSION })?;
|
||||
w.field("version", &o.version)?;
|
||||
w.field("generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?;
|
||||
if let Some(png) = &o.preview {
|
||||
w.field("preview", &Image { mime: "image/png".into(), data: base64_encode(png), proxy: false })?;
|
||||
}
|
||||
w.raw("document", &body);
|
||||
w.field("images", &images)?;
|
||||
let out = w.finish();
|
||||
if !o.compress {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::with_capacity(out.len() / 4), flate2::Compression::default());
|
||||
gz.write_all(&out).map_err(encode_err)?;
|
||||
gz.finish().map_err(encode_err)
|
||||
}
|
||||
|
||||
fn encode_err(e: impl std::fmt::Display) -> FormatError {
|
||||
FormatError::Encode(e.to_string())
|
||||
}
|
||||
|
||||
fn to_json(v: &impl Serialize, pretty: bool) -> Result<Vec<u8>, FormatError> {
|
||||
if pretty { serde_json::to_vec_pretty(v) } else { serde_json::to_vec(v) }.map_err(encode_err)
|
||||
}
|
||||
|
||||
/// The file's top-level object, written member by member (the document goes in pre-serialized).
|
||||
struct Envelope {
|
||||
out: Vec<u8>,
|
||||
pretty: bool,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
fn field(&mut self, key: &str, value: &impl Serialize) -> Result<(), FormatError> {
|
||||
let json = to_json(value, self.pretty)?;
|
||||
self.raw(key, &json);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Member `key` (a plain name) with the JSON text `json`, indented one level when pretty (as
|
||||
/// serde_json would).
|
||||
fn raw(&mut self, key: &str, json: &[u8]) {
|
||||
let out = &mut self.out;
|
||||
out.push(if out.is_empty() { b'{' } else { b',' });
|
||||
if self.pretty {
|
||||
out.extend_from_slice(b"\n ");
|
||||
}
|
||||
out.push(b'"');
|
||||
out.extend_from_slice(key.as_bytes());
|
||||
out.extend_from_slice(if self.pretty { b"\": " } else { b"\":" });
|
||||
// Newlines only occur between tokens (inside strings they are escaped).
|
||||
let mut lines = json.split(|&b| b == b'\n');
|
||||
out.extend_from_slice(lines.next().unwrap_or_default());
|
||||
for line in lines {
|
||||
out.extend_from_slice(b"\n ");
|
||||
out.extend_from_slice(line);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
self.out.extend_from_slice(if self.pretty { b"\n}" } else { b"}" });
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
/// The string values of every `"<name>": "…"` member at any depth of JSON text. Quotes inside JSON
|
||||
/// strings are escaped, so every match is a real member.
|
||||
fn string_members(json: &[u8], name: &str) -> HashSet<String> {
|
||||
let tag = format!("\"{name}\":");
|
||||
let tag = tag.as_bytes();
|
||||
let mut out = HashSet::new();
|
||||
let mut rest = json;
|
||||
while let Some(at) = rest.windows(tag.len()).position(|w| w == tag) {
|
||||
rest = rest.get(at + tag.len()..).unwrap_or_default();
|
||||
let value = rest.trim_ascii_start();
|
||||
if value.first() == Some(&b'"')
|
||||
&& let Some(Ok(s)) = serde_json::Deserializer::from_slice(value).into_iter::<String>().next()
|
||||
{
|
||||
out.insert(s);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is this gzip data (a compressed file)?
|
||||
pub fn is_compressed(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&[0x1f, 0x8b])
|
||||
}
|
||||
|
||||
/// The JSON text of a file, unpacked when compressed.
|
||||
fn unpack(bytes: &[u8]) -> Result<Cow<'_, [u8]>, FormatError> {
|
||||
if !is_compressed(bytes) {
|
||||
return Ok(Cow::Borrowed(bytes));
|
||||
}
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes)
|
||||
.take(MAX_UNPACKED + 1)
|
||||
.read_to_end(&mut raw)
|
||||
.map_err(|e| FormatError::NotVectorcraft(format!("damaged compressed data: {e}")))?;
|
||||
if raw.len() as u64 > MAX_UNPACKED {
|
||||
return Err(FormatError::TooBig);
|
||||
}
|
||||
Ok(Cow::Owned(raw))
|
||||
}
|
||||
|
||||
pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
|
|
@ -121,7 +313,8 @@ impl FileInfo {
|
|||
|
||||
/// [`load`], also telling the file's version and name.
|
||||
pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> {
|
||||
let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
|
||||
let text = unpack(bytes)?;
|
||||
let f: File = serde_json::from_slice(&text).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
|
||||
if f.format != EXTENSION && f.format != LEGACY_EXTENSION {
|
||||
return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format)));
|
||||
}
|
||||
|
|
@ -142,14 +335,37 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> {
|
|||
doc.drop_edit_modes();
|
||||
// Saved before per-fill/stroke overprint: the Overprint Black list becomes overprint flags.
|
||||
doc.migrate_overprint_black();
|
||||
// Saved before v3: the assigned profiles move out of `unknown`.
|
||||
doc.migrate_color_profiles();
|
||||
doc.fix_next_id();
|
||||
Ok((doc, FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }))
|
||||
}
|
||||
|
||||
/// Does this look like a `.vectorcraft` file?
|
||||
/// The preview PNG embedded in a native file (`None`: it has none, or isn't a native file).
|
||||
pub fn preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Head {
|
||||
preview: Option<Image>,
|
||||
}
|
||||
let text = unpack(bytes).ok()?;
|
||||
let head: Head = serde_json::from_slice(&text).ok()?;
|
||||
base64_decode(&head.preview?.data)
|
||||
}
|
||||
|
||||
/// Does this look like a `.vectorcraft` file (compressed or not)?
|
||||
pub fn sniff(bytes: &[u8]) -> bool {
|
||||
let head = &bytes[..bytes.len().min(256)];
|
||||
std::str::from_utf8(head).is_ok_and(|s| s.trim_start().starts_with('{') && (s.contains("\"vectorcraft\"") || s.contains("\"drawcraft\"")))
|
||||
const HEAD: usize = 256;
|
||||
let head: Cow<'_, [u8]> = if is_compressed(bytes) {
|
||||
let mut head = Vec::with_capacity(HEAD);
|
||||
// A damaged stream may still unpack its start: judge what came out.
|
||||
let _ = flate2::read::GzDecoder::new(bytes).take(HEAD as u64).read_to_end(&mut head);
|
||||
Cow::Owned(head)
|
||||
} else {
|
||||
Cow::Borrowed(bytes.get(..HEAD).unwrap_or(bytes))
|
||||
};
|
||||
// The cut may split a character.
|
||||
let s = String::from_utf8_lossy(&head);
|
||||
s.trim_start().starts_with('{') && (s.contains("\"vectorcraft\"") || s.contains("\"drawcraft\""))
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
|
@ -223,8 +439,8 @@ mod tests {
|
|||
assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false });
|
||||
assert!(load_info(v1.as_bytes()).unwrap().1.is_old());
|
||||
}
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_doc::{Appearance, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
|
|
@ -233,6 +449,10 @@ mod tests {
|
|||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
|
||||
d.images.insert("k".into(), ImageBlob::new("image/png", vec![1, 2, 3, 250]));
|
||||
// Only images in use are saved.
|
||||
let img = d.alloc_id();
|
||||
let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None };
|
||||
d.insert(Some(l), 1, Node::new(img, NodeKind::Image(im))).unwrap();
|
||||
let bytes = save(&d, true);
|
||||
assert!(sniff(&bytes));
|
||||
let back = load(&bytes).unwrap();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
//! `.vectorcraft` v3: compression, pruning unused images, keys from newer versions, colour
|
||||
//! profiles as a document field, saving for older versions and the embedded preview.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorProfiles, Document, ImageBlob, ImageObject, Node, NodeKind, OpacityMask, Symbol};
|
||||
use vectorcraft_format::{FormatError, SaveOptions, VERSION, is_compressed, load, preview, save, save_with, sniff};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_testkit::fixtures;
|
||||
use vectorcraft_testkit::invariants::{check_native_roundtrip, doc_json, json_approx_eq};
|
||||
|
||||
fn rich_doc() -> Document {
|
||||
(*fixtures::rich_session().doc().unwrap().doc).clone()
|
||||
}
|
||||
|
||||
fn json_of(bytes: &[u8]) -> Value {
|
||||
serde_json::from_slice(bytes).unwrap()
|
||||
}
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
gz.write_all(bytes).unwrap();
|
||||
gz.finish().unwrap()
|
||||
}
|
||||
|
||||
fn blob(bytes: &[u8]) -> ImageBlob {
|
||||
ImageBlob::new("image/png", bytes.to_vec())
|
||||
}
|
||||
|
||||
fn image_node(d: &mut Document, key: &str) -> Node {
|
||||
let id = d.alloc_id();
|
||||
Node::new(id, NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None }))
|
||||
}
|
||||
|
||||
fn opts(version: u32, compress: bool) -> SaveOptions {
|
||||
SaveOptions { version, compress, ..SaveOptions::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_files_load() {
|
||||
let d = rich_doc();
|
||||
let packed = save_with(&d, &SaveOptions { compress: true, pretty: true, ..SaveOptions::default() }).unwrap();
|
||||
assert!(is_compressed(&packed) && sniff(&packed));
|
||||
let plain = save(&d, false);
|
||||
assert!(packed.len() * 3 < plain.len(), "compressed {} vs {} bytes", packed.len(), plain.len());
|
||||
assert_eq!(doc_json(&load(&packed).unwrap()), doc_json(&load(&plain).unwrap()));
|
||||
// Compressed files are compact even when pretty was asked for.
|
||||
assert!(!flate2_unpack(&packed).contains(&b'\n'));
|
||||
// A legacy (v2, pre-rename) file compressed by hand opens too.
|
||||
let text = String::from_utf8(plain.clone()).unwrap();
|
||||
let legacy = text.replacen("\"format\":\"vectorcraft\",\"version\":3", "\"format\":\"drawcraft\",\"version\":2", 1);
|
||||
assert_ne!(legacy, text);
|
||||
let legacy = gzip(legacy.as_bytes());
|
||||
assert!(sniff(&legacy));
|
||||
assert_eq!(doc_json(&load(&legacy).unwrap()), doc_json(&load(&plain).unwrap()));
|
||||
// Other gzip data (an SVGZ) is not a native file.
|
||||
assert!(!sniff(&gzip(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>")));
|
||||
assert!(load(&packed[..packed.len() / 2]).is_err(), "a truncated file is an error");
|
||||
}
|
||||
|
||||
fn flate2_unpack(bytes: &[u8]) -> Vec<u8> {
|
||||
use std::io::Read as _;
|
||||
let mut out = vec![];
|
||||
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_images_are_pruned() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
for (k, b) in [("used", &[1u8, 2][..]), ("unused", &[3, 4]), ("in symbol", &[5]), ("in brush", &[6]), ("in mask", &[7])] {
|
||||
d.images.insert(k.into(), blob(b));
|
||||
}
|
||||
let mut n = image_node(&mut d, "used");
|
||||
n.mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false)));
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
let art = image_node(&mut d, "in symbol");
|
||||
d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
// Image objects inside foreign data (a brush library kept as JSON) keep theirs too.
|
||||
d.unknown.insert("brushes".into(), json!([{"art": {"type": "image", "key": "in brush"}}]));
|
||||
let back = load(&save(&d, true)).unwrap();
|
||||
let mut keys: Vec<&str> = back.images.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys, ["in brush", "in mask", "in symbol", "used"]);
|
||||
assert_eq!(back.images["used"].bytes.as_slice(), &[1, 2]);
|
||||
let v = json_of(&save(&d, false));
|
||||
assert!(v["images"].get("unused").is_none() && v["document"]["images"].as_object().is_some_and(|m| m.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_newer_versions_round_trip() {
|
||||
let mut v = json_of(&save(&rich_doc(), false));
|
||||
v["document"]["futureFeature"] = json!({"a": [1, 2, 3], "b": "x"});
|
||||
v["document"]["futureFlag"] = json!(true);
|
||||
let d = load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
assert_eq!(d.extra["futureFeature"]["a"], json!([1, 2, 3]));
|
||||
assert!(!d.unknown.contains_key("futureFeature"), "kept apart from `unknown`");
|
||||
for pretty in [false, true] {
|
||||
let again = json_of(&save(&d, pretty));
|
||||
assert_eq!(again["document"]["futureFeature"], v["document"]["futureFeature"]);
|
||||
assert_eq!(again["document"]["futureFlag"], json!(true));
|
||||
}
|
||||
check_native_roundtrip(&d).unwrap();
|
||||
}
|
||||
|
||||
/// A document as the first version wrote it (format name `drawcraft`, anchors as maps).
|
||||
const V1_FILE: &str = r#"{"format":"drawcraft","version":1,"generator":"DrawCraft 0.1.0","document":{"version":1,"title":"Old","artboards":[{"id":1,"name":"Artboard 1","rect":{"x0":0.0,"y0":0.0,"x1":100.0,"y1":80.0}}],
|
||||
"layers":[{"id":1,"name":"Layer 1","visible":true,"locked":false,"opacity":1.0,"kind":{"type":"layer","children":[
|
||||
{"id":2,"visible":true,"locked":false,"opacity":1.0,"kind":{"type":"path","path":{"subpaths":[{"anchors":[
|
||||
{"p":{"x":10.0,"y":10.0},"in":{"x":10.0,"y":10.0},"out":{"x":30.0,"y":0.0},"kind":"Smooth"},
|
||||
{"p":{"x":50.0,"y":40.0},"in":{"x":50.0,"y":40.0},"out":{"x":50.0,"y":40.0},"kind":"Corner"}],"closed":false}]},"rule":"NonZero","clipping":false,"guide":false}}],"color":{"Preset":0}}}],
|
||||
"next_id":3,"unknown":{"colorProfiles":{"rgb":"sRGB IEC61966-2.1","cmyk":null}}},"images":{}}"#;
|
||||
|
||||
#[test]
|
||||
fn version_1_files_load() {
|
||||
let d = load(V1_FILE.as_bytes()).unwrap();
|
||||
assert_eq!(d.title, "Old");
|
||||
let NodeKind::Path { path, .. } = &d.layers[0].children().unwrap()[0].kind else { panic!("not a path") };
|
||||
let a = &path.subpaths[0].anchors;
|
||||
assert_eq!((a[0].h_out.x, a[0].h_out.y, a[1].p.x), (30.0, 0.0, 50.0));
|
||||
// Profiles migrate out of `unknown`.
|
||||
assert_eq!(d.color_profiles, ColorProfiles { rgb: Some("sRGB IEC61966-2.1".into()), cmyk: None });
|
||||
assert!(d.unknown.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_down_to_older_versions() {
|
||||
let mut d = rich_doc();
|
||||
d.color_profiles = ColorProfiles { rgb: None, cmyk: Some("Coated".into()) };
|
||||
let current = doc_json(&load(&save(&d, false)).unwrap());
|
||||
for version in [1, 2] {
|
||||
let bytes = save_with(&d, &opts(version, false)).unwrap();
|
||||
let v = json_of(&bytes);
|
||||
assert_eq!((v["format"].as_str(), v["version"].as_u64()), (Some("drawcraft"), Some(version as u64)), "older apps know the old name");
|
||||
// Profiles where those versions keep them.
|
||||
assert_eq!(v["document"]["unknown"]["colorProfiles"], json!({"rgb": null, "cmyk": "Coated"}));
|
||||
assert!(v["document"].get("color_profiles").is_none());
|
||||
let back = load(&bytes).unwrap();
|
||||
assert!(json_approx_eq(&doc_json(&back), ¤t, 1e-12), "v{version} loses nothing it can hold");
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
// v1 anchors are maps with every member; v2 anchors are compact.
|
||||
assert_eq!(text.contains("\"p\":{\"x\""), version == 1);
|
||||
assert_eq!(text.contains("\"p\":["), version == 2);
|
||||
}
|
||||
let v3 = json_of(&save(&d, false));
|
||||
assert_eq!((v3["format"].as_str(), v3["version"].as_u64()), (Some("vectorcraft"), Some(VERSION as u64)));
|
||||
assert_eq!(v3["document"]["color_profiles"], json!({"cmyk": "Coated"}));
|
||||
assert!(v3["document"]["unknown"].get("colorProfiles").is_none());
|
||||
// Out of range, and compression older apps can't read.
|
||||
assert!(matches!(save_with(&d, &opts(0, false)), Err(FormatError::BadVersion(0))));
|
||||
assert!(matches!(save_with(&d, &opts(VERSION + 1, false)), Err(FormatError::BadVersion(_))));
|
||||
assert!(matches!(save_with(&d, &opts(2, true)), Err(FormatError::CompressedTooOld(2))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_is_embedded_and_read_back() {
|
||||
let d = Document::new(10.0, 10.0);
|
||||
assert_eq!(preview(&save(&d, false)), None);
|
||||
let png = vec![0x89, b'P', b'N', b'G', 1, 2, 3];
|
||||
for compress in [false, true] {
|
||||
let bytes = save_with(&d, &SaveOptions { compress, preview: Some(png.clone()), ..SaveOptions::default() }).unwrap();
|
||||
assert_eq!(preview(&bytes), Some(png.clone()));
|
||||
assert!(load(&bytes).is_ok());
|
||||
}
|
||||
assert_eq!(preview(b"not a file"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretty_files_are_indented_like_serde_json() {
|
||||
let d = rich_doc();
|
||||
let bytes = save(&d, true);
|
||||
let text = String::from_utf8(bytes.clone()).unwrap();
|
||||
assert!(text.starts_with("{\n \"format\": \"vectorcraft\",\n \"version\": "));
|
||||
assert!(text.contains("\n \"document\": {\n \""));
|
||||
assert!(text.ends_with("\n}"));
|
||||
// The same JSON as the compact file.
|
||||
assert_eq!(json_of(&bytes), json_of(&save(&d, false)));
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@
|
|||
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, NodeId};
|
||||
use vectorcraft_format::{base64_decode, base64_encode, load, save, sniff};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind};
|
||||
use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, preview, save, save_with, sniff};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_testkit::fixtures;
|
||||
use vectorcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq};
|
||||
use vectorcraft_testkit::strategies::arb_ops;
|
||||
|
|
@ -67,13 +68,50 @@ proptest! {
|
|||
prop_assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// Embedded image bytes survive the base64 round trip exactly.
|
||||
/// Embedded image bytes survive the base64 round trip exactly (saves keep the images in use).
|
||||
#[test]
|
||||
fn images_roundtrip(data in prop::collection::vec(any::<u8>(), 0..2000), key in "[a-z]{1,8}") {
|
||||
fn images_roundtrip(data in prop::collection::vec(any::<u8>(), 0..2000), key in "[a-z \"\\\\]{1,8}") {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", data.clone()));
|
||||
let back = load(&save(&d, false)).unwrap();
|
||||
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data));
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None };
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||||
for pretty in [false, true] {
|
||||
let back = load(&save(&d, pretty)).unwrap();
|
||||
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Compressed and older-version saves of random documents load to the same document.
|
||||
#[test]
|
||||
fn compressed_and_older_saves_roundtrip(ops in arb_ops(5..30)) {
|
||||
let mut s = fixtures::session();
|
||||
for op in &ops {
|
||||
let _ = op.apply(&mut s);
|
||||
}
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let want = doc_json(&load(&save(&d, false)).unwrap());
|
||||
for o in [SaveOptions { compress: true, ..SaveOptions::default() }, SaveOptions { version: 2, ..SaveOptions::default() }, SaveOptions { version: 1, pretty: true, ..SaveOptions::default() }] {
|
||||
let back = load(&save_with(&d, &o).unwrap()).unwrap();
|
||||
prop_assert!(json_approx_eq(&doc_json(&back), &want, 1e-12), "{:?} differs", o);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutating a compressed file (byte flips / truncation) never panics the loader or the sniffer.
|
||||
#[test]
|
||||
fn load_mutated_compressed_file_never_panics(cut in 0usize..2000, flips in prop::collection::vec((0usize..2000, any::<u8>()), 0..8)) {
|
||||
let mut bytes = save_with(&Document::new(100.0, 100.0), &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap();
|
||||
for (i, b) in flips {
|
||||
let n = bytes.len();
|
||||
bytes[i % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.min(bytes.len()));
|
||||
let _ = sniff(&bytes);
|
||||
let _ = preview(&bytes);
|
||||
if let Ok(d) = load(&bytes) {
|
||||
check_document(&d).map_err(TestCaseError::fail)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,12 +88,39 @@ pub fn check_session(s: &Session) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `.vectorcraft` save → load → the JSON of the document and of what loaded, without `images`: a
|
||||
/// save keeps only the images in use, which must come back byte for byte.
|
||||
fn native_roundtrip(doc: &Document) -> Result<(Value, Value), String> {
|
||||
let bytes = vectorcraft_format::save(doc, false);
|
||||
let back = vectorcraft_format::load(&bytes).map_err(|e| format!("load: {e}"))?;
|
||||
if let Some(k) = back.images.iter().find(|(k, b)| doc.images.get(*k).map(|d| &d.bytes) != Some(&b.bytes)).map(|(k, _)| k) {
|
||||
return Err(format!("native round trip changed image `{k}`"));
|
||||
}
|
||||
let mut lost = None;
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& doc.images.contains_key(&im.key)
|
||||
&& !back.images.contains_key(&im.key)
|
||||
{
|
||||
lost = Some(im.key.clone());
|
||||
}
|
||||
});
|
||||
if let Some(k) = lost {
|
||||
return Err(format!("native round trip lost image `{k}`, which is in use"));
|
||||
}
|
||||
let strip = |mut v: Value| {
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("images");
|
||||
}
|
||||
v
|
||||
};
|
||||
Ok((strip(doc_json(doc)), strip(doc_json(&back))))
|
||||
}
|
||||
|
||||
/// `.vectorcraft` save → load must reproduce the document (numbers compared to 1e-12 relative —
|
||||
/// see [`check_native_roundtrip_exact`] for the bit-exact version).
|
||||
pub fn check_native_roundtrip(doc: &Document) -> Result<(), String> {
|
||||
let bytes = vectorcraft_format::save(doc, false);
|
||||
let back = vectorcraft_format::load(&bytes).map_err(|e| format!("load: {e}"))?;
|
||||
let (a, b) = (doc_json(doc), doc_json(&back));
|
||||
let (a, b) = native_roundtrip(doc)?;
|
||||
if !json_approx_eq(&a, &b, 1e-12) {
|
||||
return Err(format!("native round trip differs:\n{}", first_diff(&a, &b, "$")));
|
||||
}
|
||||
|
|
@ -102,9 +129,7 @@ pub fn check_native_roundtrip(doc: &Document) -> Result<(), String> {
|
|||
|
||||
/// Bit-exact `.vectorcraft` round trip (every f64 must survive).
|
||||
pub fn check_native_roundtrip_exact(doc: &Document) -> Result<(), String> {
|
||||
let bytes = vectorcraft_format::save(doc, false);
|
||||
let back = vectorcraft_format::load(&bytes).map_err(|e| format!("load: {e}"))?;
|
||||
let (a, b) = (doc_json(doc), doc_json(&back));
|
||||
let (a, b) = native_roundtrip(doc)?;
|
||||
if a != b {
|
||||
return Err(format!("native round trip differs:\n{}", first_diff(&a, &b, "$")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
//! Background Save and Background Export (Preferences → File Handling): a save or an export encodes
|
||||
//! and writes a snapshot of the document on a worker thread while editing goes on. The status bar
|
||||
//! shows what is in progress; the result is applied on the UI thread when it arrives (a save marks
|
||||
//! the document saved as of its snapshot, so edits made meanwhile keep it modified).
|
||||
//!
|
||||
//! It needs a thread-safe writer from the host ([`crate::Services::write_shared`], desktop);
|
||||
//! without one (the web, which downloads) the work runs at once, as with the preference off.
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Writes a file; callable from any thread.
|
||||
pub type SharedWriteFn = std::sync::Arc<dyn Fn(&str, &[u8]) -> Result<(), String> + Send + Sync>;
|
||||
|
||||
/// A writer for the work: the host's file writer (or browser download) on the UI thread, the
|
||||
/// shared one on the worker.
|
||||
pub type Writer<'a> = &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>;
|
||||
|
||||
/// Encodes and writes a snapshot (no access to the app) → the result handed to [`Then`].
|
||||
type Work = Box<dyn FnOnce(Writer) -> Result<Value, String> + Send>;
|
||||
|
||||
/// Applies a result on the UI thread (document state, status, recent files) → the command result.
|
||||
type Then = Box<dyn FnOnce(&mut VectorcraftApp, Result<Value, String>) -> Result<Value, String>>;
|
||||
|
||||
/// A job's id and the work's result, back from the worker.
|
||||
type Done = (u64, Result<Value, String>);
|
||||
|
||||
struct Task {
|
||||
id: u64,
|
||||
work: Work,
|
||||
write: SharedWriteFn,
|
||||
}
|
||||
|
||||
/// A job in progress.
|
||||
pub struct Job {
|
||||
id: u64,
|
||||
/// What the status bar shows ("Saving Poster.vectorcraft").
|
||||
pub label: String,
|
||||
/// The document the job reads (`DocState::uid`), if any.
|
||||
pub doc: Option<u64>,
|
||||
then: Then,
|
||||
}
|
||||
|
||||
/// The worker and the jobs it has not finished.
|
||||
#[derive(Default)]
|
||||
pub struct Background {
|
||||
worker: Option<(Sender<Task>, Receiver<Done>)>,
|
||||
/// Jobs in the order they were started (the worker runs them in that order).
|
||||
pub jobs: Vec<Job>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Background {
|
||||
/// The worker's channels, started on first use (`None` on wasm, which has no threads).
|
||||
fn worker(&mut self) -> Option<&Sender<Task>> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
if self.worker.is_none() {
|
||||
let (tx, tasks) = channel::<Task>();
|
||||
let (done_tx, done) = channel();
|
||||
std::thread::Builder::new()
|
||||
.name("vectorcraft-save".into())
|
||||
.spawn(move || {
|
||||
while let Ok(Task { id, work, write }) = tasks.recv() {
|
||||
let r = vectorcraft_engine::guard::catch_panic(|| work(&mut |p: &str, b: &[u8]| write(p, b)))
|
||||
.unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)")));
|
||||
if done_tx.send((id, r)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
self.worker = Some((tx, done));
|
||||
}
|
||||
self.worker.as_ref().map(|(tx, _)| tx)
|
||||
}
|
||||
|
||||
/// Is a job reading document `uid` still running?
|
||||
pub fn busy_with(&self, uid: u64) -> bool {
|
||||
self.jobs.iter().any(|j| j.doc == Some(uid))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `work` on a worker thread when `enabled` and the host can write from one, else now; `then`
|
||||
/// gets its result on the UI thread. → `then`'s result, or `{background: true, status}` while the
|
||||
/// work runs (the status bar shows `label`).
|
||||
pub fn run(
|
||||
app: &mut VectorcraftApp,
|
||||
enabled: bool,
|
||||
label: String,
|
||||
doc: Option<u64>,
|
||||
work: impl FnOnce(Writer) -> Result<Value, String> + Send + 'static,
|
||||
then: impl FnOnce(&mut VectorcraftApp, Result<Value, String>) -> Result<Value, String> + 'static,
|
||||
) -> Result<Value, String> {
|
||||
let mut work: Work = Box::new(work);
|
||||
let shared = app.services.write_shared.clone().filter(|_| enabled && app.services.download.is_none());
|
||||
if let Some(write) = shared {
|
||||
let id = app.background.next_id;
|
||||
if let Some(tx) = app.background.worker() {
|
||||
match tx.send(Task { id, work, write }) {
|
||||
Ok(()) => {
|
||||
let bg = &mut app.background;
|
||||
bg.next_id += 1;
|
||||
bg.jobs.push(Job { id, label: label.clone(), doc, then: Box::new(then) });
|
||||
let status = format!("{label}…");
|
||||
app.status(status.clone());
|
||||
return Ok(json!({ "background": true, "status": status }));
|
||||
}
|
||||
// The worker stopped: start a new one next time, and do this one now.
|
||||
Err(e) => {
|
||||
app.background.worker = None;
|
||||
work = e.0.work;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let services = &mut app.services;
|
||||
let r = work(&mut |p: &str, b: &[u8]| crate::io::write_to(services, p, b));
|
||||
then(app, r)
|
||||
}
|
||||
|
||||
/// Apply the results that arrived (each frame).
|
||||
pub fn poll(app: &mut VectorcraftApp) {
|
||||
while let Some(done) = app.background.worker.as_ref().and_then(|(_, rx)| rx.try_recv().ok()) {
|
||||
finish(app, done);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for every job and apply its result (before quitting, closing a document, or another save
|
||||
/// of a document a job is reading).
|
||||
pub fn wait_all(app: &mut VectorcraftApp) {
|
||||
while !app.background.jobs.is_empty() {
|
||||
let Some(done) = app.background.worker.as_ref().and_then(|(_, rx)| rx.recv().ok()) else {
|
||||
// The worker is gone: its jobs can't finish.
|
||||
for job in std::mem::take(&mut app.background.jobs) {
|
||||
apply(app, job, Err("the background worker stopped".into()));
|
||||
}
|
||||
app.background.worker = None;
|
||||
return;
|
||||
};
|
||||
finish(app, done);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(app: &mut VectorcraftApp, (id, r): Done) {
|
||||
let Some(i) = app.background.jobs.iter().position(|j| j.id == id) else { return };
|
||||
let job = app.background.jobs.remove(i);
|
||||
apply(app, job, r);
|
||||
}
|
||||
|
||||
/// Hand a job its result; a failure goes to the status bar (the call that started it returned).
|
||||
fn apply(app: &mut VectorcraftApp, job: Job, r: Result<Value, String>) {
|
||||
if let Err(e) = (job.then)(app, r) {
|
||||
app.status(format!("{} failed: {e}", job.label));
|
||||
}
|
||||
}
|
||||
|
|
@ -383,7 +383,13 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
egui::RichText::new(format!("X: {} Y: {}", u.readout(p.x), u.readout(p.y))).font(theme::mono(10.5)).color(t.text_dim),
|
||||
);
|
||||
}
|
||||
if !app.ui.status.is_empty() {
|
||||
// Background saves and exports in progress, else the last message.
|
||||
if let Some(job) = app.background.jobs.first() {
|
||||
let more = app.background.jobs.len() - 1;
|
||||
let text = if more > 0 { format!("{}… (+{more})", job.label) } else { format!("{}…", job.label) };
|
||||
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
|
||||
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
|
||||
} else if !app.ui.status.is_empty() {
|
||||
ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
|||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
|
||||
"activeDocument": app.session.active_index(),
|
||||
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
|
||||
// Saves and exports still being written in the background (Background Save / Export).
|
||||
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use vectorcraft_doc::SavedView;
|
|||
use vectorcraft_engine::EngineError;
|
||||
use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan};
|
||||
|
||||
use crate::background::{self, Writer};
|
||||
use crate::dialogs::svg_options;
|
||||
use crate::state::Dialog;
|
||||
use crate::{FilePick, Services, VectorcraftApp, dialogs};
|
||||
|
|
@ -101,7 +102,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> Resu
|
|||
}
|
||||
|
||||
/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`.
|
||||
fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dl) = services.download.as_mut() {
|
||||
dl(&fileio::file_name(path), bytes);
|
||||
return Ok(());
|
||||
|
|
@ -161,10 +162,10 @@ fn with_save_extension(path: &str, f: &Format) -> String {
|
|||
|
||||
/// Write every file of an export (one per artboard, linked images) for the destination `path`.
|
||||
/// → the export's own files (without the linked images).
|
||||
fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<Vec<String>, String> {
|
||||
fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<Vec<String>, String> {
|
||||
let named = enc.named(doc, path);
|
||||
for (p, bytes) in &named {
|
||||
write_to(&mut app.services, p, bytes)?;
|
||||
write(p, bytes)?;
|
||||
}
|
||||
Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect())
|
||||
}
|
||||
|
|
@ -207,7 +208,8 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
|
|||
o.insert("path".into(), json!(with_save_extension(&picked, first.format)));
|
||||
}
|
||||
let chosen = plan(app, mode, &q)?;
|
||||
if ask && !chosen.format.options.is_empty() {
|
||||
// Native saves don't stop to ask: Use Compression decides, and options can be passed.
|
||||
if ask && !chosen.format.options.is_empty() && !matches!(chosen.format.id, "vectorcraft" | "template") {
|
||||
let path = chosen.path.clone().unwrap_or_default();
|
||||
return ask_format_options(app, mode, chosen.format, &path);
|
||||
}
|
||||
|
|
@ -273,16 +275,43 @@ fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) {
|
|||
}
|
||||
|
||||
/// Write a planned save through the services and report it in the status bar.
|
||||
/// With Background Save on, the file is encoded and written from a snapshot off the UI thread
|
||||
/// ([`background`]): this returns `{path, background: true, status}` at once, and the document
|
||||
/// takes the file (and counts as saved, as of the snapshot) once it is written.
|
||||
fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String> {
|
||||
let retargets = plan.retargets();
|
||||
let services = &mut app.services;
|
||||
let r = fileio::save_with(&mut app.session, plan, |path, bytes| write_to(services, path, bytes).map_err(EngineError::Other))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let path = r["path"].as_str().unwrap_or_default().to_string();
|
||||
if retargets {
|
||||
note_recent(app, &path);
|
||||
let uid = app.session.active().ok_or("no document")?.uid;
|
||||
// Saves of one document finish in order.
|
||||
if app.background.busy_with(uid) {
|
||||
background::wait_all(app);
|
||||
}
|
||||
let job = fileio::save_job(&mut app.session, plan).map_err(|e| e.to_string())?;
|
||||
let (retargets, target) = (job.plan().retargets(), job.plan().path.clone());
|
||||
let label = match &target {
|
||||
Some(path) => format!("Saving {}", fileio::file_name(path)),
|
||||
None => "Saving".into(),
|
||||
};
|
||||
// Without a path the bytes come back to the caller: nothing to do in the background.
|
||||
let enabled = app.session.prefs.background_save && target.is_some();
|
||||
// The work writes the file; the copy makes it the document's own afterwards.
|
||||
let done = job.clone();
|
||||
let work = move |write: Writer| job.write(|p, b| write(p, b).map_err(EngineError::Other)).map_err(|e| e.to_string());
|
||||
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
|
||||
let r = r?;
|
||||
// The document may have closed meanwhile: the file is written all the same.
|
||||
if let Some(st) = app.session.document_mut(uid) {
|
||||
done.finish(st);
|
||||
}
|
||||
let path = r["path"].as_str().unwrap_or_default().to_string();
|
||||
if retargets {
|
||||
note_recent(app, &path);
|
||||
}
|
||||
report_saved(app, &path, &r);
|
||||
Ok(r)
|
||||
};
|
||||
let mut r = background::run(app, enabled, label, Some(uid), work, then)?;
|
||||
if let Some(path) = target.filter(|_| r.get("path").is_none()) {
|
||||
r["path"] = json!(path);
|
||||
}
|
||||
report_saved(app, &path, &r);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
|
|
@ -334,21 +363,36 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
|||
Some(z) if f.id == "svg" && z.id == "svgz" => z,
|
||||
_ => f,
|
||||
};
|
||||
let enc = fileio::encode_all(&doc, f.id, params).map_err(|e| e.to_string())?;
|
||||
let files = write_encoded(app, &doc, &path, &enc)?;
|
||||
let what = match files.as_slice() {
|
||||
[one] => one.clone(),
|
||||
_ => format!("{} files", files.len()),
|
||||
// Background Export: encoded and written from this snapshot while editing goes on.
|
||||
let label = format!("Exporting {}", fileio::file_name(&path));
|
||||
let (params, target) = (params.clone(), path.clone());
|
||||
let work = move |write: Writer| {
|
||||
let enc = fileio::encode_all(&doc, f.id, ¶ms).map_err(|e| e.to_string())?;
|
||||
let files = write_encoded(write, &doc, &path, &enc)?;
|
||||
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings });
|
||||
if files.len() > 1 {
|
||||
out["files"] = json!(files);
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
app.status(match enc.warnings.first() {
|
||||
Some(first) => format!("Exported {what} with {} note(s): {first}", enc.warnings.len()),
|
||||
None => format!("Exported {what}"),
|
||||
});
|
||||
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings });
|
||||
if files.len() > 1 {
|
||||
out["files"] = json!(files);
|
||||
let enabled = app.session.prefs.background_export;
|
||||
let mut r = background::run(app, enabled, label, None, work, |app, r| {
|
||||
let r = r?;
|
||||
let what = match r["files"].as_array() {
|
||||
Some(files) => format!("{} files", files.len()),
|
||||
None => r["path"].as_str().unwrap_or_default().to_string(),
|
||||
};
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
app.status(match warnings.first() {
|
||||
Some(first) => format!("Exported {what} with {} note(s): {first}", warnings.len()),
|
||||
None => format!("Exported {what}"),
|
||||
});
|
||||
Ok(r)
|
||||
})?;
|
||||
if r.get("path").is_none() {
|
||||
r["path"] = json!(target);
|
||||
}
|
||||
Ok(out)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
//! shortcuts, the ⌘K palette and the control channel ([`control`]).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod background;
|
||||
mod brand;
|
||||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
|
|
@ -37,6 +38,8 @@ pub mod workspaces;
|
|||
#[cfg(test)]
|
||||
mod tests_aisave;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_clipboard;
|
||||
#[cfg(test)]
|
||||
mod tests_docsetup;
|
||||
|
|
@ -135,6 +138,9 @@ pub struct Services {
|
|||
pub system_clipboard: Option<Box<dyn SystemClipboard>>,
|
||||
/// File → Show in Folder: select a file in the system file manager (desktop).
|
||||
pub reveal: Option<RevealFn>,
|
||||
/// Write a file from any thread (desktop): lets Background Save and Export write off the UI
|
||||
/// thread ([`background`]). Without it they run at once.
|
||||
pub write_shared: Option<background::SharedWriteFn>,
|
||||
}
|
||||
|
||||
/// Cached canvas raster.
|
||||
|
|
@ -220,6 +226,8 @@ pub struct VectorcraftApp {
|
|||
system_paste_at: f64,
|
||||
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
|
||||
pub(crate) paste_chord: shortcuts::PasteChord,
|
||||
/// Saves and exports running in the background (Preferences → File Handling).
|
||||
pub background: background::Background,
|
||||
}
|
||||
|
||||
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`].
|
||||
|
|
@ -266,6 +274,7 @@ impl VectorcraftApp {
|
|||
system_paste: false,
|
||||
system_paste_at: f64::NEG_INFINITY,
|
||||
paste_chord: Default::default(),
|
||||
background: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -530,11 +539,20 @@ impl VectorcraftApp {
|
|||
self.system_paste_at = now;
|
||||
self.system_paste = self.session.clipboard.is_empty() && self.session.active().is_some() && self.system_clipboard_pasteable();
|
||||
}
|
||||
// The window's close button (or the system quitting the app) asks about unsaved documents.
|
||||
if ctx.input(|i| i.viewport().close_requested()) && unsaved::any_dirty(self) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
if let Err(e) = unsaved::close_all(self, "quit") {
|
||||
self.status(e);
|
||||
background::poll(self);
|
||||
if !self.background.jobs.is_empty() {
|
||||
// Keep the status bar's progress moving and pick the result up when it arrives.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// The window's close button (or the system quitting the app) asks about unsaved documents,
|
||||
// once the saves running in the background are done.
|
||||
if ctx.input(|i| i.viewport().close_requested()) {
|
||||
background::wait_all(self);
|
||||
if unsaved::any_dirty(self) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
if let Err(e) = unsaved::close_all(self, "quit") {
|
||||
self.status(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
shortcut_editor::sync(&self.ui);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
//! Background Save and Background Export: the file is written from a snapshot off the UI thread,
|
||||
//! and the document counts as saved as of that snapshot.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, Node};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::{Services, VectorcraftApp, background, io};
|
||||
|
||||
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Holds the background writer until opened.
|
||||
#[derive(Clone, Default)]
|
||||
struct Gate(Arc<AtomicBool>);
|
||||
|
||||
impl Gate {
|
||||
fn open(&self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
fn wait(&self) {
|
||||
while !self.0.load(Ordering::SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An app whose thread-safe writer records files once `gate` opens.
|
||||
fn app(gate: Gate) -> (VectorcraftApp, Files) {
|
||||
let files = Files::default();
|
||||
let f = files.clone();
|
||||
let services = Services {
|
||||
write: Some(Box::new(|_: &str, _: &[u8]| Err("the UI thread must not write here".into()))),
|
||||
write_shared: Some(Arc::new(move |p: &str, b: &[u8]| {
|
||||
gate.wait();
|
||||
f.lock().unwrap().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(VectorcraftApp::new(Session::new(), services), files)
|
||||
}
|
||||
|
||||
/// A document of `n` paths.
|
||||
fn paths(n: usize) -> Document {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let layer = d.layers[0].id;
|
||||
for i in 0..n {
|
||||
let id = d.alloc_id();
|
||||
let (x, y) = ((i % 250) as f64 * 4.0, (i / 250) as f64 * 4.0);
|
||||
d.insert(Some(layer), i, Node::path(id, shapes::ellipse(Rect::new(x, y, x + 3.0, y + 3.0)), Appearance::default_art())).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn written(files: &Files) -> Vec<String> {
|
||||
files.lock().unwrap().iter().map(|(p, _)| p.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_50k_path_save_returns_at_once_and_the_file_appears_later() {
|
||||
let gate = Gate::default();
|
||||
let (mut app, files) = app(gate.clone());
|
||||
app.session.add_document(paths(50_000), None);
|
||||
let r = app.run("file.save", json!({"path": "/docs/big.vectorcraft"})).unwrap();
|
||||
assert_eq!((r["background"].as_bool(), r["path"].as_str()), (Some(true), Some("/docs/big.vectorcraft")));
|
||||
assert!(written(&files).is_empty(), "nothing written yet");
|
||||
assert_eq!(app.background.jobs.len(), 1);
|
||||
assert!(app.ui.status.starts_with("Saving big.vectorcraft"));
|
||||
// The document is saved once the file is written.
|
||||
assert!(app.session.doc().unwrap().is_dirty() && app.session.doc().unwrap().path.is_none());
|
||||
gate.open();
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(written(&files), ["/docs/big.vectorcraft"]);
|
||||
let st = app.session.doc().unwrap();
|
||||
assert!(!st.is_dirty());
|
||||
assert_eq!(st.path.as_deref(), Some("/docs/big.vectorcraft"));
|
||||
assert_eq!(app.ui.status, "Saved /docs/big.vectorcraft");
|
||||
let bytes = files.lock().unwrap()[0].1.clone();
|
||||
assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), 50_001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dirty_state_follows_the_snapshot() {
|
||||
let gate = Gate::default();
|
||||
let (mut app, files) = app(gate.clone());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.run("file.save", json!({"path": "/docs/a.vectorcraft"})).unwrap();
|
||||
// Edited while the file is written: still modified afterwards.
|
||||
app.run("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
gate.open();
|
||||
background::wait_all(&mut app);
|
||||
assert!(app.session.doc().unwrap().is_dirty(), "the edit came after the snapshot");
|
||||
let saved = vectorcraft_format::load(&files.lock().unwrap()[0].1).unwrap();
|
||||
assert_eq!(saved.node_count(), 2, "the file holds the snapshot");
|
||||
// Undoing back to the snapshot is clean again.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(!app.session.doc().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_run_in_the_background_and_the_preference_turns_both_off() {
|
||||
let gate = Gate::default();
|
||||
let (mut app, files) = app(gate.clone());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let r = io::export(&mut app, Some("svg"), Some("/out/a.svg".into()), &json!({})).unwrap();
|
||||
assert_eq!((r["background"].as_bool(), r["path"].as_str()), (Some(true), Some("/out/a.svg")));
|
||||
gate.open();
|
||||
background::wait_all(&mut app);
|
||||
assert_eq!(written(&files), ["/out/a.svg"]);
|
||||
assert_eq!(app.ui.status, "Exported /out/a.svg");
|
||||
// Preferences off: at once, through the ordinary writer (which this app refuses).
|
||||
app.session.execute("prefs.set", &json!({"values": {"backgroundSave": false, "backgroundExport": false}})).unwrap();
|
||||
assert!(io::export(&mut app, Some("svg"), Some("/out/b.svg".into()), &json!({})).is_err());
|
||||
assert!(app.run("file.save", json!({"path": "/docs/x.vectorcraft"})).is_err());
|
||||
assert!(app.background.jobs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_waits_for_background_saves() {
|
||||
let gate = Gate::default();
|
||||
let (mut app, files) = app(gate.clone());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("file.save", json!({"path": "/docs/q.vectorcraft"})).unwrap();
|
||||
let opener = gate.clone();
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
opener.open();
|
||||
});
|
||||
// Without waiting, the document would still count as modified and Quit would ask about it.
|
||||
let r: Value = app.run("app.quit", json!({})).unwrap();
|
||||
release.join().unwrap();
|
||||
assert!(r.is_null(), "{r}");
|
||||
assert_eq!(app.ui.status, "quit");
|
||||
assert_eq!(written(&files), ["/docs/q.vectorcraft"]);
|
||||
}
|
||||
|
||||
/// The status bar's texts in one headless frame.
|
||||
fn status_bar_texts(app: &mut VectorcraftApp) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::chrome::status_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_bar_shows_progress() {
|
||||
let gate = Gate::default();
|
||||
let (mut app, _) = app(gate.clone());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("file.save", json!({"path": "/docs/p.vectorcraft"})).unwrap();
|
||||
io::export(&mut app, Some("svg"), Some("/out/p.svg".into()), &json!({})).unwrap();
|
||||
let busy = status_bar_texts(&mut app);
|
||||
assert!(busy.iter().any(|t| t == "Saving p.vectorcraft… (+1)"), "{busy:?}");
|
||||
gate.open();
|
||||
background::wait_all(&mut app);
|
||||
let done = status_bar_texts(&mut app);
|
||||
assert!(done.iter().any(|t| t == "Exported /out/p.svg") && !done.iter().any(|t| t.contains('…')), "{done:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_options_fail_before_going_to_the_background() {
|
||||
let (mut app, files) = app(Gate::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
for bad in [json!({"path": "/docs/v.vectorcraft", "version": 9}), json!({"path": "/docs/v.vectorcraft", "version": 1, "compress": true})] {
|
||||
assert!(app.run("file.save", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert!(app.background.jobs.is_empty() && written(&files).is_empty());
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ pub const KIND: &str = "saveChanges";
|
|||
|
||||
/// File → Close (or a tab's ×) for document `i`.
|
||||
pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||||
// A save still running decides whether there is anything left to save.
|
||||
crate::background::wait_all(app);
|
||||
let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty();
|
||||
if dirty { ask(app, i, "close") } else { close_now(app, i) }
|
||||
}
|
||||
|
|
@ -25,6 +27,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
|||
/// File → Close All (`then` = `closeAll`) or Quit (`quit`): ask about the next modified document,
|
||||
/// or finish once none is left.
|
||||
pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result<Value, String> {
|
||||
crate::background::wait_all(app);
|
||||
if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) {
|
||||
return ask(app, i, then);
|
||||
}
|
||||
|
|
@ -71,7 +74,14 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
|||
let i = i.ok_or("no such document")?;
|
||||
if !d.bool("discard") {
|
||||
app.session.set_active(i);
|
||||
io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
|
||||
let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
|
||||
// Closing needs the file written: wait for a background save, and stop if it failed.
|
||||
if r["background"] == true {
|
||||
crate::background::wait_all(app);
|
||||
if let Some(d) = app.session.documents().get(i).filter(|d| d.is_dirty()) {
|
||||
return Err(format!("{}: not saved, so not closed", d.title()));
|
||||
}
|
||||
}
|
||||
}
|
||||
close_now(app, i)?;
|
||||
match d.str("then").as_str() {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
|
||||
| `document.inspect` | | layer tree, selection, history, paint defaults |
|
||||
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf |
|
||||
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf, background saves and exports still running |
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
|
||||
| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning |
|
||||
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` |
|
||||
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written |
|
||||
|
||||
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
|
||||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,13 @@ and reflected or repeated gradients (`spreadMethod`) are expanded into stops. `<
|
|||
found in the SVG's folder) stay linked images (see Linked images); SVG files and `data:` SVGs become art; a file that
|
||||
can't be read becomes a placeholder in its box, named in `warnings` and `missingLinks`.
|
||||
|
||||
Native files: `run_command document.save {compress: true}` writes a gzip-compressed `.vectorcraft` (the
|
||||
`useCompression` preference makes that the default for saves; compressed files open like any other, told apart by
|
||||
their content). `version: 2` or `1` writes a file older VectorCraft versions open (format name `drawcraft`, never
|
||||
compressed), and `preview: true` embeds a PNG of the first artboard, at most 256 px on its longer side. A save writes
|
||||
a temporary file and renames it over the old one, so a failed save never damages the file; only the images the
|
||||
document uses are written, and document keys written by a newer version are kept.
|
||||
|
||||
## Resources
|
||||
|
||||
| URI | Content |
|
||||
|
|
|
|||
Loading…
Reference in New Issue