mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 23:35:10 +00:00
EPS Save: every file of a multi-artboard save is checked against the file read from
A save with Use Artboards writes a file per artboard beside the path. Each of them is now checked against the file the document was read from, before the first file is written, so a sibling cannot replace that file without acknowledgeLoss. The interface checks the files beside the path the same way.
This commit is contained in:
@@ -69,7 +69,6 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub(crate) use export::with_artboard_fills;
|
||||
pub(crate) use export::{export_crop, isolated};
|
||||
pub use export::{export_doc, export_source};
|
||||
pub(crate) use load::check_not_lossy_overwrite;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::is_placeholder;
|
||||
@@ -80,6 +79,7 @@ pub use load::{
|
||||
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
|
||||
read_for_open, texture_image,
|
||||
};
|
||||
pub(crate) use load::{check_files_not_lossy_overwrite, check_not_lossy_overwrite, lossy_source};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label};
|
||||
pub(crate) use mesh3d::{mesh_file_names, mesh_files};
|
||||
|
||||
@@ -993,13 +993,38 @@ fn is_loss(note: &str) -> bool {
|
||||
/// Another name keeps the original file, with all it has.
|
||||
pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> {
|
||||
let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) };
|
||||
Err(lossy_overwrite(target, losses, cmd))
|
||||
}
|
||||
|
||||
/// The refusal to write `target`, the file the document was read from, when reading it left out `losses`.
|
||||
fn lossy_overwrite(target: &str, losses: &[String], cmd: &str) -> EngineError {
|
||||
let what = losses_summary(losses);
|
||||
Err(bad(
|
||||
bad(
|
||||
cmd,
|
||||
format!(
|
||||
"{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway"
|
||||
),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// The file the document was read from and what reading it left out, when a write to that file
|
||||
/// needs `acknowledgeLoss` and `p` does not have it ([`overwrite_losses`]).
|
||||
pub(crate) fn lossy_source(st: &crate::DocState, p: &Value) -> Option<(String, Vec<String>)> {
|
||||
let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?;
|
||||
(!bool_or(p, "acknowledgeLoss", false)).then(|| (source.to_string(), st.import_losses.clone()))
|
||||
}
|
||||
|
||||
/// None of the files `targets` (every file a save writes, before any is written) is the
|
||||
/// `source` file that reading left `losses` out of ([`lossy_source`]).
|
||||
pub(crate) fn check_files_not_lossy_overwrite<'a>(
|
||||
source: &(String, Vec<String>),
|
||||
targets: impl IntoIterator<Item = &'a str>,
|
||||
cmd: &str,
|
||||
) -> Result<()> {
|
||||
match targets.into_iter().find(|t| same_file(&source.0, t)) {
|
||||
Some(target) => Err(lossy_overwrite(target, &source.1, cmd)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What writing the file `target` for document `st` would lose for good: the notes of what reading
|
||||
|
||||
@@ -99,6 +99,10 @@ pub struct SavePlan {
|
||||
/// `preview`, `precision`) given beside the path, and each key of `options`, that the format
|
||||
/// doesn't read, naming the file the save writes.
|
||||
pub notes: Vec<String>,
|
||||
/// The file the document was read from and what reading it left out, when the save needs
|
||||
/// `acknowledgeLoss` to write over it and was not given it: every file the save writes is
|
||||
/// checked against it before the first is written.
|
||||
pub lossy_source: Option<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
impl SavePlan {
|
||||
@@ -308,7 +312,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
|
||||
.map(|k| format!("`{k}` was not used: {} files don't read it", written.label))
|
||||
.collect();
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes })
|
||||
let lossy_source = super::lossy_source(st, p);
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
|
||||
}
|
||||
|
||||
/// The native save options, which a save also takes beside the path.
|
||||
@@ -899,6 +904,9 @@ impl SaveJob {
|
||||
return Ok(out);
|
||||
};
|
||||
let files = enc.named(doc, path);
|
||||
if let Some(source) = &plan.lossy_source {
|
||||
super::check_files_not_lossy_overwrite(source, files.iter().map(|(p, _)| p.as_str()), plan.mode.command())?;
|
||||
}
|
||||
for (p, bytes) in &files {
|
||||
write(p, bytes)?;
|
||||
}
|
||||
|
||||
@@ -905,6 +905,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
|
||||
folder: None,
|
||||
modified: None,
|
||||
notes: Vec::new(),
|
||||
lossy_source: None,
|
||||
};
|
||||
let save = fileio::job_for(s, st, plan)?;
|
||||
let meta = Meta {
|
||||
|
||||
@@ -525,6 +525,30 @@ fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() {
|
||||
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");
|
||||
|
||||
@@ -264,7 +264,9 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
|
||||
if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") {
|
||||
return ask_format_options(app, mode, first.format, &path);
|
||||
}
|
||||
return write_plan(app, first);
|
||||
// Only `path` was asked about: the files written beside it are checked against the file
|
||||
// the document was read from when the save writes them.
|
||||
return write_plan(app, plan(app, mode, p)?);
|
||||
}
|
||||
if is_web(app) && ask_options {
|
||||
return Ok(open_options(app, mode.command(), first.format, &first.name, true));
|
||||
@@ -291,7 +293,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
|
||||
crate::dialogs::opened_by_panel(app);
|
||||
return r;
|
||||
}
|
||||
write_plan(app, chosen)
|
||||
write_plan(app, plan(app, mode, &q)?)
|
||||
}
|
||||
|
||||
/// The UI command that runs a save in `mode`.
|
||||
|
||||
@@ -298,6 +298,37 @@ fn replacing_a_file_that_opening_left_things_out_of_asks_first() {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_does_not_replace_the_file_read_from_through_a_file_written_beside_the_path() {
|
||||
let d = dir("lossy-beside");
|
||||
let path = d.join("Art.eps").to_string_lossy().to_string();
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
rect(&mut app);
|
||||
let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false});
|
||||
let _ = app.run("file.saveAs", json!({"path": path, "options": options}));
|
||||
crate::background::wait_all(&mut app);
|
||||
let mut siblings: Vec<_> = std::fs::read_dir(&d)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("Art_")))
|
||||
.collect();
|
||||
siblings.sort();
|
||||
assert_eq!(siblings.len(), 2, "{siblings:?}");
|
||||
let sibling = siblings[0].to_string_lossy().to_string();
|
||||
let saved = std::fs::read(&sibling).unwrap();
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let st = app.session.doc_mut().unwrap();
|
||||
st.imported_from = Some(sibling.clone());
|
||||
st.import_losses = vec!["hidden type".into()];
|
||||
rect(&mut app);
|
||||
let _ = app.run("file.saveAs", json!({"path": path, "options": options}));
|
||||
crate::background::wait_all(&mut app);
|
||||
assert_eq!(std::fs::read(&sibling).unwrap(), saved);
|
||||
assert!(!std::path::Path::new(&path).exists(), "no file of the set is written when one is refused");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_saves_stream_into_the_file() {
|
||||
let d = dir("stream");
|
||||
|
||||
Reference in New Issue
Block a user