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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 12:27:02 +07:00
1 parent 7aee3bd487
commit 65cdc45ed9
22 files changed
+1103 -85

No files matched your search

Generated
+1
View File
@@ -4192,6 +4192,7 @@ dependencies = [
name = "vectorcraft-format"
version = "0.1.1"
dependencies = [
"flate2",
"proptest",
"serde",
"serde_json",
+3 -2
View File
@@ -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.
+12
View File
@@ -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))));
+75
View File
@@ -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());
}
}
+9 -12
View File
@@ -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();
+3 -3
View File
@@ -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)),
+10 -3
View File
@@ -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<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"))]
+77
View File
@@ -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<bool>,
version: Option<u32>,
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<Vec<u8>> {
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<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
}
+17 -7
View File
@@ -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<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())),
@@ -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, &params);
}
let params = super::pdf::expand_preset(s, cmd, &params)?;
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<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 +356,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 +383,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)
),
+2
View File
@@ -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> {
+6
View File
@@ -394,6 +394,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 +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;
+162
View File
@@ -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");
}
+57 -2
View File
@@ -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))?;
}
}
+1
View File
@@ -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 }
+96
View File
@@ -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);
}
}
+49
View File
@@ -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));
}
}
+256 -43
View File
@@ -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,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<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 {
/// 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 +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<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> {
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::<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 +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<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 +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();
+183
View File
@@ -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), &current, 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)));
}
+44 -6
View File
@@ -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)?;
}
}
}
+31 -6
View File
@@ -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, "$")));
}
+2 -1
View File
@@ -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);
}
+7
View File
@@ -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 |