diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index cb513e316..b594c51b8 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -608,6 +608,28 @@ fn named_for_save(cmd: &str, e: crate::EngineError) -> crate::EngineError { } } +/// Refuse the artboards a `.ai` or `.ait` save is given (`artboard`, `artboards`, or a `range` +/// that isn't empty) when one of them is not in `doc`, with the error an export gives. The save +/// then writes every artboard, or a legacy `.ai` file's `range`, whatever `artboard` and +/// `artboards` name. An empty `artboards` list is not refused: the save writes every artboard. +fn named_artboards_exist(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<()> { + let n = doc.artboards.len(); + // The encoders refuse a document without artboards themselves. + if n == 0 { + return Ok(()); + } + // The encoders refuse a value of the wrong type themselves, each with its own label. + let Ok(mut picked) = super::encode::options::(&written_as(cmd, f, p), p) else { + return Ok(()); + }; + picked.range = picked.range.filter(|r| !r.trim().is_empty()); + if picked.range.is_none() && picked.artboards.as_ref().is_some_and(Vec::is_empty) { + return Ok(()); + } + picked.resolve(n).map_err(|e| bad(cmd, e))?; + Ok(()) +} + /// The `.ai` option `editingData`: whether the file also carries the editing copy of the art that /// other apps read (default on). #[derive(Default, serde::Deserialize)] @@ -736,11 +758,16 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option String { + match s.execute(cmd, p) { + Err(EngineError::BadParams { cmd: c, msg }) if c == cmd => msg, + other => panic!("{cmd} {p}: {:?}", other.map(|r| r["files"].as_array().map(Vec::len))), + } +} + +#[test] +fn an_artboard_the_document_does_not_have_is_refused_as_an_export_refuses_it() { + let dir = Folder::new("ai7-no-artboard"); + let mut s = three_boards(); + let path = dir.file("Poster.ai"); + for pick in [json!({"artboards": [5]}), json!({"artboards": [0, 3]}), json!({"artboard": 3}), json!({"range": "4"})] { + let mut p = json!({"format": "ai", "aiVersion": "7"}); + p.as_object_mut().unwrap().extend(pick.as_object().unwrap().clone()); + let export = refusal(&mut s, "document.export", &p); + assert!(export.starts_with("no artboard") || export.starts_with("range `4`"), "{p}: {export}"); + assert_eq!(refusal(&mut s, "file.saveCopy", &p), export, "{p}"); + p["path"] = json!(path); + assert_eq!(refusal(&mut s, "file.saveAs", &p), export, "{p}"); + assert_eq!(refusal(&mut s, "document.save", &p), export, "{p}"); + } + // A `range` that isn't text is refused as an export refuses it. + let p = json!({"format": "ai", "aiVersion": "7", "range": 42}); + assert_eq!(refusal(&mut s, "file.saveCopy", &p), refusal(&mut s, "document.export", &p)); + assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 0, "nothing is written"); + // An artboard the document has is saved with the artboards of the range: every one. + let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "artboards": [2]})).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(3), "{r}"); + // Once that artboard is deleted, Save is not refused for it. + s.execute("artboard.delete", &json!({"index": 2})).unwrap(); + let r = s.execute("document.save", &json!({})).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}"); +} + /// A 32×32 PNG checkerboard of 16-pixel squares: `a` top left and bottom right, `b` elsewhere. fn checker(a: [u8; 4], b: [u8; 4]) -> Vec { let img = image::RgbaImage::from_fn(32, 32, |x, y| image::Rgba(if (x < 16) == (y < 16) { a } else { b })); diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index 130a7da18..9324b1f4b 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -244,6 +244,59 @@ fn ai_option_errors_name_a_file_saved_without_pdf_content() { assert!(r["warnings"].to_string().contains("`version` was not used: PDF-compatible .ai files don't read it"), "{r}"); } +/// The message of a refused command's error. +fn refusal(s: &mut Session, cmd: &str, p: &Value) -> String { + match s.execute(cmd, p) { + Err(EngineError::BadParams { cmd: c, msg }) if c == cmd => msg, + other => panic!("{cmd} {p}: {:?}", other.map(|r| r["bytes"].clone())), + } +} + +#[test] +fn an_ai_save_refuses_an_artboard_the_document_does_not_have_as_an_export_does() { + let dir = Folder::new("ai-no-artboard"); + let mut s = three_boards(); + let path = dir.file("x.ai"); + let missing = [ + json!({"artboards": [5]}), + json!({"artboards": [0, 3]}), + json!({"artboard": 3}), + json!({"range": "4"}), + json!({"separateArtboards": true, "artboards": [5]}), + ]; + let wrong_type = [json!({"artboard": "x"}), json!({"artboards": ["x"]}), json!({"range": 42})]; + for pdf in [true, false] { + for pick in missing.iter().chain(&wrong_type) { + let mut p = json!({"format": "ai", "pdfCompatible": pdf}); + p.as_object_mut().unwrap().extend(pick.as_object().unwrap().clone()); + let export = refusal(&mut s, "document.export", &p); + p["path"] = json!(path); + for cmd in ["file.saveCopy", "file.saveAs", "document.save"] { + assert_eq!(refusal(&mut s, cmd, &p), export, "{cmd} {p}"); + } + // A template is not exported: its errors name the template where they name the format. + p["format"] = json!("ait"); + let template = refusal(&mut s, "file.saveAsTemplate", &p); + if missing.contains(pick) { + assert_eq!(template, export, "{p}"); + } else { + assert!(template.contains("invalid type"), "{p}: {template}"); + } + } + } + assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 0, "nothing is written"); + // An artboard the document has, an empty `artboards` list, or `artboards` that a `range` + // overrides is saved with every artboard, as before. + for pick in [json!({"artboards": [1]}), json!({"artboards": []}), json!({"range": "1", "artboards": [5]})] { + let mut p = json!({"format": "ai"}); + p.as_object_mut().unwrap().extend(pick.as_object().unwrap().clone()); + let r = s.execute("file.saveCopy", &p).unwrap(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "x.ai", "dataBase64": r["dataBase64"]})).unwrap(); + assert_eq!(o.doc().unwrap().doc.artboards.len(), 3, "{p}"); + } +} + #[test] fn a_remembered_artboard_the_document_no_longer_has_is_dropped() { let dir = Folder::new("remembered-artboards");