//! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read //! from a file), drawn and output as vectors, saved with its preview, checked, updated and //! relinked when the file changes, and turned into an editable copy (Break Link, Expand). use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PlacedDocument}; use vectorcraft_geom::{Rect, shapes}; use super::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write}; use super::*; /// A VectorCraft document at `path`: one `w`×`h` artboard filled with `rgb` (and `extra` more /// rectangles, which change the file's size). fn source(path: &str, w: f64, h: f64, rgb: [u8; 3], extra: usize) { let mut d = Document::new(w, h); let layer = d.layers[0].id; let [r, g, b] = rgb; for i in 0..=extra { let id = d.alloc_id(); let look = Appearance::basic(Paint::solid(Color::rgb8(r, g, b)), Paint::None, 0.0); d.insert(Some(layer), i, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, w, h)), look)).unwrap(); } write(path, &vectorcraft_format::save_file(&d)); } /// A document drawing symbol `Star` (a blue 100×50 rectangle) at `path`, titled `title` (tests /// that remove the file give it content of its own: files read are cached by content). fn star_source(path: &str, title: &str) { let mut d = Document::new(100.0, 50.0); d.title = title.into(); let layer = d.layers[0].id; let art = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), look(BLUE)); d.symbols.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(art) }); let id = d.alloc_id(); d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); write(path, &vectorcraft_format::save_file(&d)); } fn look(c: [u8; 3]) -> Appearance { Appearance::basic(Paint::solid(Color::rgb8(c[0], c[1], c[2])), Paint::None, 0.0) } /// The active document gets a red symbol named `Star` of its own. fn parent_star(s: &mut Session) { let star = Node::path(vectorcraft_doc::NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(RED)); std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc) .symbols .push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(star) }); } /// Place `path` (linked: the default), centred on the artboard → its id. fn place(s: &mut Session, path: &str) -> NodeId { let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap(); assert_eq!(r["linked"], true, "{r}"); NodeId(r["ids"][0].as_u64().unwrap()) } fn placed(s: &Session, id: NodeId) -> PlacedDocument { match &s.doc().unwrap().doc.node(id).unwrap().kind { NodeKind::PlacedDocument(p) => (**p).clone(), k => panic!("not a placed document: {k:?}"), } } fn bounds(s: &Session, id: NodeId) -> Rect { s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() } fn decode(v: &Value) -> Vec { vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() } /// Images and paths in `bytes`, a PDF, read back. fn pdf_contents(bytes: &[u8]) -> (usize, usize) { // The page as drawn (not the VectorCraft document the PDF carries). let (doc, _) = super::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap(); let (mut images, mut paths) = (0, 0); doc.walk(|n| match n.kind { NodeKind::Image(_) => images += 1, NodeKind::Path { .. } | NodeKind::Compound { .. } => paths += 1, _ => {} }); (images, paths) } /// The JSON of a `.vectorcraft` file (the data after it left out). fn file_json(bytes: &[u8]) -> Value { let end = bytes.windows(vectorcraft_format::BLOB_MAGIC.len()).position(|w| w == vectorcraft_format::BLOB_MAGIC).unwrap_or(bytes.len()); serde_json::from_slice(&bytes[..end]).unwrap() } #[test] fn a_document_places_linked_by_default_at_its_artboard_size() { let dir = Folder::new("placed-basic"); let src = dir.file("logo.vectorcraft"); source(&src, 100.0, 50.0, RED, 0); let mut s = session(); let id = place(&mut s, &src); let p = placed(&s, id); assert!((p.width - 100.0).abs() < 1e-6 && (p.height - 50.0).abs() < 1e-6, "{p:?}"); assert_eq!(bounds(&s, id), Rect::new(150.0, 125.0, 250.0, 175.0)); assert_eq!((p.link.path.as_str(), p.link.page), (src.as_str(), Some(1))); let n = s.doc().unwrap().doc.node(id).unwrap(); assert_eq!((n.name.as_deref(), n.kind_label()), (Some("logo.vectorcraft"), "Placed Document")); assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); // It moves and scales like any object. s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); assert_eq!(bounds(&s, id), Rect::new(160.0, 125.0, 260.0, 175.0)); // Its art's bounds instead of the artboard. let mut d = Document::new(100.0, 50.0); let layer = d.layers[0].id; let id2 = d.alloc_id(); d.insert(Some(layer), 0, Node::path(id2, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 30.0)), look(RED))).unwrap(); let small = dir.file("small.vectorcraft"); write(&small, &vectorcraft_format::save_file(&d)); let r = s.execute("file.place", &json!({"path": small, "crop": "bounding", "at": [200, 150]})).unwrap(); let p = placed(&s, NodeId(r["ids"][0].as_u64().unwrap())); assert!(p.bounding && (p.width - 30.0).abs() < 1e-6 && (p.height - 20.0).abs() < 1e-6, "{p:?}"); } #[test] fn without_link_or_a_file_a_document_places_as_an_editable_copy() { let dir = Folder::new("placed-copy"); let src = dir.file("logo.vectorcraft"); source(&src, 100.0, 50.0, RED, 0); let mut s = session(); let data = vectorcraft_format::base64_encode(&std::fs::read(&src).unwrap()); for p in [json!({"path": src, "link": false}), json!({"name": "logo.vectorcraft", "dataBase64": data})] { let r = s.execute("file.place", &p).unwrap(); assert_eq!(r["linked"], false, "{r}"); let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); assert_eq!(n.kind_label(), "Clip Group", "{p}"); } assert!(!s.doc().unwrap().doc.has_placed()); } #[test] fn a_placed_document_is_vectors_in_every_output() { let dir = Folder::new("placed-outputs"); let src = dir.file("logo.vectorcraft"); source(&src, 100.0, 50.0, RED, 0); let mut s = session(); place(&mut s, &src); let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); let text = svg["text"].as_str().unwrap(); assert!(!text.contains(" 0); let png = decode(&s.execute("document.serialize", &json!({"format": "png"})).unwrap()); let [r, g, b, _] = image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(200, 150).0; assert!(near([r, g, b], RED), "{:?}", [r, g, b]); for format in ["eps", "emf", "dxf"] { let out = s.execute("document.serialize", &json!({"format": format})).unwrap(); assert!(out.to_string().len() > 200, "{format}: {out}"); } let info = s.execute("document.info", &json!({})).unwrap(); assert!(info.to_string().contains("\"placedDocuments\""), "{info}"); } #[test] fn the_preview_is_saved_so_the_document_shows_it_without_its_file() { let dir = Folder::new("placed-saved"); let src = dir.file("logo.vectorcraft"); // Content of its own: files read are cached by content, and this one is removed. source(&src, 100.0, 50.0, RED, 2); let mut s = session(); let id = place(&mut s, &src); let doc_path = dir.file("poster/poster.vectorcraft"); std::fs::create_dir_all(dir.file("poster")).unwrap(); save(&mut s, &doc_path); let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); let kind = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"]; assert_eq!(kind["type"], "placeddocument"); assert_eq!(kind["link"]["relative"], "../logo.vectorcraft"); let key = kind["key"].as_str().unwrap(); let entry = &saved["images"][key]; assert_eq!((entry["proxy"].as_bool(), entry["mime"].as_str()), (Some(true), Some("image/jpeg")), "the preview, as JPEG (opaque)"); // Include Linked Files keeps the file itself. let full = file_json(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "includeLinked": true})).unwrap())); assert_eq!((full["images"][key]["proxy"].clone(), full["images"][key]["mime"].as_str()), (Value::Null, Some("application/json"))); std::fs::remove_file(&src).unwrap(); let r = open(&mut s, &doc_path); assert_eq!(r["missingLinks"].as_array().map(Vec::len), Some(1), "{r}"); assert_eq!(placed(&s, id).link.path, src); assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "shown from the saved preview"); assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 1); // Output with the file gone: the preview, with a warning. let png = s.execute("document.serialize", &json!({"format": "png"})).unwrap(); assert!(png["warnings"].to_string().contains("output as their previews"), "{png}"); let [r, g, b, _] = image::load_from_memory(&decode(&png)).unwrap().to_rgba8().get_pixel(200, 150).0; assert!(near([r, g, b], RED), "{:?}", [r, g, b]); // Saved for older apps: the art it shows (here its preview), as plain objects. let old: Value = serde_json::from_slice(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": 2})).unwrap())).unwrap(); let old = old["document"]["layers"][0]["kind"]["children"][0].to_string(); assert!(old.contains("\"image\"") && !old.contains("placeddocument"), "{old}"); } #[test] fn a_document_saved_with_previews_outputs_its_files_art() { let dir = Folder::new("placed-preview-output"); let src = dir.file("logo.vectorcraft"); source(&src, 100.0, 50.0, RED, 0); let mut s = session(); let id = place(&mut s, &src); let doc_path = dir.file("poster.vectorcraft"); save(&mut s, &doc_path); open(&mut s, &doc_path); let key = placed(&s, id).key; assert!(s.doc().unwrap().doc.images[&key].is_proxy(), "opened with the preview only"); assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); // Output reads the file again: vectors, no warning. let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); assert_eq!(svg["warnings"], json!([]), "{}", svg["warnings"]); let text = svg["text"].as_str().unwrap(); assert!(!text.contains(" = doc.symbols.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names.len(), 2, "the file's Star joined, renamed: {names:?}"); assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"], json!([])); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Embed"); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.has_placed()); // A file that can't be read stays linked. std::fs::remove_file(&src).unwrap(); let doc_path = dir.file("poster.vectorcraft"); save(&mut s, &doc_path); open(&mut s, &doc_path); let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap(); assert_eq!((r["embedded"].clone(), r["missing"].clone()), (json!([]), json!([id.0])), "{r}"); } #[test] fn expand_turns_a_placed_document_into_editable_art_with_its_resources() { let dir = Folder::new("placed-expand"); let src = dir.file("badge.vectorcraft"); star_source(&src, "star"); let mut s = session(); parent_star(&mut s); let id = place(&mut s, &src); s.execute("object.scale", &json!({"sx": 200})).unwrap(); let before = bounds(&s, id); s.execute("object.expand", &json!({})).unwrap(); let doc = &s.doc().unwrap().doc; let n = doc.node(id).unwrap(); assert!(n.is_container() && !doc.has_placed(), "{:?}", n.kind_label()); let after = n.geometric_bounds().unwrap(); assert!((after.width() - before.width()).abs() < 0.5 && (after.height() - before.height()).abs() < 0.5, "{after:?} vs {before:?}"); assert!(near(centre_colour(doc), BLUE)); let saved = vectorcraft_format::save_file(doc); assert!(near(centre_colour(&vectorcraft_format::load(&saved).unwrap()), BLUE), "saved with its resources"); let info = s.execute("object.expand.info", &json!({})).unwrap(); assert_eq!(info["object"], false, "nothing left to expand: {info}"); } #[test] fn saving_a_document_refreshes_the_open_documents_that_place_it() { let dir = Folder::new("placed-refresh"); let src = dir.file("logo.vectorcraft"); source(&src, 100.0, 50.0, RED, 0); let mut s = session(); let id = place(&mut s, &src); let parent = s.active_index().unwrap(); // Open the file, recolour it and save it. s.execute("document.open", &json!({"path": src})).unwrap(); s.execute("select.all", &json!({})).unwrap(); s.execute("paint.setFill", &json!({"color": "#1414e6"})).unwrap(); s.execute("document.save", &json!({})).unwrap(); let child = s.active_index().unwrap(); assert_ne!(parent, child); let doc = &s.documents()[parent]; assert_eq!(doc.history.undo.last().unwrap().label, "Update Links"); assert!(near(centre_colour(&doc.doc), BLUE), "shows the saved version"); assert!(matches!(doc.doc.node(id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))); assert_eq!(s.active_index(), Some(child), "the saved document stays active"); } #[test] fn link_info_gives_the_artboards_size_not_pixels() { let dir = Folder::new("placed-info"); let src = dir.file("logo.vectorcraft"); source(&src, 100.0, 50.0, RED, 0); let mut s = session(); let id = place(&mut s, &src); s.execute("object.scale", &json!({"sx": 200})).unwrap(); let i = s.execute("links.info", &json!({"id": id.0})).unwrap(); assert_eq!((i["pageWidth"].as_f64(), i["pageHeight"].as_f64(), i["document"].as_bool()), (Some(100.0), Some(50.0), Some(true)), "{i}"); assert!(i.get("pixelWidth").is_none() && i.get("ppi").is_none(), "no pixels: {i}"); assert_eq!( (i["status"].as_str(), i["fileName"].as_str(), i["scale"].clone()), (Some("ok"), Some("logo.vectorcraft"), json!([200.0, 200.0])), "{i}" ); }