//! The native save options of the file itself: Include Linked Files, embedded ICC profiles and the //! PDF a PDF-compatible file carries, each round-tripping (and ignored by readers that don't //! need them). use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::links::LinkInfo; use vectorcraft_doc::{ Appearance, BlendSpec, Document, EnvelopeKind, ImageBlob, ImageObject, LiveShape, MAX_COORD, Node, NodeKind, OpacityMask, Symbol, }; use vectorcraft_format::{ ICC_MIME, MAX_PRECISION, PRETTY_MAX_BYTES, PRETTY_MAX_OBJECTS, SaveOptions, load, load_file, pdf_content, save_file, save_with, }; use vectorcraft_geom::path::{Anchor, PathData, SubPath}; use vectorcraft_geom::{Affine, Point}; use vectorcraft_testkit::fixtures::DocBuilder; fn json_of(bytes: &[u8]) -> Value { serde_json::from_slice(bytes).unwrap() } /// A document showing image `k` linked to a file; the blob holds the file's bytes and a preview. fn linked_doc() -> Document { let mut d = Document::new(100.0, 100.0); let layer = d.layers[0].id; let id = d.alloc_id(); let im = ImageObject { key: "k".into(), width: 2, height: 2, xf: Affine::IDENTITY, link: Some(LinkInfo::new("/art/photo.png")), placement: Default::default(), pixels: None, }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]); blob.proxy = Some(Arc::new(vec![9, 9])); d.images.insert("k".into(), blob); d } #[test] fn include_linked_files_keeps_the_files_bytes() { let d = linked_doc(); let plain = save_with(&d, &SaveOptions::default()).unwrap(); let image = &json_of(&plain)["images"]["k"]; assert_eq!(image["proxy"], true, "only the preview by default"); let full = save_with(&d, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); let image = &json_of(&full)["images"]["k"]; assert!(image.get("proxy").is_none()); let back = load(&full).unwrap(); assert_eq!(back.images["k"].bytes.as_slice(), &[1, 2, 3, 4, 5, 6]); assert!(!back.images["k"].is_proxy()); // Only the preview loaded (the file was never found): the preview is all there is to write. let mut missing = linked_doc(); let preview = Arc::new(vec![9, 9]); missing.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }); let out = save_with(&missing, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); assert_eq!(json_of(&out)["images"]["k"]["proxy"], true); } #[test] fn embedded_profiles_and_pdf_round_trip() { let mut d = Document::new(100.0, 100.0); d.color_profiles.rgb = Some("Studio RGB".into()); let o = SaveOptions { profiles: [("Studio RGB".to_string(), vec![0, 1, 2, 3, 250])].into(), pdf: Some(b"%PDF-1.7 fake".to_vec()), compress: true, ..SaveOptions::default() }; let bytes = save_with(&d, &o).unwrap(); let f = load_file(&bytes).unwrap(); assert_eq!(f.profiles["Studio RGB"], vec![0, 1, 2, 3, 250]); assert_eq!(f.doc.color_profiles.rgb.as_deref(), Some("Studio RGB")); assert_eq!(pdf_content(&bytes).unwrap(), b"%PDF-1.7 fake"); // The plain file says what they are; a file without them has neither member. let plain = save_with(&d, &SaveOptions { compress: false, ..o }).unwrap(); let v = json_of(&plain); assert_eq!((v["profiles"]["Studio RGB"]["mime"].as_str(), v["pdf"]["mime"].as_str()), (Some(ICC_MIME), Some("application/pdf"))); let none = save_with(&d, &SaveOptions::default()).unwrap(); assert!(json_of(&none).get("profiles").is_none() && pdf_content(&none).is_none()); assert!(load_file(&none).unwrap().profiles.is_empty()); // Damaged profile data is left out rather than failing the open. let damaged = String::from_utf8(plain).unwrap().replacen("AAECA/o=", "not base64!", 1); assert!(load_file(damaged.as_bytes()).unwrap().profiles.is_empty()); } /// A document of `paths` open paths with `anchors` anchors each. fn paths_doc(paths: usize, anchors: usize) -> Document { let mut b = DocBuilder::new(1000.0, 1000.0); for i in 0..paths { let points: Vec = (0..anchors).map(|j| Point::new(j as f64, i 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 } fn is_pretty(bytes: &[u8]) -> bool { bytes.windows(3).any(|w| w == b"\n ") } #[test] fn small_documents_are_saved_pretty() { let d = paths_doc(10, 10); assert!(SaveOptions::for_doc(&d).pretty); assert!(is_pretty(&save_file(&d))); } #[test] fn many_objects_are_saved_compact() { let d = paths_doc(PRETTY_MAX_OBJECTS + 1, 2); assert!(!SaveOptions::for_doc(&d).pretty); } /// Anchors per path that make a document of `paths` paths about `bytes` of compact JSON. fn anchors_for(paths: usize, bytes: usize) -> usize { let per_anchor = serde_json::to_vec(&paths_doc(1, 1000)).unwrap().len() / 1000; bytes / per_anchor / paths } #[test] fn few_objects_with_many_anchors_are_saved_compact() { let d = paths_doc(3, anchors_for(3, 2 * PRETTY_MAX_BYTES)); assert!(!SaveOptions::for_doc(&d).pretty, "3 objects, but about {} bytes", 2 * PRETTY_MAX_BYTES); let bytes = save_file(&d); assert!(!is_pretty(&bytes)); assert_eq!(load(&bytes).unwrap().layers[0].count(), d.layers[0].count()); } /// Anchors per path that make a document of `paths` paths about `bytes` of pretty-printed JSON. fn pretty_anchors_for(paths: usize, bytes: usize) -> usize { let per_anchor = serde_json::to_vec_pretty(&paths_doc(1, 1000)).unwrap().len() / 1000; bytes / per_anchor / paths } #[test] fn documents_under_the_size_limit_stay_pretty() { let d = paths_doc(2, pretty_anchors_for(2, PRETTY_MAX_BYTES / 2)); assert!(SaveOptions::for_doc(&d).pretty); assert!(is_pretty(&save_file(&d))); } #[test] fn the_limit_is_on_the_pretty_printed_size() { // One traced-sized path: well under the limit as compact JSON, over it pretty-printed. let d = paths_doc(1, 20_000); let compact = serde_json::to_vec(&d).unwrap().len(); let pretty = serde_json::to_vec_pretty(&d).unwrap().len(); assert!(compact < PRETTY_MAX_BYTES && pretty > PRETTY_MAX_BYTES, "compact {compact} bytes, pretty {pretty} bytes"); assert!(!SaveOptions::for_doc(&d).pretty); let bytes = save_file(&d); assert!(!is_pretty(&bytes)); assert_eq!(load(&bytes).unwrap().layers[0].count(), d.layers[0].count()); } #[test] fn the_files_indentation_counts_toward_the_limit() { // The pretty JSON's size and line breaks; the file indents every line after the first by two spaces more. let measure = |anchors: usize| { let json = serde_json::to_vec_pretty(&paths_doc(1, anchors)).unwrap(); (json.len(), json.iter().filter(|&&b| b == b'\n').count()) }; // The most anchors for which `fits` holds (it holds for 1 and not past twice the estimate). let most = |fits: &dyn Fn((usize, usize)) -> bool| { let (mut lo, mut hi) = (1, 2 * pretty_anchors_for(1, PRETTY_MAX_BYTES)); while lo < hi { let mid = (lo + hi).div_ceil(2); if fits(measure(mid)) { lo = mid } else { hi = mid - 1 } } lo }; // Without the file's indentation this many anchors fit the limit, with it they do not. let bare = most(&|(len, _)| len <= PRETTY_MAX_BYTES); let (len, newlines) = measure(bare); assert!(len + 2 * newlines > PRETTY_MAX_BYTES, "{len} bytes, {newlines} line breaks"); assert!(!SaveOptions::for_doc(&paths_doc(1, bare)).pretty); // Indented, this many fit. let indented = most(&|(len, newlines)| len + 2 * newlines <= PRETTY_MAX_BYTES); assert!(indented < bare); assert!(SaveOptions::for_doc(&paths_doc(1, indented)).pretty); assert!(!SaveOptions::for_doc(&paths_doc(1, indented + 1)).pretty); } #[test] fn the_limit_is_on_the_version_written() { // Version 1 writes every anchor with both handles as `{x, y}` maps: this document fits in the // current version and not in version 1. let d = paths_doc(1, pretty_anchors_for(1, PRETTY_MAX_BYTES / 2)); let v1 = SaveOptions { version: 1, ..SaveOptions::default() }; assert!(SaveOptions::default().pretty_fits(&d)); assert!(!v1.pretty_fits(&d)); let pretty = save_with(&d, &SaveOptions { pretty: true, ..v1.clone() }).unwrap(); assert!(pretty.len() > PRETTY_MAX_BYTES, "{} bytes", pretty.len()); } #[test] fn art_outside_the_layers_counts_too() { // Generated art kept in a symbol: one instance-sized document, a symbol full of anchors. let mut d = paths_doc(1, 4); let mut art = paths_doc(1, anchors_for(1, 2 * PRETTY_MAX_BYTES)); let path = Arc::clone(&art.layers[0]); art.layers.clear(); d.symbols.push(Symbol { dynamic: false, name: "Leaves".into(), art: path }); assert!(!SaveOptions::for_doc(&d).pretty); let bytes = save_file(&d); assert!(!is_pretty(&bytes)); assert_eq!(load(&bytes).unwrap().symbols.len(), 1); } /// One path at `x, y`, its out handle at `x + 6.1234567, y - 0.00001` (`-0.0` at 4 decimals when /// `y` is 0), filled with a color whose components have many digits. fn precise_path(d: &mut Document, x: f64, y: f64) -> Node { let p = Point::new(x, y); let a = Anchor { p, h_in: p, h_out: Point::new(x + 6.1234567, y - 0.00001), kind: Default::default() }; let sp = SubPath::new(vec![a, Anchor::corner(Point::new(x + 20.0, y))], false); let fill = Paint::solid(Color::rgb(0.02745098, 0.33333334, 0.9019608)); Node::path(d.alloc_id(), PathData::single(sp), Appearance::basic(fill, Paint::None, 0.0)) } /// A document with a precise path in the layer, in an opacity mask, in a symbol (placed with a /// precise transform) and in a brush kept as foreign data. fn precise_doc() -> Document { let mut d = Document::new(100.0, 100.0); let layer = d.layers[0].id; let mut n = precise_path(&mut d, 3.4876474632797176, 3.499999999999999); n.extras_mut().mask = Some(Box::new(OpacityMask::new(precise_path(&mut d, 7.77777777, 1.0), false))); d.insert(Some(layer), 0, n).unwrap(); let art = precise_path(&mut d, 1.23456789, 0.0); d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let xf = Affine::new([1.0, 0.0, 0.0, 1.0, 0.123456789, 0.0]); let id = d.alloc_id(); d.insert(Some(layer), 1, Node::new(id, NodeKind::SymbolInstance { symbol: "S".into(), xf })).unwrap(); let brush = serde_json::to_value(precise_path(&mut d, 5.55555555, 4.0)).unwrap(); d.unknown.insert("brushes".into(), serde_json::json!([{"name": "B", "type": "art", "art": brush}])); d } fn first_anchor(n: &Node) -> Anchor { let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {n:?}") }; path.subpaths[0].anchors[0] } #[test] fn precision_rounds_path_anchors_and_handles_only() { let d = precise_doc(); let bytes = save_with(&d, &SaveOptions { precision: Some(4), ..SaveOptions::default() }).unwrap(); let text = String::from_utf8(bytes.clone()).unwrap(); assert!(!text.contains("-0.0"), "a handle rounded to zero is written as 0.0"); let back = load(&bytes).unwrap(); let path = &back.layers[0].children().unwrap()[0]; let a = first_anchor(path); assert_eq!((a.p, a.h_in, a.h_out), (Point::new(3.4876, 3.5), Point::new(3.4876, 3.5), Point::new(9.6111, 3.5))); let mask = path.extras().mask.as_ref().unwrap(); assert_eq!(first_anchor(&mask.art).p, Point::new(7.7778, 1.0)); let a = first_anchor(&back.symbols[0].art); assert_eq!((a.p, a.h_out), (Point::new(1.2346, 0.0), Point::new(7.358, 0.0))); let brush = &back.unknown["brushes"][0]["art"]["kind"]["path"]["subpaths"][0]["anchors"][0]; assert_eq!((brush["p"][0].as_f64(), brush["out"][0].as_f64()), (Some(5.5556), Some(11.679))); // Colors and transforms keep every digit. let original = &d.layers[0].children().unwrap()[0]; assert_eq!(path.appearance, original.appearance); assert_eq!(mask.art.appearance, original.extras().mask.as_ref().unwrap().art.appearance); let NodeKind::SymbolInstance { xf, .. } = &back.layers[0].children().unwrap()[1].kind else { panic!("not an instance") }; assert_eq!(xf.as_coeffs()[4], 0.123456789); // The document saved is left as it was. assert_eq!(first_anchor(&d.layers[0].children().unwrap()[0]).p.x, 3.4876474632797176); } #[test] fn precision_rounds_the_paths_objects_keep_besides_their_own() { let mut d = Document::new(100.0, 100.0); let layer = d.layers[0].id; let path = || PathData::single(SubPath::polyline(&[Point::new(1.23456789, 0.0), Point::new(9.87654321, 5.0)], false)); // Live Corners' uncut outline, and Intertwine areas of tiers 1 and 2. let mut n = precise_path(&mut d, 1.0, 1.0); if let NodeKind::Path { live, .. } = &mut n.kind { *live = Some(Box::new(LiveShape::Path { base: path(), radii: vec![2.0], kinds: vec![], radii_relative: false })); } n.extras_mut().intertwine = Some(Box::new(path())); n.extras_mut().intertwine_tiers = vec![path()]; d.insert(Some(layer), 0, n).unwrap(); // A blend's replaced spine and an envelope's top-object outline. let keys = vec![Arc::new(precise_path(&mut d, 1.0, 1.0)), Arc::new(precise_path(&mut d, 50.0, 1.0))]; let blend = BlendSpec { spine: Some(path()), ..BlendSpec::default() }; let id = d.alloc_id(); d.insert(Some(layer), 1, Node::new(id, NodeKind::Blend { children: keys, spec: Box::new(blend) })).unwrap(); let envelope = NodeKind::Envelope { content: vec![Arc::new(precise_path(&mut d, 1.0, 1.0))], kind: Box::new(EnvelopeKind::TopObject { path: path() }), fidelity: 50.0, editing: false, options: Default::default(), frame: Default::default(), }; let id = d.alloc_id(); d.insert(Some(layer), 2, Node::new(id, envelope)).unwrap(); // The same three objects as brushes' art, and type, whose frame stays exact. let mut brushes: Vec = d.layers[0].children().unwrap().iter().map(|n| serde_json::json!({"art": n})).collect(); let frame = serde_json::json!({"subpaths": [{"anchors": [{"p": [1.23456789, 0.0]}], "closed": true}]}); brushes.push(serde_json::json!({"art": {"id": 99, "kind": {"type": "text", "kind": "area", "frame": frame}}})); d.unknown.insert("brushes".into(), Value::Array(brushes)); let text = |precision| String::from_utf8(save_with(&d, &SaveOptions { precision, ..SaveOptions::default() }).unwrap()).unwrap(); let exact = text(None); assert_eq!((exact.matches("1.23456789").count(), exact.matches("9.87654321").count()), (11, 10), "{exact}"); let rounded = text(Some(4)); assert_eq!((rounded.matches("1.23456789").count(), rounded.matches("9.87654321").count()), (1, 0), "{rounded}"); assert_eq!((rounded.matches("1.2346").count(), rounded.matches("9.8765").count()), (10, 10), "{rounded}"); } #[test] fn without_precision_coordinates_are_written_exactly() { let d = precise_doc(); let exact = save_with(&d, &SaveOptions::default()).unwrap(); let text = String::from_utf8(exact.clone()).unwrap(); for x in ["3.4876474632797176", "3.499999999999999", "7.77777777", "1.23456789", "5.55555555"] { assert!(text.contains(x), "{x}"); } assert_eq!(first_anchor(&load(&exact).unwrap().layers[0].children().unwrap()[0]).p.x, 3.4876474632797176); // A precision over MAX_PRECISION rounds to MAX_PRECISION decimals. let at = |p| save_with(&d, &SaveOptions { precision: Some(p), ..SaveOptions::default() }).unwrap(); assert_eq!(at(200), at(MAX_PRECISION)); assert!(at(0).len() < at(MAX_PRECISION).len()); } /// A document holding one path through `points`. fn doc_with_path(points: &[Point], closed: bool) -> Document { let mut d = Document::new(100.0, 100.0); let layer = d.layers[0].id; let id = d.alloc_id(); let path = PathData::single(SubPath::polyline(points, closed)); d.insert(Some(layer), 0, Node::path(id, path, Appearance::basic(Paint::None, Paint::None, 0.0))).unwrap(); d } /// The first subpath of the first object of `d` saved at `precision` and opened again. fn saved_subpath(d: &Document, precision: Option) -> SubPath { let back = load(&save_with(d, &SaveOptions { precision, ..SaveOptions::default() }).unwrap()).unwrap(); let NodeKind::Path { path, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("not a path") }; path.subpaths[0].clone() } #[test] fn without_precision_every_coordinate_reopens_bit_for_bit() { // 500 anchors at coordinates of every size and sign (a fixed pseudo-random sequence). let mut seed = 0x9e37_79b9_7f4a_7c15u64; let mut next = || { seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); let unit = (seed >> 11) as f64 / (1u64 << 53) as f64 - 0.5; unit * 10f64.powi((seed % 13) as i32 - 6) }; let mut points: Vec = (0..500).map(|_| Point::new(next(), next())).collect(); points.extend([Point::new(MAX_COORD, -1e-300), Point::new(f64::MIN_POSITIVE, -3_999_999.123_456_789)]); let d = doc_with_path(&points, false); let bytes = save_with(&d, &SaveOptions::default()).unwrap(); let got: Vec = saved_subpath(&d, None).anchors.iter().map(|a| a.p).collect(); let bits = |ps: &[Point]| ps.iter().map(|p| (p.x.to_bits(), p.y.to_bits())).collect::>(); assert_eq!(bits(&got), bits(&points)); // Saving the reopened file writes the same bytes. assert_eq!(save_with(&load(&bytes).unwrap(), &SaveOptions::default()).unwrap(), bytes); } #[test] fn precision_rounds_halves_away_from_zero() { // These halves are exact in binary, so the scaled value is exactly .5. let d = doc_with_path(&[Point::new(0.125, -0.125), Point::new(0.375, -0.375), Point::new(1.5, -2.5)], false); let at = |p| saved_subpath(&d, Some(p)).anchors.iter().map(|a| a.p).collect::>(); assert_eq!(at(2), [Point::new(0.13, -0.13), Point::new(0.38, -0.38), Point::new(1.5, -2.5)]); assert_eq!(at(0), [Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(2.0, -3.0)]); // `1.005` is stored as slightly less than the decimal, so it goes down. let d = doc_with_path(&[Point::new(1.005, -1.005)], false); assert_eq!(saved_subpath(&d, Some(2)).anchors[0].p, Point::new(1.0, -1.0)); } #[test] fn a_rounded_closed_path_stays_closed_on_its_first_anchor() { let first = Point::new(10.123456, 20.987654); let d = doc_with_path(&[first, Point::new(50.55555, 20.4444), Point::new(30.01, 60.996), first], true); let sp = saved_subpath(&d, Some(2)); assert!(sp.closed); assert_eq!(sp.anchors.len(), 4); assert_eq!(sp.anchors[0].p, Point::new(10.12, 20.99)); assert_eq!(sp.anchors[3].p, sp.anchors[0].p); } #[test] fn a_shape_smaller_than_the_precision_keeps_its_anchors_on_one_point() { let d = doc_with_path(&[Point::new(5.0001, 5.0001), Point::new(5.0004, 5.0001), Point::new(5.0002, 5.0004)], true); let sp = saved_subpath(&d, Some(3)); assert!(sp.closed); assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), [Point::new(5.0, 5.0); 3]); } #[test] fn precision_rounds_coordinates_at_the_edge_of_the_canvas() { let points = [Point::new(3_999_999.123_456_789, -3_999_999.987_654_321), Point::new(MAX_COORD, -MAX_COORD)]; let d = doc_with_path(&points, false); let at = |p| saved_subpath(&d, Some(p)).anchors.iter().map(|a| a.p).collect::>(); assert_eq!(at(3), [Point::new(3_999_999.123, -3_999_999.988), points[1]]); for (got, want) in at(MAX_PRECISION).iter().zip(&points) { assert!((got.x - want.x).abs() <= 1e-9 && (got.y - want.y).abs() <= 1e-9, "{got:?} vs {want:?}"); } } #[test] fn saving_a_rounded_file_at_the_same_precision_again_writes_the_same_bytes() { let o = SaveOptions { precision: Some(2), ..SaveOptions::default() }; let once = save_with(&precise_doc(), &o).unwrap(); assert_eq!(save_with(&load(&once).unwrap(), &o).unwrap(), once); }