//! `.vectorcraft` v3: compression, pruning unused images, keys from newer versions, colour //! profiles as a document field, saving for older versions and the embedded preview. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::Write as _; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{ColorProfiles, Document, ImageBlob, ImageObject, Node, NodeKind, OpacityMask, Symbol}; use vectorcraft_format::{FormatError, SaveOptions, VERSION, is_compressed, load, preview, save, save_with, sniff}; use vectorcraft_geom::Affine; use vectorcraft_testkit::fixtures; use vectorcraft_testkit::invariants::{check_native_roundtrip, doc_json, json_approx_eq}; fn rich_doc() -> Document { (*fixtures::rich_session().doc().unwrap().doc).clone() } fn json_of(bytes: &[u8]) -> Value { serde_json::from_slice(bytes).unwrap() } fn gzip(bytes: &[u8]) -> Vec { let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); gz.write_all(bytes).unwrap(); gz.finish().unwrap() } fn blob(bytes: &[u8]) -> ImageBlob { ImageBlob::new("image/png", bytes.to_vec()) } fn image_node(d: &mut Document, key: &str) -> Node { let id = d.alloc_id(); Node::new( id, NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }), ) } fn opts(version: u32, compress: bool) -> SaveOptions { SaveOptions { version, compress, ..SaveOptions::default() } } #[test] fn compressed_files_load() { let d = rich_doc(); let packed = save_with(&d, &SaveOptions { compress: true, pretty: true, ..SaveOptions::default() }).unwrap(); assert!(is_compressed(&packed) && sniff(&packed)); let plain = save(&d, false); assert!(packed.len() * 3 < plain.len(), "compressed {} vs {} bytes", packed.len(), plain.len()); assert_eq!(doc_json(&load(&packed).unwrap()), doc_json(&load(&plain).unwrap())); // Compressed files are compact even when pretty was asked for. assert!(!flate2_unpack(&packed).contains(&b'\n')); // A legacy (v2, pre-rename) file compressed by hand opens too. let text = String::from_utf8(plain.clone()).unwrap(); let legacy = text.replacen("\"format\":\"vectorcraft\",\"version\":3", "\"format\":\"drawcraft\",\"version\":2", 1); assert_ne!(legacy, text); let legacy = gzip(legacy.as_bytes()); assert!(sniff(&legacy)); assert_eq!(doc_json(&load(&legacy).unwrap()), doc_json(&load(&plain).unwrap())); // Other gzip data (an SVGZ) is not a native file. assert!(!sniff(&gzip(b""))); assert!(load(&packed[..packed.len() / 2]).is_err(), "a truncated file is an error"); } fn flate2_unpack(bytes: &[u8]) -> Vec { use std::io::Read as _; let mut out = vec![]; flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).unwrap(); out } #[test] fn unused_images_are_pruned() { let mut d = Document::new(100.0, 100.0); let layer = d.layers[0].id; for (k, b) in [("used", &[1u8, 2][..]), ("unused", &[3, 4]), ("in symbol", &[5]), ("in brush", &[6]), ("in mask", &[7])] { d.images.insert(k.into(), blob(b)); } let mut n = image_node(&mut d, "used"); n.mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false))); d.insert(Some(layer), 0, n).unwrap(); let art = image_node(&mut d, "in symbol"); d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art) }); // Image objects inside foreign data (a brush library kept as JSON) keep theirs too. d.unknown.insert("brushes".into(), json!([{"art": {"type": "image", "key": "in brush"}}])); let back = load(&save(&d, true)).unwrap(); let mut keys: Vec<&str> = back.images.keys().map(String::as_str).collect(); keys.sort_unstable(); assert_eq!(keys, ["in brush", "in mask", "in symbol", "used"]); assert_eq!(back.images["used"].bytes.as_slice(), &[1, 2]); let v = json_of(&save(&d, false)); assert!(v["images"].get("unused").is_none() && v["document"]["images"].as_object().is_some_and(|m| m.is_empty())); } #[test] fn keys_from_newer_versions_round_trip() { let mut v = json_of(&save(&rich_doc(), false)); v["document"]["futureFeature"] = json!({"a": [1, 2, 3], "b": "x"}); v["document"]["futureFlag"] = json!(true); let d = load(&serde_json::to_vec(&v).unwrap()).unwrap(); assert_eq!(d.extra["futureFeature"]["a"], json!([1, 2, 3])); assert!(!d.unknown.contains_key("futureFeature"), "kept apart from `unknown`"); for pretty in [false, true] { let again = json_of(&save(&d, pretty)); assert_eq!(again["document"]["futureFeature"], v["document"]["futureFeature"]); assert_eq!(again["document"]["futureFlag"], json!(true)); } check_native_roundtrip(&d).unwrap(); } /// A document as the first version wrote it (format name `drawcraft`, anchors as maps). const V1_FILE: &str = r#"{"format":"drawcraft","version":1,"generator":"DrawCraft 0.1.0","document":{"version":1,"title":"Old","artboards":[{"id":1,"name":"Artboard 1","rect":{"x0":0.0,"y0":0.0,"x1":100.0,"y1":80.0}}], "layers":[{"id":1,"name":"Layer 1","visible":true,"locked":false,"opacity":1.0,"kind":{"type":"layer","children":[ {"id":2,"visible":true,"locked":false,"opacity":1.0,"kind":{"type":"path","path":{"subpaths":[{"anchors":[ {"p":{"x":10.0,"y":10.0},"in":{"x":10.0,"y":10.0},"out":{"x":30.0,"y":0.0},"kind":"Smooth"}, {"p":{"x":50.0,"y":40.0},"in":{"x":50.0,"y":40.0},"out":{"x":50.0,"y":40.0},"kind":"Corner"}],"closed":false}]},"rule":"NonZero","clipping":false,"guide":false}}],"color":{"Preset":0}}}], "next_id":3,"unknown":{"colorProfiles":{"rgb":"sRGB IEC61966-2.1","cmyk":null}}},"images":{}}"#; #[test] fn version_1_files_load() { let d = load(V1_FILE.as_bytes()).unwrap(); assert_eq!(d.title, "Old"); let NodeKind::Path { path, .. } = &d.layers[0].children().unwrap()[0].kind else { panic!("not a path") }; let a = &path.subpaths[0].anchors; assert_eq!((a[0].h_out.x, a[0].h_out.y, a[1].p.x), (30.0, 0.0, 50.0)); // Profiles migrate out of `unknown`. assert_eq!(d.color_profiles, ColorProfiles { rgb: Some("sRGB IEC61966-2.1".into()), cmyk: None }); assert!(d.unknown.is_empty()); } #[test] fn saves_down_to_older_versions() { let mut d = rich_doc(); d.color_profiles = ColorProfiles { rgb: None, cmyk: Some("Coated".into()) }; let current = doc_json(&load(&save(&d, false)).unwrap()); for version in [1, 2] { let bytes = save_with(&d, &opts(version, false)).unwrap(); let v = json_of(&bytes); assert_eq!((v["format"].as_str(), v["version"].as_u64()), (Some("drawcraft"), Some(version as u64)), "older apps know the old name"); // Profiles where those versions keep them. assert_eq!(v["document"]["unknown"]["colorProfiles"], json!({"rgb": null, "cmyk": "Coated"})); assert!(v["document"].get("color_profiles").is_none()); let back = load(&bytes).unwrap(); assert!(json_approx_eq(&doc_json(&back), ¤t, 1e-12), "v{version} loses nothing it can hold"); let text = String::from_utf8(bytes).unwrap(); // v1 anchors are maps with every member; v2 anchors are compact. assert_eq!(text.contains("\"p\":{\"x\""), version == 1); assert_eq!(text.contains("\"p\":["), version == 2); } let v3 = json_of(&save(&d, false)); assert_eq!((v3["format"].as_str(), v3["version"].as_u64()), (Some("vectorcraft"), Some(VERSION as u64))); assert_eq!(v3["document"]["color_profiles"], json!({"cmyk": "Coated"})); assert!(v3["document"]["unknown"].get("colorProfiles").is_none()); // Out of range, and compression older apps can't read. assert!(matches!(save_with(&d, &opts(0, false)), Err(FormatError::BadVersion(0)))); assert!(matches!(save_with(&d, &opts(VERSION + 1, false)), Err(FormatError::BadVersion(_)))); assert!(matches!(save_with(&d, &opts(2, true)), Err(FormatError::CompressedTooOld(2)))); } #[test] fn preview_is_embedded_and_read_back() { let d = Document::new(10.0, 10.0); assert_eq!(preview(&save(&d, false)), None); let png = vec![0x89, b'P', b'N', b'G', 1, 2, 3]; for compress in [false, true] { let bytes = save_with(&d, &SaveOptions { compress, preview: Some(png.clone()), ..SaveOptions::default() }).unwrap(); assert_eq!(preview(&bytes), Some(png.clone())); assert!(load(&bytes).is_ok()); } assert_eq!(preview(b"not a file"), None); } #[test] fn pretty_files_are_indented_like_serde_json() { let d = rich_doc(); let bytes = save(&d, true); let text = String::from_utf8(bytes.clone()).unwrap(); assert!(text.starts_with("{\n \"format\": \"vectorcraft\",\n \"version\": ")); assert!(text.contains("\n \"document\": {\n \"")); assert!(text.ends_with("\n}")); // The same JSON as the compact file. assert_eq!(json_of(&bytes), json_of(&save(&d, false))); } /// #451: a guide's artboard is written only for artboard guides, so canvas guides save as they /// did, and a file without it (an older version's) opens with canvas guides. #[test] fn artboard_guides_round_trip_and_canvas_guides_save_as_before() { use vectorcraft_doc::Guide; let mut d = Document::new(100.0, 100.0); let id = d.artboards[0].id; d.guides = vec![Guide::new(true, 10.0), Guide { artboard: Some(id), ..Guide::new(false, 20.0) }]; let mut v = json_of(&save(&d, false)); assert_eq!(v["document"]["guides"], json!([{"vertical": true, "pos": 10.0}, {"vertical": false, "pos": 20.0, "artboard": id}])); assert_eq!(load(&save(&d, false)).unwrap().guides, d.guides); v["document"]["guides"] = json!([{"vertical": false, "pos": 20.0}]); let old = load(&serde_json::to_vec(&v).unwrap()).unwrap(); assert_eq!(old.guides, [Guide::new(false, 20.0)]); }