mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
A native save was pretty-printed when the document had at most 2,000 layers and objects and its compact JSON was at most 1 MiB. Pretty printing puts every coordinate on its own indented line, so traced art with few paths and many anchors could pass that check and be written at about five times the size of its compact JSON. The 1 MiB limit now applies to the pretty-printed document, indented as the file holds it and in the form the save writes: version 1 anchors, and placed documents expanded for older versions or Include Linked Files. Documents whose pretty-printed JSON is over 1 MiB are saved as compact JSON, even when their compact JSON is under it. The 2,000-object rule and compressed saves are unchanged. docs/mcp.md describes the rule.
205 lines
9.4 KiB
Rust
205 lines
9.4 KiB
Rust
//! 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 an_older_version_is_pretty_only_when_its_own_json_fits() {
|
||
// One path whose anchors fit the pretty size limit in the current version but not as
|
||
// version 1 writes them (both handles of every anchor as `{x, y}` maps).
|
||
use vectorcraft_geom::Point;
|
||
use vectorcraft_geom::path::{PathData, SubPath};
|
||
let path_doc = |anchors: usize| {
|
||
let mut b = vectorcraft_testkit::fixtures::DocBuilder::new(1000.0, 1000.0);
|
||
let points: Vec<Point> = (0..anchors).map(|j| Point::new(j as f64, (j % 2) as f64)).collect();
|
||
let node = b.path_node(PathData::single(SubPath::polyline(&points, false)), Default::default());
|
||
b.add(node);
|
||
b.doc
|
||
};
|
||
let per_anchor = serde_json::to_vec_pretty(&path_doc(1000)).unwrap().len() / 1000;
|
||
let d = path_doc(vectorcraft_format::PRETTY_MAX_BYTES / 2 / per_anchor);
|
||
let mut s = Session::new();
|
||
open(&mut s, "traced.vectorcraft", &vectorcraft_format::save_file(&d));
|
||
let is_pretty = |bytes: &[u8]| bytes.windows(3).any(|w| w == b"\n ");
|
||
assert!(is_pretty(&saved(&mut s, json!({"compress": false}))));
|
||
let old = saved(&mut s, json!({"version": 1}));
|
||
assert!(!is_pretty(&old), "{} bytes", old.len());
|
||
open(&mut s, "old.drawcraft", &old);
|
||
assert_eq!(s.doc().unwrap().doc.node_count(), 2);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
/// A save records the date the document was made in, so the same commands twice gave different
|
||
/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and
|
||
/// `SOURCE_DATE_EPOCH` for an export that takes no date param.
|
||
#[test]
|
||
fn a_pinned_date_makes_saves_byte_identical() {
|
||
let once = |t: i64| {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 200, "created": t})).unwrap();
|
||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||
saved(&mut s, json!({}))
|
||
};
|
||
let at = 1_791_208_980;
|
||
assert_eq!(once(at), once(at), "the same document and date give the same bytes");
|
||
// The date really is in the bytes, so the comparison above is not vacuous.
|
||
assert_ne!(once(at), once(at + 1), "another date shows in the bytes");
|
||
}
|