//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template, Revert, //! templates, converted legacy files, the saved view and the single Document Color Mode command. use std::path::PathBuf; use serde_json::{Value, json}; use vectorcraft_doc::SavedView; use vectorcraft_geom::Point; use super::*; use crate::cmd::fileio::{SaveMode, save_plan}; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); s } fn rect(s: &mut Session) { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); } /// A fresh folder for one test. fn dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("vc-save-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } fn path(d: &std::path::Path, name: &str) -> String { d.join(name).to_string_lossy().to_string() } fn b64(v: &Value) -> Vec { vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() } fn objects(s: &Session) -> usize { s.doc().unwrap().doc.node_count() } #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); let mut s = session(); rect(&mut s); let svg = path(&d, "art.svg"); let r = s.execute("document.save", &json!({"path": svg})).unwrap(); assert_eq!(r["format"], "svg"); assert!(r["warnings"][0].as_str().unwrap().contains("SVG keeps the artwork"), "{r}"); assert!(std::fs::read_to_string(&svg).unwrap().starts_with(" = o["saveFormats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]); // SVGZ is gzip-compressed SVG and reopens as SVGZ. let svgz = path(&d, "doc.svgz"); s.execute("file.saveAs", &json!({"path": svgz})).unwrap(); assert_eq!(&std::fs::read(&svgz).unwrap()[..2], [0x1f, 0x8b]); s.execute("document.open", &json!({"path": svgz})).unwrap(); assert_eq!(s.doc().unwrap().format, "svgz"); // Export formats aren't save formats. let e = s.execute("file.saveAs", &json!({"path": path(&d, "x.png")})).unwrap_err().to_string(); assert!(e.contains("document.export"), "{e}"); assert!(s.execute("document.save", &json!({"format": "webp"})).is_err()); assert!(s.execute("file.saveAs", &json!({"path": svgz, "options": 3})).is_err()); let _ = std::fs::remove_dir_all(d); } #[test] fn another_format_without_a_path_hands_the_bytes_back() { let d = dir("otherfmt"); let mut s = session(); let native = path(&d, "a.vectorcraft"); s.execute("document.save", &json!({"path": native})).unwrap(); let r = s.execute("document.save", &json!({"format": "svg"})).unwrap(); assert!(String::from_utf8(b64(&r)).unwrap().starts_with(" Vec { let text = String::from_utf8(vectorcraft_format::save(&vectorcraft_doc::Document::new(50.0, 50.0), false)).unwrap(); let text = if legacy { text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1) } else { text.replacen(&format!("\"version\":{}", vectorcraft_format::VERSION), "\"version\":1", 1) }; text.into_bytes() } #[test] fn older_native_files_open_converted_and_save_under_a_new_name() { let d = dir("converted"); let legacy = path(&d, "logo.drawcraft"); std::fs::write(&legacy, old_file(true)).unwrap(); let v1 = path(&d, "badge.vectorcraft"); std::fs::write(&v1, old_file(false)).unwrap(); let mut s = Session::new(); s.execute("document.open", &json!({"path": legacy})).unwrap(); assert_eq!(s.doc().unwrap().title(), "logo [Converted]"); // Save doesn't overwrite the old file: it asks for a .vectorcraft name. let r = s.execute("document.save", &json!({})).unwrap(); assert_eq!((r["name"].as_str(), r.get("path")), (Some("logo.vectorcraft"), None)); assert_eq!(std::fs::read(&legacy).unwrap(), old_file(true)); let r = s.execute("document.open", &json!({"path": v1})).unwrap(); assert_eq!(r["title"], "badge [Converted]"); let saved = path(&d, "badge2.vectorcraft"); s.execute("file.saveAs", &json!({"path": saved})).unwrap(); assert_eq!(s.doc().unwrap().title(), "badge2.vectorcraft"); assert!(!s.doc().unwrap().converted); // A current file isn't converted; with the preference off old files open as they are. s.execute("document.open", &json!({"path": saved})).unwrap(); assert_eq!(s.doc().unwrap().title(), "badge2.vectorcraft"); s.prefs.append_converted = false; s.execute("document.open", &json!({"path": legacy})).unwrap(); assert_eq!(s.doc().unwrap().title(), "logo.drawcraft"); assert_eq!(s.execute("document.save", &json!({})).unwrap()["path"].as_str(), Some(legacy.as_str())); let _ = std::fs::remove_dir_all(d); } #[test] fn the_saved_view_round_trips_without_dirtying() { let d = dir("view"); let mut s = session(); let view = SavedView { name: String::new(), center: Point::new(40.0, 25.0), zoom: 3.0, rotation: 15.0 }; s.doc_mut().unwrap().view = Some(view.clone()); assert!(!s.doc().unwrap().is_dirty(), "the view isn't an edit"); let native = path(&d, "v.vectorcraft"); s.execute("document.save", &json!({"path": native})).unwrap(); assert_eq!(s.doc().unwrap().doc.last_view, None, "only the written file carries it"); s.execute("document.open", &json!({"path": native})).unwrap(); assert_eq!(s.doc().unwrap().view.as_ref(), Some(&view)); // Files without a view (older ones) open with none; SVG doesn't carry it. let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap(); assert_eq!(doc.last_view, None); let svg = path(&d, "v.svg"); s.execute("document.save", &json!({"path": svg})).unwrap(); s.execute("document.open", &json!({"path": svg})).unwrap(); assert_eq!(s.doc().unwrap().view, None); let _ = std::fs::remove_dir_all(d); } #[test] fn open_layers_rows_round_trip_without_dirtying() { let d = dir("open-rows"); let mut s = session(); let layer = s.doc().unwrap().doc.layers[0].id; let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [a]})).unwrap(); let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); // A document starts with its top-level layers open, and a file keeps nothing for that. assert!(s.doc().unwrap().layers_open.contains(layer) && !s.doc().unwrap().layers_open.contains(g)); let native = path(&d, "rows.vectorcraft"); s.execute("document.save", &json!({"path": native})).unwrap(); let bytes = std::fs::read(&native).unwrap(); assert_eq!(vectorcraft_format::load(&bytes).unwrap().layers_open, None); // Opening the group and closing the layer is view state: not an edit, kept by the file. let st = s.doc_mut().unwrap(); st.layers_open.set(g, true); st.layers_open.set(layer, false); assert!(!s.doc().unwrap().is_dirty()); s.execute("document.save", &json!({"path": native})).unwrap(); assert_eq!(s.doc().unwrap().doc.layers_open, None, "only the written file carries it"); let mut again = session(); again.execute("document.open", &json!({"path": native})).unwrap(); let open = &again.doc().unwrap().layers_open; assert!(open.contains(g) && !open.contains(layer)); // Older files (none saved) open with the top-level layers open. let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap(); assert_eq!(doc.layers_open, None); let _ = std::fs::remove_dir_all(d); } #[test] fn save_plans_suggest_names_and_folders() { let mut s = session(); s.execute("file.info", &json!({"title": "Map"})).unwrap(); let plan = |s: &Session, mode, p: Value| save_plan(s, mode, &p).unwrap(); let p = plan(&s, SaveMode::Save, json!({})); assert_eq!((p.path, p.name.as_str(), p.format.id, p.folder), (None, "Map.vectorcraft", "vectorcraft", None)); assert_eq!(plan(&s, SaveMode::Copy, json!({"format": "pdf"})).name, "Map copy.pdf"); assert_eq!(plan(&s, SaveMode::Template, json!({"format": "svg"})).format.id, "template", "a template is native or .ait"); // A native template by default; .ait when the format, else the path, names it. assert_eq!(plan(&s, SaveMode::Template, json!({})).name, "Map template.vctemplate"); assert_eq!(plan(&s, SaveMode::Template, json!({"format": "ait"})).name, "Map template.ait"); assert_eq!(plan(&s, SaveMode::Template, json!({"path": "/t/Map.ait"})).format.id, "ait"); assert_eq!(plan(&s, SaveMode::Template, json!({"path": "/t/Map.ait", "format": "template"})).format.id, "template"); assert_eq!(SaveMode::of("file.saveCopy"), Some(SaveMode::Copy)); assert_eq!(crate::cmd::fileio::save_filters("svg")[0], ("SVG", &["svg"][..])); } #[test] fn document_color_mode_is_one_command_with_a_hidden_alias() { let mut s = session(); let ids: Vec<&str> = s.commands().iter().filter(|c| c.menu.first() == Some(&"File") && c.label.contains("Color Mode")).map(|c| c.id).collect(); assert_eq!(ids, ["file.documentColorMode"], "one File menu entry"); assert!(crate::cmd::is_alias("object.convertDocumentColorMode") && !crate::cmd::is_alias("file.documentColorMode")); // Both ids run the same conversion; `convert: false` only switches the mode. rect(&mut s); assert!(s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap()["changed"].as_u64().unwrap() >= 1); let before = s.doc().unwrap().doc.clone(); let r = s.execute("file.documentColorMode", &json!({"mode": "rgb", "convert": false})).unwrap(); assert_eq!(r["changed"], 0); assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb); assert_eq!(s.doc().unwrap().doc.layers, before.layers, "colours untouched"); let e = s.execute("file.documentColorMode", &json!({"mode": "lab"})).unwrap_err().to_string(); assert!(e.contains("file.documentColorMode"), "{e}"); } #[test] fn choosing_the_current_color_mode_is_not_an_edit() { let mut s = session(); rect(&mut s); let st = s.doc_mut().unwrap(); st.mark_saved(); let undo = st.history.undo.len(); for id in ["file.documentColorMode", "object.convertDocumentColorMode"] { assert_eq!(s.execute(id, &json!({"mode": "rgb"})).unwrap()["changed"], 0); } let st = s.doc().unwrap(); assert!(!st.is_dirty(), "the document stays saved"); assert_eq!(st.history.undo.len(), undo, "no empty undo step"); } #[test] fn a_pdf_opened_in_part_is_not_saved_back_over_the_whole_file() { let d = dir("pdfpart"); let mut s = Session::new(); s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let pdf = path(&d, "two.pdf"); s.execute("document.export", &json!({"path": pdf, "format": "pdf"})).unwrap(); // The whole file: Save writes it back as PDF. s.execute("document.open", &json!({"path": pdf})).unwrap(); let st = s.doc().unwrap(); assert_eq!((st.path.as_deref(), st.format), (Some(pdf.as_str()), "pdf")); // One page of it: Save asks for a name instead of dropping the other page. s.execute("document.open", &json!({"path": pdf, "pages": "1"})).unwrap(); let st = s.doc().unwrap(); assert_eq!((st.path.as_deref(), st.format), (None, "vectorcraft")); assert!(s.execute("document.save", &json!({})).unwrap().get("dataBase64").is_some()); let _ = std::fs::remove_dir_all(d); } #[test] fn a_template_places_like_a_native_file() { let mut s = session(); rect(&mut s); let tpl = s.execute("document.serialize", &json!({"format": "template"})).unwrap(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); let empty = s.doc().unwrap().doc.node_count(); s.execute("file.place", &json!({"name": "card.vctemplate", "dataBase64": tpl["dataBase64"]})).unwrap(); let st = s.doc().unwrap(); assert!(st.doc.node_count() > empty && !st.selection.is_empty(), "the template's art is placed and selected"); }