From 65cdc45ed948efe2c3ae75272ae205d4d344e53e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 10:30:01 +0700 Subject: [PATCH] M4.35: native files compress, save atomically, drop unused images, keep newer keys and save down to v1 - Format v3: optional gzip (Preferences > File Handling > Use Compression, or document.save {compress}); compressed files open by their magic bytes, legacy ones too. - Every file write goes to a temporary file renamed over the target (engine writes and the desktop app's writer), so a failed save keeps the old file. - Only the images the document uses are written. - Document keys from newer versions are kept (Document.extra, flattened). - Assigned colour profiles are a real field (Document.color_profiles), migrated from unknown.colorProfiles on load. - document.save {version: 1|2} writes files older versions open; {preview} embeds a PNG of the first artboard of at most 256 px. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/vectorcraft/src/main.rs | 5 +- crates/doc/src/lib.rs | 12 + crates/doc/src/profiles.rs | 75 +++++++ crates/engine/src/cmd/colormgmt.rs | 21 +- crates/engine/src/cmd/fileio/encode.rs | 6 +- crates/engine/src/cmd/fileio/mod.rs | 13 +- crates/engine/src/cmd/fileio/native.rs | 77 +++++++ crates/engine/src/cmd/fileio/save.rs | 24 +- crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/lib.rs | 6 + crates/engine/src/tests_nativefile.rs | 162 ++++++++++++++ crates/engine/tests/import_fuzz.rs | 59 ++++- crates/format/Cargo.toml | 1 + crates/format/src/atomic.rs | 96 ++++++++ crates/format/src/legacy.rs | 49 ++++ crates/format/src/lib.rs | 299 +++++++++++++++++++++---- crates/format/tests/native_v3.rs | 183 +++++++++++++++ crates/format/tests/prop_format.rs | 50 ++++- crates/testkit/src/invariants.rs | 37 ++- crates/ui-egui/src/io.rs | 3 +- docs/mcp.md | 7 + 22 files changed, 1103 insertions(+), 85 deletions(-) create mode 100644 crates/doc/src/profiles.rs create mode 100644 crates/engine/src/cmd/fileio/native.rs create mode 100644 crates/engine/src/tests_nativefile.rs create mode 100644 crates/format/src/atomic.rs create mode 100644 crates/format/src/legacy.rs create mode 100644 crates/format/tests/native_v3.rs diff --git a/Cargo.lock b/Cargo.lock index 802870b7..82ab5494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4192,6 +4192,7 @@ dependencies = [ name = "vectorcraft-format" version = "0.1.1" dependencies = [ + "flate2", "proptest", "serde", "serde_json", diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index d0a866cf..a01f4bcb 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -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); } } } @@ -157,7 +158,7 @@ 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(|p: &str, b: &[u8]| fileio::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), // 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. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3607c7c1..b3125304 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, + /// 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, } 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)))); diff --git a/crates/doc/src/profiles.rs b/crates/doc/src/profiles.rs new file mode 100644 index 00000000..aba099bf --- /dev/null +++ b/crates/doc/src/profiles.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cmyk: Option, +} + +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()); + } +} diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 2ffabbb4..c6e0b375 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -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 { vec![ @@ -188,9 +190,8 @@ fn load_profile(_: &mut Session, _: &Value) -> Result { /// The document's assigned profiles (`None` = working space). pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option, Option) { - 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| 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 { @@ -213,13 +214,9 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { }; 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(); diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 311c6062..a94e65dc 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -250,7 +250,7 @@ fn boards(r: std::result::Result) -> Result { r.map_err(|e| bad(C, e)) } -fn options(f: &Format, p: &Value) -> Result { +pub(super) fn options(f: &Format, p: &Value) -> Result { if !p.is_object() { return Ok(T::default()); } @@ -303,12 +303,12 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { 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)), diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 2f75c915..d4438a8a 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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,14 @@ 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 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 +326,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 +398,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 +494,8 @@ pub(crate) fn read_file(path: &str) -> Result> { #[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"))] diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs new file mode 100644 index 00000000..24c78908 --- /dev/null +++ b/crates/engine/src/cmd/fileio/native.rs @@ -0,0 +1,77 @@ +//! 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, + version: Option, + preview: bool, +} + +/// The `.vectorcraft` file of `doc` with the options in `p`. +pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { + 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); + if o.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>> { + 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 +} diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index e8e20fe8..17ed6cf4 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -12,7 +12,9 @@ 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 { 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())), @@ -247,7 +250,11 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[ // 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 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); + } let params = super::pdf::expand_preset(s, cmd, ¶ms)?; let mut enc = if plan.format.id == "ai" { // A PDF of every artboard with the PDF options, always carrying the native document. @@ -328,10 +335,13 @@ fn format_options(s: &mut Session, p: &Value) -> Result { 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 +356,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), 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 +383,7 @@ pub(super) fn specs() -> Vec { "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: \" 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: \" template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}", has_doc, |s, p| save(s, SaveMode::Template, p) ), diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index d546717e..5919afd9 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -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> { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5678c79f..014e0dcf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -394,6 +394,9 @@ pub struct Prefs { pub templates_folder: String, /// Open older native files as " [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 +525,7 @@ impl Default for Prefs { pdf_presets: vec![], templates_folder: String::new(), append_converted: true, + use_compression: false, } } } @@ -1053,6 +1057,8 @@ mod tests_maskview; #[cfg(test)] mod tests_menucmds; #[cfg(test)] +mod tests_nativefile; +#[cfg(test)] mod tests_newart; #[cfg(test)] mod tests_newdoc; diff --git a/crates/engine/src/tests_nativefile.rs b/crates/engine/src/tests_nativefile.rs new file mode 100644 index 00000000..4561423c --- /dev/null +++ b/crates/engine/src/tests_nativefile.rs @@ -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 { + 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 { + 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 = 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"); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index e855e1a3..ca5816fa 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -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] { + use vectorcraft_format::{SaveOptions, save_with}; + static SAMPLES: std::sync::OnceLock>> = 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 { + 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::()), 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))?; + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index d0fd4e90..0dedea5f 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -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 } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs new file mode 100644 index 00000000..db1d53d2 --- /dev/null +++ b/crates/format/src/atomic.rs @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/format/src/legacy.rs b/crates/format/src/legacy.rs new file mode 100644 index 00000000..036e2612 --- /dev/null +++ b/crates/format/src/legacy.rs @@ -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)); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 7bf8a7bc..8ae37a6b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -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": "" }, //! "document": { …vectorcraft_doc::Document… }, //! "images": { "": { "mime": "image/png", "data": "" } } } //! ``` -//! 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,46 @@ 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, } -/// 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>, +} + +impl Default for SaveOptions { + fn default() -> Self { + Self { pretty: false, compress: false, version: VERSION, preview: None } + } +} + +impl SaveOptions { + /// 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 { - 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 +136,152 @@ 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 { - 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, FormatError> { + if !(MIN_VERSION..=VERSION).contains(&o.version) { + return Err(FormatError::BadVersion(o.version)); + } + if o.compress && o.version < COMPRESSED_SINCE { + return Err(FormatError::CompressedTooOld(o.version)); + } + 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::() + 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, 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, + 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 { + self.out.extend_from_slice(if self.pretty { b"\n}" } else { b"}" }); + self.out + } +} + +/// The string values of every `"": "…"` 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 { + 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::().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, 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 { @@ -121,7 +306,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 +328,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> { + #[derive(Deserialize)] + struct Head { + preview: Option, + } + 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 +432,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 +442,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(); diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs new file mode 100644 index 00000000..3e4fc2ee --- /dev/null +++ b/crates/format/tests/native_v3.rs @@ -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 { + 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""))); + assert!(load(&packed[..packed.len() / 2]).is_err(), "a truncated file is an error"); +} + +fn flate2_unpack(bytes: &[u8]) -> Vec { + 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))); +} diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 016ab98b..ef41d467 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -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::(), 0..2000), key in "[a-z]{1,8}") { + fn images_roundtrip(data in prop::collection::vec(any::(), 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::()), 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)?; + } } } diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs index 452c9610..82b90b89 100644 --- a/crates/testkit/src/invariants.rs +++ b/crates/testkit/src/invariants.rs @@ -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, "$"))); } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 6e758280..257721fa 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -207,7 +207,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); } diff --git a/docs/mcp.md b/docs/mcp.md index f9148568..be6fef95 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 |