//! 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 crate::cmd::fileio::{SaveMode, save_plan}; use crate::*; 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() } /// A client confined by automation roots: a save without a path writes the document's own file, and /// when that file is outside the read roots the reply leaves out its `path` and names the files /// written beside it by their file names. A reply without a path leaves out a suggested `folder` /// outside them. A path inside the read roots, and one the client gives, stay whole. #[test] fn a_confined_save_leaves_out_paths_outside_the_read_root() { use crate::file_access::{AutomationRoots, confine}; let d = dir("roots"); let (inside, outside) = (d.join("in"), d.join("out")); for f in [&inside, &outside] { std::fs::create_dir_all(f).unwrap(); } let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let own = path(&outside, "doc.vectorcraft"); s.execute("document.save", &json!({"path": own})).unwrap(); let person = s.execute("document.save", &json!({"separateArtboards": true})).unwrap(); let files: Vec = person["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); assert_eq!((files.len(), files[0].as_str()), (3, own.as_str()), "{person}"); let write_only = AutomationRoots::new(&[inside.as_path()], &[outside.as_path()]).unwrap(); let both = AutomationRoots::new(&[inside.as_path(), outside.as_path()], &[outside.as_path()]).unwrap(); let run = |s: &mut Session, roots: &Option>, id: &str, p: Value| { confine(roots.as_ref(), || s.execute(id, &p)).unwrap_or_else(|e| panic!("{id} {p}: {e}")) }; let hidden = outside.to_string_lossy().into_owned(); let r = run(&mut s, &write_only, "document.save", json!({})); assert!(r.get("path").is_none() && r["bytes"].as_u64().is_some_and(|n| n > 0), "{r}"); assert!(!r.to_string().contains(&hidden), "{r}"); let r = run(&mut s, &write_only, "document.save", json!({"separateArtboards": true})); let names: Vec = files.iter().map(|f| json!(std::path::Path::new(f).file_name().unwrap().to_string_lossy())).collect(); assert_eq!((r.get("path"), r["files"].as_array()), (None, Some(&names)), "{r}"); // Inside the read roots, or given by the client: whole. assert_eq!(run(&mut s, &both, "document.save", json!({}))["path"], json!(own)); assert_eq!(run(&mut s, &write_only, "document.save", json!({"path": own}))["path"], json!(own)); // A save without a path known suggests the document's folder. let r = run(&mut s, &write_only, "file.saveCopy", json!({})); assert!(r.get("folder").is_none() && r["name"] == "doc copy.vectorcraft" && r.get("dataBase64").is_some(), "{r}"); assert_eq!(run(&mut s, &both, "file.saveCopy", json!({}))["folder"], json!(hidden)); let _ = std::fs::remove_dir_all(d); } /// A save without a path, and Revert, refused because the document's own file is outside a confined /// client's roots: the error keeps the root and the reason and names the file by its file name, also /// for the files a save writes beside it. A path the client gives stays whole in the error. #[test] fn pathless_save_rejection_hides_document_folder() { use crate::file_access::{AutomationRoots, confine}; let d = dir("roots-error"); let (inside, outside) = (d.join("in"), d.join("kept-out")); for f in [&inside, &outside] { std::fs::create_dir_all(f).unwrap(); } let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let own = path(&outside, "doc.vectorcraft"); s.execute("document.save", &json!({"path": own})).unwrap(); rect(&mut s); let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); let hidden = outside.to_string_lossy().into_owned(); let mut refused = |id: &str, p: Value| confine(roots.as_ref(), || s.execute(id, &p)).unwrap_err().to_string(); let e = refused("document.save", json!({})); assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); let e = refused("document.save", json!({"separateArtboards": true})); assert!(e.starts_with(&format!("automation path rejected: outside the write root {root}: ")) && !e.contains(&hidden), "{e}"); let e = refused("file.revert", json!({})); assert_eq!(e, format!("automation path rejected: outside the read root {root}: doc.vectorcraft")); // Given by the client: whole. let e = refused("document.save", json!({"path": own})); assert_eq!(e, format!("automation path rejected: outside the write root {root}: {own}")); // Without read roots, a write refusal still names the file by its file name. let write_only = AutomationRoots::new(&[], &[inside.as_path()]).unwrap(); let e = confine(write_only.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); assert!(s.doc().unwrap().is_dirty(), "nothing was saved"); // Read with losses from its own file: the refusal to write over it names the file by its file name. let st = s.doc_mut().unwrap(); st.imported_from = Some(own.clone()); st.import_losses = vec!["hidden text".into()]; let e = confine(roots.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); assert!(e.starts_with("invalid parameters for `document.save`: doc.vectorcraft is the file this document was read from"), "{e}"); assert!(!e.contains(&hidden), "{e}"); let e = confine(roots.as_ref(), || s.execute("document.save", &json!({"path": own}))).unwrap_err().to_string(); assert!(e.contains(&format!("{own} is the file this document was read from")), "{e}"); let _ = std::fs::remove_dir_all(d); } #[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", "eps"]); // 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); } /// #4523: a new document's VectorCraft Options start as the last native save used them (not its /// artboard range, nor Use Compression, which its preference decides). #[test] fn a_new_document_offers_the_native_options_used_last() { let d = dir("lastnative"); let mut s = session(); let opts = json!({"preview": true, "embedProfiles": false, "pdfCompatible": true, "separateArtboards": true, "range": "1"}); s.execute("file.saveAs", &json!({"path": path(&d, "a.vectorcraft"), "options": opts})).unwrap(); s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); let o = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); let value = |k: &str| o["options"][k]["value"].clone(); assert_eq!((value("preview"), value("embedProfiles"), value("pdfCompatible")), (json!(true), json!(false), json!(true))); assert_eq!( (value("separateArtboards"), value("range")), (o["options"]["separateArtboards"]["default"].clone(), o["options"]["range"]["default"].clone()) ); 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"); } #[test] fn transparency_grid_round_trips_without_dirtying() { let d = dir("transparency-grid"); let mut s = session(); let native = path(&d, "grid.vectorcraft"); for on in [true, false] { s.execute("view.transparencyGrid", &json!({"on": on})).unwrap(); assert!(!s.doc().unwrap().is_dirty(), "the grid is view state, not an edit"); s.execute("document.save", &json!({"path": native})).unwrap(); s.execute("document.open", &json!({"path": native})).unwrap(); assert_eq!(s.doc().unwrap().transparency_grid, on); assert!(s.doc().unwrap().history.undo.is_empty()); assert!(!s.doc().unwrap().is_dirty()); if on { let svg = path(&d, "grid.svg"); s.execute("file.saveCopy", &json!({"path": svg})).unwrap(); s.execute("document.open", &json!({"path": svg})).unwrap(); assert!(!s.doc().unwrap().transparency_grid, "SVG does not store view state"); s.execute("document.open", &json!({"path": native})).unwrap(); assert!(s.doc().unwrap().transparency_grid); } } // A native file written before this setting existed still opens with the grid hidden. let old = path(&d, "old.vectorcraft"); let bytes = vectorcraft_format::save(&Document::new(5.0, 5.0), false); assert!(!String::from_utf8_lossy(&bytes).contains("transparency_grid")); std::fs::write(&old, bytes).unwrap(); s.execute("document.open", &json!({"path": old})).unwrap(); assert!(!s.doc().unwrap().transparency_grid); let _ = std::fs::remove_dir_all(d); } /// The `%%BoundingBox` of an EPS file's PostScript. fn bounding_box(eps: &[u8]) -> [i64; 4] { let ps = std::str::from_utf8(vectorcraft_eps::sections(eps).unwrap().0).unwrap(); let line = ps.lines().find_map(|l| l.strip_prefix("%%BoundingBox:")).expect("a bounding box"); let v: Vec = line.split_whitespace().map(|n| n.parse().unwrap()).collect(); [v[0], v[1], v[2], v[3]] } #[test] fn opened_eps_saves_to_its_own_file_around_its_art_or_on_its_page() { let d = dir("eps-original-format"); let eps_path = path(&d, "Artwork.eps"); // Original, contributor-authored PostScript: the artwork occupies only part of the page. let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nshowpage\n%%EOF\n"; std::fs::write(&eps_path, source).unwrap(); let mut s = Session::new(); s.execute("document.open", &json!({"path": eps_path})).unwrap(); assert_eq!(s.doc().unwrap().format, "eps"); assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); let board = s.doc().unwrap().doc.artboards[0].rect; assert_eq!((board.width(), board.height()), (200.0, 100.0)); rect(&mut s); let edited_layers = s.doc().unwrap().doc.layers.clone(); // Without Use Artboards the bounding box fits the art, as Export draws it; the editing data // keeps the page. let quiet = json!({"previewFormat": "none", "thumbnails": false}); let r = s.execute("document.save", &json!({"options": quiet})).unwrap(); assert_eq!((r["path"].as_str(), r["format"].as_str()), (Some(eps_path.as_str()), Some("eps"))); let bytes = std::fs::read(&eps_path).unwrap(); assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0")); let exported = b64(&s.execute("document.export", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap()); assert_eq!(bounding_box(&bytes), bounding_box(&exported)); assert_ne!(bounding_box(&bytes), [0, 0, 200, 100], "the art does not fill the page"); let editing = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(editing.artboards[0].rect, board); assert_eq!(editing.layers, edited_layers); assert!(!s.doc().unwrap().is_dirty()); let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); assert_eq!(opened["restored"], true); assert_eq!(s.doc().unwrap().doc.layers, edited_layers); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board); // Use Artboards keeps the opened file's page. rect(&mut s); s.execute("document.save", &json!({"options": {"useArtboards": true, "previewFormat": "none", "thumbnails": false}})).unwrap(); assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); // Save remembers it. rect(&mut s); s.execute("document.save", &json!({})).unwrap(); assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); std::fs::remove_dir_all(d).unwrap(); } #[test] fn eps_save_without_use_artboards_fits_the_box_to_art_past_the_artboard() { let mut s = session(); s.execute("shape.rectangle", &json!({"x": -50, "y": 20, "width": 300, "height": 40})).unwrap(); let quiet = json!({"previewFormat": "none", "thumbnails": false}); let r = s.execute("document.save", &json!({"format": "eps", "options": quiet})).unwrap(); let [x0, y0, x1, y1] = bounding_box(&b64(&r)); assert!(x0 <= -50 && x1 >= 250 && y0 >= 0 && y1 <= 100, "{:?}", [x0, y0, x1, y1]); for (use_artboards, expected) in [(false, [x0, y0, x1, y1]), (true, [0, 0, 200, 100])] { let options = json!({"useArtboards": use_artboards, "previewFormat": "none", "thumbnails": false}); let r = s.execute("document.save", &json!({"format": "eps", "options": options})).unwrap(); assert_eq!(bounding_box(&b64(&r)), expected, "useArtboards: {use_artboards}"); } } #[test] fn eps_save_keeps_all_editing_artboards_in_the_file_of_one_page() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let r = s.execute("document.save", &json!({"format": "eps", "artboard": 1, "previewFormat": "none", "thumbnails": false})).unwrap(); let bytes = b64(&r); assert!(r.get("files").is_none(), "{r}"); let board = s.doc().unwrap().doc.artboards[1].rect; let [x0, y0, x1, y1] = bounding_box(&bytes); assert_eq!(((x1 - x0) as f64, (y1 - y0) as f64), (board.width(), board.height())); let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(native.artboards.len(), 2); assert_eq!(native.layers, s.doc().unwrap().doc.layers); assert!(s.execute("document.save", &json!({"format": "eps", "options": {"selectedOnly": true}})).is_err(), "selected-only is Export's job"); } #[test] fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() { let d = dir("eps-named-artboard"); let eps_path = path(&d, "Art.eps"); let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let options = json!({"useArtboards": true, "artboard": 1, "previewFormat": "none", "thumbnails": false}); s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); let o = s.execute("file.formatOptions", &json!({})).unwrap(); assert_eq!(o["options"]["useArtboards"]["value"], true); assert_eq!(o["options"]["range"]["value"], "2", "the saved artboard, not All"); std::fs::remove_dir_all(d).unwrap(); } #[test] fn eps_save_with_use_artboards_refuses_to_replace_the_file_read_from_before_writing_any_file() { let d = dir("eps-artboards-lossy-sibling"); let eps_path = path(&d, "Art.eps"); let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); let sibling = r["files"][1].as_str().unwrap().to_string(); let original = std::fs::read(&sibling).unwrap(); std::fs::remove_file(&eps_path).unwrap(); let st = s.doc_mut().unwrap(); st.imported_from = Some(sibling.clone()); st.import_losses.push("test import omitted artwork".into()); let err = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap_err().to_string(); assert!(err.contains("acknowledgeLoss") && err.contains(&sibling), "{err}"); assert_eq!(std::fs::read(&sibling).unwrap(), original); assert!(!std::path::Path::new(&eps_path).exists(), "no file of the set is written when one is refused"); s.execute("document.save", &json!({"path": eps_path, "options": options, "acknowledgeLoss": true})).unwrap(); assert!(std::path::Path::new(&eps_path).exists()); std::fs::remove_dir_all(d).unwrap(); } #[test] fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() { let d = dir("eps-artboards"); let eps_path = path(&d, "Art.eps"); let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); let files: Vec = r["files"].as_array().expect("files").iter().map(|f| f.as_str().unwrap().to_string()).collect(); assert_eq!(files.len(), 3, "{r}"); assert_eq!(files[0], eps_path, "the file the document becomes comes first"); assert!(files[1..].iter().all(|f| f.starts_with(&path(&d, "Art_")) && f.ends_with(".eps")), "{files:?}"); // The central file: the visible art and the whole document. let central = std::fs::read(&eps_path).unwrap(); let exported = b64(&s.execute("document.export", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap()); assert_eq!(bounding_box(¢ral), bounding_box(&exported)); assert_eq!(vectorcraft_format::load(&vectorcraft_eps::native(¢ral).unwrap()).unwrap().artboards.len(), 2); // Each artboard's file: its artboard as the page, holding that artboard. let boards = s.doc().unwrap().doc.artboards.clone(); for (f, board) in files[1..].iter().zip(&boards) { let bytes = std::fs::read(f).unwrap(); let [x0, y0, x1, y1] = bounding_box(&bytes); assert_eq!(((x1 - x0) as f64, (y1 - y0) as f64), (board.rect.width(), board.rect.height()), "{f}"); let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(native.artboards.iter().map(|a| a.rect).collect::>(), [board.rect], "{f}"); } let st = s.doc().unwrap(); assert_eq!((st.path.as_deref(), st.is_dirty()), (Some(eps_path.as_str()), false)); // With no visible art the central file is still written, on the first artboard. s.execute("select.all", &json!({})).unwrap(); s.execute("edit.clear", &json!({})).unwrap(); std::fs::remove_file(&eps_path).unwrap(); s.execute("document.save", &json!({})).unwrap(); assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); std::fs::remove_dir_all(d).unwrap(); } #[test] fn eps_save_retains_lossy_import_overwrite_protection_and_transparency_warning() { let d = dir("eps-import-warning"); let eps_path = path(&d, "Artwork.eps"); let mut s = session(); rect(&mut s); s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); let r = s.execute("document.save", &json!({"path": eps_path, "previewFormat": "none", "thumbnails": false})).unwrap(); assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flatten")), "{r}"); let original = std::fs::read(&eps_path).unwrap(); let st = s.doc_mut().unwrap(); st.imported_from = Some(eps_path.clone()); st.import_losses.push("test import omitted artwork".into()); rect(&mut s); assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); assert_eq!(std::fs::read(&eps_path).unwrap(), original); s.execute("document.save", &json!({"acknowledgeLoss": true})).unwrap(); assert_ne!(std::fs::read(&eps_path).unwrap(), original); std::fs::remove_dir_all(d).unwrap(); } #[test] fn eps_save_supports_an_empty_artboard_without_cropping_its_page() { let mut s = session(); let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap(); let bytes = b64(&r); let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(native.artboards[0].rect, s.doc().unwrap().doc.artboards[0].rect); assert_eq!(native.node_count(), s.doc().unwrap().doc.node_count()); assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100")); } #[test] fn partially_read_eps_requires_confirmation_before_save_back() { let d = dir("eps-partial-import"); let eps_path = path(&d, "partial.eps"); let original = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nunsupportedMockyOperator\n1 0 0 setrgbcolor 80 20 40 30 rectfill\nshowpage\n%%EOF\n"; std::fs::write(&eps_path, original).unwrap(); let mut s = Session::new(); let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); assert!(opened["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().starts_with("the file was read up to an error")), "{opened}"); rect(&mut s); assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); assert_eq!(std::fs::read(&eps_path).unwrap(), original); s.execute("document.save", &json!({"acknowledgeLoss": true})).unwrap(); assert!(vectorcraft_eps::native(&std::fs::read(&eps_path).unwrap()).is_some()); std::fs::remove_dir_all(d).unwrap(); } #[test] fn eps_with_damaged_editing_data_requires_confirmation_before_save_back() { let d = dir("eps-damaged-editing"); let eps_path = path(&d, "damaged.eps"); let mut s = session(); rect(&mut s); let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false})).unwrap(); let mut bytes = b64(&r); let at = bytes.windows(9).position(|w| w == b"native\n% ").unwrap() + 12; bytes[at] = b'z'; std::fs::write(&eps_path, &bytes).unwrap(); let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); assert_eq!(opened["restored"], false); assert!(opened["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("editing data can't be read")), "{opened}"); rect(&mut s); assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); assert_eq!(std::fs::read(&eps_path).unwrap(), bytes); std::fs::remove_dir_all(d).unwrap(); } #[test] fn eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 4000, "height": 4000})).unwrap(); rect(&mut s); // Explicit colour preview remains a colour preview; Save bounds its resolution, not the art. let r = s.execute("document.save", &json!({"format": "eps", "useArtboards": true, "previewFormat": "tiffColor"})).unwrap(); let bytes = b64(&r); let (ps, tiff) = vectorcraft_eps::sections(&bytes).unwrap(); let tiff = tiff.unwrap(); assert!(tiff.len() < 4 * 1024 * 1024 + 64 * 1024, "compatibility preview grew to {} bytes", tiff.len()); let preview = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap(); assert_eq!((preview.width(), preview.height()), (1024, 1024)); assert!(std::str::from_utf8(ps).unwrap().contains("%%BoundingBox: 0 0 4000 4000")); let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(native.artboards, s.doc().unwrap().doc.artboards); assert_eq!(native.layers, s.doc().unwrap().doc.layers); } /// #4646: a new document's units and ruler guides survive Save, Close and Open in every format /// that carries the whole document (an SVG keeps them only with Preserve Editing). #[test] fn units_and_ruler_guides_survive_save_and_reopen() { let d = dir("units-guides"); for ext in ["vectorcraft", "pdf", "ai", "eps"] { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "units": "Millimeters"})).unwrap(); s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap(); s.execute("guide.add", &json!({"vertical": false, "pos": 30})).unwrap(); rect(&mut s); let p = path(&d, &format!("a.{ext}")); s.execute("document.save", &json!({"path": p})).unwrap(); s.execute("file.close", &json!({})).unwrap(); s.execute("document.open", &json!({"path": p})).unwrap(); let doc = &s.doc().unwrap().doc; let guides: Vec<_> = doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!((doc.units, guides), (vectorcraft_doc::Unit::Millimeters, vec![(true, 50.0), (false, 30.0)]), "{ext}"); } let _ = std::fs::remove_dir_all(d); } /// A ruler guide hidden or locked on its own (the Layers panel's eye and lock on its row, /// `layer.setProps` on its node) stays so through Save, Close and Open, and the other guide stays /// visible and unlocked. #[test] fn a_guide_hidden_or_locked_on_its_own_survives_save_and_reopen() { let d = dir("guide-flags"); for ext in ["vectorcraft", "pdf", "ai", "eps"] { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); for pos in [10, 20, 30] { s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); } rect(&mut s); let ids: Vec = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect(); s.execute("layer.setProps", &json!({"ids": [ids[1]], "visible": false})).unwrap(); s.execute("layer.setProps", &json!({"ids": [ids[2]], "locked": true})).unwrap(); let p = path(&d, &format!("a.{ext}")); s.execute("document.save", &json!({"path": p})).unwrap(); s.execute("file.close", &json!({})).unwrap(); s.execute("document.open", &json!({"path": p})).unwrap(); let flags: Vec<_> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.pos, g.hidden, g.locked)).collect(); assert_eq!(flags, [(10.0, false, false), (20.0, true, false), (30.0, false, true)], "{ext}"); } let _ = std::fs::remove_dir_all(d); }