mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
- 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]>
184 lines
8.9 KiB
Rust
184 lines
8.9 KiB
Rust
//! `.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)));
|
||
}
|