mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
7aee3bd487
commit
65cdc45ed9
22 files changed
+1103
-85
No files matched your search
@@ -13,6 +13,7 @@ vectorcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Safe saving: a file is written to a temporary file beside it, then renamed over it, so a failed
|
||||
//! or interrupted write never leaves a damaged file behind (the old one stays as it was).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Write `bytes` to `path` atomically (see the module docs).
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, |f| f.write_all(bytes))
|
||||
}
|
||||
|
||||
/// Write `path` atomically with `fill`, which writes the content into the temporary file. When
|
||||
/// anything fails (`fill`, flushing, the rename) the temporary file is removed and `path` is left
|
||||
/// untouched.
|
||||
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let target = resolve_link(path)?;
|
||||
let tmp = temp_path(&target)?;
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
let filled = fill(&mut f).and_then(|()| f.sync_all());
|
||||
drop(f);
|
||||
filled.and_then(|()| keep_permissions(&target, &tmp)).and_then(|()| std::fs::rename(&tmp, &target)).inspect_err(|_| {
|
||||
// Best effort: the temporary file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
}
|
||||
|
||||
/// A symbolic link keeps pointing at its file: write the file it names.
|
||||
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path),
|
||||
_ => Ok(path.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh hidden name beside `target` (the same folder, so the rename stays on one volume).
|
||||
fn temp_path(target: &Path) -> io::Result<PathBuf> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", target.display())))?;
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(target.with_file_name(format!(".{}.{}-{n}.tmp", name.to_string_lossy(), std::process::id())))
|
||||
}
|
||||
|
||||
/// The replacement keeps the replaced file's permissions (Unix; Windows files have none to keep
|
||||
/// beyond read-only, which makes the save fail as writing in place would).
|
||||
#[cfg(unix)]
|
||||
fn keep_permissions(target: &Path, tmp: &Path) -> io::Result<()> {
|
||||
match std::fs::metadata(target) {
|
||||
Ok(m) => std::fs::set_permissions(tmp, m.permissions()),
|
||||
// A new file: the default permissions.
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The other files in `d` besides `keep`.
|
||||
fn leftovers(d: &Path, keep: &str) -> Vec<String> {
|
||||
std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).filter(|n| n != keep).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_original_file_survives_a_failed_write() {
|
||||
let d = dir("fail");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let r = write_atomic_with(&path, |f| {
|
||||
f.write_all(b"half a fi")?;
|
||||
Err(io::Error::other("disk full"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
assert!(leftovers(&d, "doc.vectorcraft").is_empty(), "the temporary file is removed");
|
||||
write_atomic(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert!(leftovers(&d, "doc.vectorcraft").is_empty());
|
||||
// A folder that doesn't exist: an error, nothing written.
|
||||
assert!(write_atomic(&d.join("missing").join("x.vectorcraft"), b"x").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Writing for older readers: format v1's anchor form.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Rewrite every anchor in a serialized document (`anchors` arrays of path data) the way format v1
|
||||
/// wrote it: `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`, every member present (v1 readers need
|
||||
/// them all; v2 writes `{p: [x, y], in?, out?, kind?}`).
|
||||
pub(crate) fn anchors_to_v1(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o.iter_mut() {
|
||||
match x {
|
||||
Value::Array(anchors) if k == "anchors" => anchors.iter_mut().for_each(anchor_to_v1),
|
||||
_ => anchors_to_v1(x),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(anchors_to_v1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_to_v1(a: &mut Value) {
|
||||
let point = |v: &Value| match v.as_array().map(Vec::as_slice) {
|
||||
Some([x, y]) => Some(json!({ "x": x, "y": y })),
|
||||
_ => None,
|
||||
};
|
||||
let Some(o) = a.as_object() else { return };
|
||||
// Not a v2 anchor (already a map, or something else called `anchors`): leave it alone.
|
||||
let Some(p) = o.get("p").and_then(point) else { return };
|
||||
let handle = |k: &str| o.get(k).and_then(point).unwrap_or_else(|| p.clone());
|
||||
let kind = o.get("kind").cloned().unwrap_or_else(|| json!("Corner"));
|
||||
*a = json!({ "p": p, "in": handle("in"), "out": handle("out"), "kind": kind });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn anchors_are_written_the_v1_way() {
|
||||
let mut v = json!({"layers": [{"path": {"subpaths": [{"anchors": [{"p": [1.0, 2.0]}, {"p": [3.0, 4.0], "out": [5.0, 4.0], "kind": "Smooth"}], "closed": true}]}}], "effect": {"anchors": true}});
|
||||
anchors_to_v1(&mut v);
|
||||
let a = &v["layers"][0]["path"]["subpaths"][0]["anchors"];
|
||||
assert_eq!(a[0], json!({"p": {"x": 1.0, "y": 2.0}, "in": {"x": 1.0, "y": 2.0}, "out": {"x": 1.0, "y": 2.0}, "kind": "Corner"}));
|
||||
assert_eq!(a[1], json!({"p": {"x": 3.0, "y": 4.0}, "in": {"x": 3.0, "y": 4.0}, "out": {"x": 5.0, "y": 4.0}, "kind": "Smooth"}));
|
||||
assert_eq!(v["effect"]["anchors"], json!(true));
|
||||
}
|
||||
}
|
||||
+256
-43
@@ -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();
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! `.vectorcraft` v3: compression, pruning unused images, keys from newer versions, colour
|
||||
//! profiles as a document field, saving for older versions and the embedded preview.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorProfiles, Document, ImageBlob, ImageObject, Node, NodeKind, OpacityMask, Symbol};
|
||||
use vectorcraft_format::{FormatError, SaveOptions, VERSION, is_compressed, load, preview, save, save_with, sniff};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_testkit::fixtures;
|
||||
use vectorcraft_testkit::invariants::{check_native_roundtrip, doc_json, json_approx_eq};
|
||||
|
||||
fn rich_doc() -> Document {
|
||||
(*fixtures::rich_session().doc().unwrap().doc).clone()
|
||||
}
|
||||
|
||||
fn json_of(bytes: &[u8]) -> Value {
|
||||
serde_json::from_slice(bytes).unwrap()
|
||||
}
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
gz.write_all(bytes).unwrap();
|
||||
gz.finish().unwrap()
|
||||
}
|
||||
|
||||
fn blob(bytes: &[u8]) -> ImageBlob {
|
||||
ImageBlob::new("image/png", bytes.to_vec())
|
||||
}
|
||||
|
||||
fn image_node(d: &mut Document, key: &str) -> Node {
|
||||
let id = d.alloc_id();
|
||||
Node::new(id, NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None }))
|
||||
}
|
||||
|
||||
fn opts(version: u32, compress: bool) -> SaveOptions {
|
||||
SaveOptions { version, compress, ..SaveOptions::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_files_load() {
|
||||
let d = rich_doc();
|
||||
let packed = save_with(&d, &SaveOptions { compress: true, pretty: true, ..SaveOptions::default() }).unwrap();
|
||||
assert!(is_compressed(&packed) && sniff(&packed));
|
||||
let plain = save(&d, false);
|
||||
assert!(packed.len() * 3 < plain.len(), "compressed {} vs {} bytes", packed.len(), plain.len());
|
||||
assert_eq!(doc_json(&load(&packed).unwrap()), doc_json(&load(&plain).unwrap()));
|
||||
// Compressed files are compact even when pretty was asked for.
|
||||
assert!(!flate2_unpack(&packed).contains(&b'\n'));
|
||||
// A legacy (v2, pre-rename) file compressed by hand opens too.
|
||||
let text = String::from_utf8(plain.clone()).unwrap();
|
||||
let legacy = text.replacen("\"format\":\"vectorcraft\",\"version\":3", "\"format\":\"drawcraft\",\"version\":2", 1);
|
||||
assert_ne!(legacy, text);
|
||||
let legacy = gzip(legacy.as_bytes());
|
||||
assert!(sniff(&legacy));
|
||||
assert_eq!(doc_json(&load(&legacy).unwrap()), doc_json(&load(&plain).unwrap()));
|
||||
// Other gzip data (an SVGZ) is not a native file.
|
||||
assert!(!sniff(&gzip(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>")));
|
||||
assert!(load(&packed[..packed.len() / 2]).is_err(), "a truncated file is an error");
|
||||
}
|
||||
|
||||
fn flate2_unpack(bytes: &[u8]) -> Vec<u8> {
|
||||
use std::io::Read as _;
|
||||
let mut out = vec![];
|
||||
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_images_are_pruned() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
for (k, b) in [("used", &[1u8, 2][..]), ("unused", &[3, 4]), ("in symbol", &[5]), ("in brush", &[6]), ("in mask", &[7])] {
|
||||
d.images.insert(k.into(), blob(b));
|
||||
}
|
||||
let mut n = image_node(&mut d, "used");
|
||||
n.mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false)));
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
let art = image_node(&mut d, "in symbol");
|
||||
d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
// Image objects inside foreign data (a brush library kept as JSON) keep theirs too.
|
||||
d.unknown.insert("brushes".into(), json!([{"art": {"type": "image", "key": "in brush"}}]));
|
||||
let back = load(&save(&d, true)).unwrap();
|
||||
let mut keys: Vec<&str> = back.images.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys, ["in brush", "in mask", "in symbol", "used"]);
|
||||
assert_eq!(back.images["used"].bytes.as_slice(), &[1, 2]);
|
||||
let v = json_of(&save(&d, false));
|
||||
assert!(v["images"].get("unused").is_none() && v["document"]["images"].as_object().is_some_and(|m| m.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_newer_versions_round_trip() {
|
||||
let mut v = json_of(&save(&rich_doc(), false));
|
||||
v["document"]["futureFeature"] = json!({"a": [1, 2, 3], "b": "x"});
|
||||
v["document"]["futureFlag"] = json!(true);
|
||||
let d = load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
assert_eq!(d.extra["futureFeature"]["a"], json!([1, 2, 3]));
|
||||
assert!(!d.unknown.contains_key("futureFeature"), "kept apart from `unknown`");
|
||||
for pretty in [false, true] {
|
||||
let again = json_of(&save(&d, pretty));
|
||||
assert_eq!(again["document"]["futureFeature"], v["document"]["futureFeature"]);
|
||||
assert_eq!(again["document"]["futureFlag"], json!(true));
|
||||
}
|
||||
check_native_roundtrip(&d).unwrap();
|
||||
}
|
||||
|
||||
/// A document as the first version wrote it (format name `drawcraft`, anchors as maps).
|
||||
const V1_FILE: &str = r#"{"format":"drawcraft","version":1,"generator":"DrawCraft 0.1.0","document":{"version":1,"title":"Old","artboards":[{"id":1,"name":"Artboard 1","rect":{"x0":0.0,"y0":0.0,"x1":100.0,"y1":80.0}}],
|
||||
"layers":[{"id":1,"name":"Layer 1","visible":true,"locked":false,"opacity":1.0,"kind":{"type":"layer","children":[
|
||||
{"id":2,"visible":true,"locked":false,"opacity":1.0,"kind":{"type":"path","path":{"subpaths":[{"anchors":[
|
||||
{"p":{"x":10.0,"y":10.0},"in":{"x":10.0,"y":10.0},"out":{"x":30.0,"y":0.0},"kind":"Smooth"},
|
||||
{"p":{"x":50.0,"y":40.0},"in":{"x":50.0,"y":40.0},"out":{"x":50.0,"y":40.0},"kind":"Corner"}],"closed":false}]},"rule":"NonZero","clipping":false,"guide":false}}],"color":{"Preset":0}}}],
|
||||
"next_id":3,"unknown":{"colorProfiles":{"rgb":"sRGB IEC61966-2.1","cmyk":null}}},"images":{}}"#;
|
||||
|
||||
#[test]
|
||||
fn version_1_files_load() {
|
||||
let d = load(V1_FILE.as_bytes()).unwrap();
|
||||
assert_eq!(d.title, "Old");
|
||||
let NodeKind::Path { path, .. } = &d.layers[0].children().unwrap()[0].kind else { panic!("not a path") };
|
||||
let a = &path.subpaths[0].anchors;
|
||||
assert_eq!((a[0].h_out.x, a[0].h_out.y, a[1].p.x), (30.0, 0.0, 50.0));
|
||||
// Profiles migrate out of `unknown`.
|
||||
assert_eq!(d.color_profiles, ColorProfiles { rgb: Some("sRGB IEC61966-2.1".into()), cmyk: None });
|
||||
assert!(d.unknown.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_down_to_older_versions() {
|
||||
let mut d = rich_doc();
|
||||
d.color_profiles = ColorProfiles { rgb: None, cmyk: Some("Coated".into()) };
|
||||
let current = doc_json(&load(&save(&d, false)).unwrap());
|
||||
for version in [1, 2] {
|
||||
let bytes = save_with(&d, &opts(version, false)).unwrap();
|
||||
let v = json_of(&bytes);
|
||||
assert_eq!((v["format"].as_str(), v["version"].as_u64()), (Some("drawcraft"), Some(version as u64)), "older apps know the old name");
|
||||
// Profiles where those versions keep them.
|
||||
assert_eq!(v["document"]["unknown"]["colorProfiles"], json!({"rgb": null, "cmyk": "Coated"}));
|
||||
assert!(v["document"].get("color_profiles").is_none());
|
||||
let back = load(&bytes).unwrap();
|
||||
assert!(json_approx_eq(&doc_json(&back), ¤t, 1e-12), "v{version} loses nothing it can hold");
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
// v1 anchors are maps with every member; v2 anchors are compact.
|
||||
assert_eq!(text.contains("\"p\":{\"x\""), version == 1);
|
||||
assert_eq!(text.contains("\"p\":["), version == 2);
|
||||
}
|
||||
let v3 = json_of(&save(&d, false));
|
||||
assert_eq!((v3["format"].as_str(), v3["version"].as_u64()), (Some("vectorcraft"), Some(VERSION as u64)));
|
||||
assert_eq!(v3["document"]["color_profiles"], json!({"cmyk": "Coated"}));
|
||||
assert!(v3["document"]["unknown"].get("colorProfiles").is_none());
|
||||
// Out of range, and compression older apps can't read.
|
||||
assert!(matches!(save_with(&d, &opts(0, false)), Err(FormatError::BadVersion(0))));
|
||||
assert!(matches!(save_with(&d, &opts(VERSION + 1, false)), Err(FormatError::BadVersion(_))));
|
||||
assert!(matches!(save_with(&d, &opts(2, true)), Err(FormatError::CompressedTooOld(2))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_is_embedded_and_read_back() {
|
||||
let d = Document::new(10.0, 10.0);
|
||||
assert_eq!(preview(&save(&d, false)), None);
|
||||
let png = vec![0x89, b'P', b'N', b'G', 1, 2, 3];
|
||||
for compress in [false, true] {
|
||||
let bytes = save_with(&d, &SaveOptions { compress, preview: Some(png.clone()), ..SaveOptions::default() }).unwrap();
|
||||
assert_eq!(preview(&bytes), Some(png.clone()));
|
||||
assert!(load(&bytes).is_ok());
|
||||
}
|
||||
assert_eq!(preview(b"not a file"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretty_files_are_indented_like_serde_json() {
|
||||
let d = rich_doc();
|
||||
let bytes = save(&d, true);
|
||||
let text = String::from_utf8(bytes.clone()).unwrap();
|
||||
assert!(text.starts_with("{\n \"format\": \"vectorcraft\",\n \"version\": "));
|
||||
assert!(text.contains("\n \"document\": {\n \""));
|
||||
assert!(text.ends_with("\n}"));
|
||||
// The same JSON as the compact file.
|
||||
assert_eq!(json_of(&bytes), json_of(&save(&d, false)));
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, NodeId};
|
||||
use vectorcraft_format::{base64_decode, base64_encode, load, save, sniff};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind};
|
||||
use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, preview, save, save_with, sniff};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_testkit::fixtures;
|
||||
use vectorcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq};
|
||||
use vectorcraft_testkit::strategies::arb_ops;
|
||||
@@ -67,13 +68,50 @@ proptest! {
|
||||
prop_assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// Embedded image bytes survive the base64 round trip exactly.
|
||||
/// Embedded image bytes survive the base64 round trip exactly (saves keep the images in use).
|
||||
#[test]
|
||||
fn images_roundtrip(data in prop::collection::vec(any::<u8>(), 0..2000), key in "[a-z]{1,8}") {
|
||||
fn images_roundtrip(data in prop::collection::vec(any::<u8>(), 0..2000), key in "[a-z \"\\\\]{1,8}") {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", data.clone()));
|
||||
let back = load(&save(&d, false)).unwrap();
|
||||
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data));
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None };
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||||
for pretty in [false, true] {
|
||||
let back = load(&save(&d, pretty)).unwrap();
|
||||
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Compressed and older-version saves of random documents load to the same document.
|
||||
#[test]
|
||||
fn compressed_and_older_saves_roundtrip(ops in arb_ops(5..30)) {
|
||||
let mut s = fixtures::session();
|
||||
for op in &ops {
|
||||
let _ = op.apply(&mut s);
|
||||
}
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let want = doc_json(&load(&save(&d, false)).unwrap());
|
||||
for o in [SaveOptions { compress: true, ..SaveOptions::default() }, SaveOptions { version: 2, ..SaveOptions::default() }, SaveOptions { version: 1, pretty: true, ..SaveOptions::default() }] {
|
||||
let back = load(&save_with(&d, &o).unwrap()).unwrap();
|
||||
prop_assert!(json_approx_eq(&doc_json(&back), &want, 1e-12), "{:?} differs", o);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutating a compressed file (byte flips / truncation) never panics the loader or the sniffer.
|
||||
#[test]
|
||||
fn load_mutated_compressed_file_never_panics(cut in 0usize..2000, flips in prop::collection::vec((0usize..2000, any::<u8>()), 0..8)) {
|
||||
let mut bytes = save_with(&Document::new(100.0, 100.0), &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap();
|
||||
for (i, b) in flips {
|
||||
let n = bytes.len();
|
||||
bytes[i % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.min(bytes.len()));
|
||||
let _ = sniff(&bytes);
|
||||
let _ = preview(&bytes);
|
||||
if let Ok(d) = load(&bytes) {
|
||||
check_document(&d).map_err(TestCaseError::fail)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user