mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 15:57:49 +00:00
The eye and lock on a guide's row in the Layers panel hide or lock that
guide alone, in one undo step, and a drag down either column takes guide
rows along with the other rows it passes. A hidden guide is not drawn and
does not snap, and its row is dimmed; a locked guide is drawn and snaps
but can't be selected, moved or deleted. Both can be changed while the
guide's layer is hidden or locked, and the guide shows or can be edited
only when its layer allows it as well. The state is kept through save
and reopen.
- layer.setProps takes ruler guide node ids; every other command still
refuses them.
- guide.list rows carry the guide's node id and its own hidden and
locked state.
- Alt-click and Ctrl/Cmd+Alt-click on a guide row's eye or lock act on
the other layers, as on any row in that layer.
- Object > Lock > Selection and Object > Hide > Selection lock or hide the
selected ruler guides with the selected art, in one undo step, outside
isolation mode. Unlock All and Show All, document scope, also unlock or
show guides locked or hidden on their own and select them as guides.
- Guide rows dropped on the Layers panel's trash delete their guides
(guide.remove {indexes}). A click on a guide row clears the highlighted
rows, so Delete deletes the guide; with no row highlighted the trash
deletes the selected guides.
- guide.remove and guide.setLayer refuse index together with indexes and
an indexes value that is not a list, and count a repeated index once.
guide.select, guide.remove and guide.setLayer refuse an indexes list of
more than 10,000 entries; guide.setLayer cut such a list short before.
- Moving, deleting or moving to another layer a guide that is hidden or
locked on its own fails with "the guide is hidden or locked"; a guide
on a hidden or locked layer still fails with "the guide is on a hidden
or locked layer".
Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md.
818 lines
43 KiB
Rust
818 lines
43 KiB
Rust
//! 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<u8> {
|
|
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<String> = 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<std::sync::Arc<AutomationRoots>>, 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<Value> = 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("<?xml"));
|
|
let st = s.doc().unwrap();
|
|
assert_eq!((st.path.as_deref(), st.format, st.is_dirty()), (Some(svg.as_str()), "svg", false));
|
|
// Save writes the same file in the same format.
|
|
rect(&mut s);
|
|
s.execute("document.save", &json!({})).unwrap();
|
|
assert!(std::fs::read_to_string(&svg).unwrap().starts_with("<?xml"));
|
|
assert!(!s.doc().unwrap().is_dirty());
|
|
// A reopened SVG keeps its path and saves as SVG again.
|
|
let mut s = Session::new();
|
|
s.execute("document.open", &json!({"path": svg})).unwrap();
|
|
assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().format), (Some(svg.as_str()), "svg"));
|
|
rect(&mut s);
|
|
assert_eq!(s.execute("document.save", &json!({})).unwrap()["format"], "svg");
|
|
assert!(std::fs::read_to_string(&svg).unwrap().starts_with("<?xml"));
|
|
let _ = std::fs::remove_dir_all(d);
|
|
}
|
|
|
|
#[test]
|
|
fn save_as_takes_on_path_format_and_options() {
|
|
let d = dir("saveas");
|
|
let mut s = session();
|
|
let pdf = path(&d, "doc.pdf");
|
|
s.execute("file.saveAs", &json!({"path": pdf, "options": {"range": "1", "preview": true}})).unwrap();
|
|
let st = s.doc().unwrap();
|
|
assert_eq!((st.format, st.title()), ("pdf", "doc.pdf".to_string()));
|
|
assert_eq!(Value::Object(st.save_options.clone()), json!({"range": "1"}), "only the format's own options are kept");
|
|
assert!(std::fs::read(&pdf).unwrap().starts_with(b"%PDF"));
|
|
// The remembered options are what Save and the options dialog use.
|
|
let o = s.execute("file.formatOptions", &json!({})).unwrap();
|
|
assert_eq!((o["id"].as_str(), o["options"]["range"]["value"].as_str()), (Some("pdf"), Some("1")));
|
|
assert_eq!(o["options"]["artboards"]["value"], Value::Null);
|
|
let svg = s.execute("file.formatOptions", &json!({"format": "svg"})).unwrap();
|
|
assert_eq!(svg["options"]["outlineText"]["value"], false, "another format shows its defaults");
|
|
let ids: Vec<&str> = 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("<?xml"));
|
|
assert_eq!(r["name"], "a.svg");
|
|
assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap()), "the native file isn't overwritten with SVG");
|
|
let _ = std::fs::remove_dir_all(d);
|
|
}
|
|
|
|
#[test]
|
|
fn save_a_copy_keeps_the_path_title_and_modified_state() {
|
|
let d = dir("copy");
|
|
let mut s = session();
|
|
let native = path(&d, "poster.vectorcraft");
|
|
s.execute("document.save", &json!({"path": native})).unwrap();
|
|
rect(&mut s);
|
|
let r = s.execute("file.saveCopy", &json!({})).unwrap();
|
|
assert_eq!((r["name"].as_str(), r["folder"].as_str()), (Some("poster copy.vectorcraft"), Some(d.to_string_lossy().as_ref())));
|
|
let copy = path(&d, "poster copy.svg");
|
|
s.execute("file.saveCopy", &json!({"path": copy})).unwrap();
|
|
assert!(std::fs::read_to_string(©).unwrap().starts_with("<?xml"));
|
|
let st = s.doc().unwrap();
|
|
assert_eq!((st.path.as_deref(), st.format, st.title()), (Some(native.as_str()), "vectorcraft", "poster.vectorcraft".to_string()));
|
|
assert!(st.is_dirty(), "a copy leaves the document modified");
|
|
let _ = std::fs::remove_dir_all(d);
|
|
}
|
|
|
|
#[test]
|
|
fn revert_reloads_the_saved_file_in_the_same_tab() {
|
|
let d = dir("revert");
|
|
let mut s = session();
|
|
rect(&mut s);
|
|
let native = path(&d, "a.vectorcraft");
|
|
s.execute("document.save", &json!({"path": native})).unwrap();
|
|
assert!(s.execute("file.revert", &json!({})).is_err(), "nothing to revert");
|
|
s.execute("file.new", &json!({})).unwrap();
|
|
s.set_active(0);
|
|
let uid = s.doc().unwrap().uid;
|
|
rect(&mut s);
|
|
rect(&mut s);
|
|
assert_eq!(objects(&s), 4);
|
|
s.execute("file.revert", &json!({})).unwrap();
|
|
let st = s.doc().unwrap();
|
|
assert_eq!((s.active_index(), s.documents().len(), objects(&s)), (Some(0), 2, 2));
|
|
assert!(!st.is_dirty() && st.history.undo.is_empty() && st.selection.is_empty());
|
|
assert_eq!((st.uid, st.path.as_deref()), (uid, Some(native.as_str())));
|
|
// With the file gone the document is left as it is, still modified.
|
|
rect(&mut s);
|
|
std::fs::remove_file(&native).unwrap();
|
|
assert!(s.execute("file.revert", &json!({})).is_err());
|
|
let st = s.doc().unwrap();
|
|
assert!(st.is_dirty());
|
|
assert_eq!((objects(&s), st.history.undo.len()), (3, 1));
|
|
// Never saved: nothing to revert to.
|
|
s.set_active(1);
|
|
rect(&mut s);
|
|
assert!(s.execute("file.revert", &json!({})).is_err());
|
|
let _ = std::fs::remove_dir_all(d);
|
|
}
|
|
|
|
#[test]
|
|
fn templates_suggest_a_name_in_the_templates_folder_and_open_untitled() {
|
|
let d = dir("tpl");
|
|
let mut s = session();
|
|
let source = path(&d, "flyer.vectorcraft");
|
|
s.execute("document.save", &json!({"path": source})).unwrap();
|
|
s.prefs.templates_folder = path(&d, "Templates");
|
|
let r = s.execute("file.saveAsTemplate", &json!({})).unwrap();
|
|
assert_eq!(r["name"], "flyer template.vctemplate");
|
|
assert_eq!(r["folder"].as_str(), Some(s.prefs.templates_folder.as_str()));
|
|
assert_ne!(r["name"].as_str(), std::path::Path::new(&source).file_name().and_then(|n| n.to_str()), "never the source file");
|
|
// Writing it leaves the document alone; opening it gives an untitled document.
|
|
rect(&mut s);
|
|
let tpl = path(&d, "flyer template.vctemplate");
|
|
s.execute("file.saveAsTemplate", &json!({"path": tpl})).unwrap();
|
|
assert_eq!(s.doc().unwrap().path.as_deref(), Some(source.as_str()));
|
|
assert!(s.doc().unwrap().is_dirty());
|
|
let r = s.execute("document.open", &json!({"path": tpl})).unwrap();
|
|
assert_eq!(r["format"], "template");
|
|
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
|
assert_eq!(s.doc().unwrap().path, None);
|
|
assert!(!s.doc().unwrap().doc.template);
|
|
// New from Template opens any file untitled.
|
|
let r = s.execute("file.newFromTemplate", &json!({"path": source})).unwrap();
|
|
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
|
assert_eq!(s.doc().unwrap().path, None);
|
|
// With no preference the folder is under the user's home (where there is one).
|
|
s.prefs.templates_folder.clear();
|
|
if let Some(f) = crate::cmd::fileio::templates_folder(&s.prefs) {
|
|
assert!(f.ends_with("VectorCraft Templates"), "{f}");
|
|
}
|
|
let _ = std::fs::remove_dir_all(d);
|
|
}
|
|
|
|
/// A native file as an older version wrote it: under the former name, or format version 1.
|
|
fn old_file(legacy: bool) -> Vec<u8> {
|
|
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<i64> = 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<String> = 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::<Vec<_>>(), [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<u64> = 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);
|
|
}
|