From d18499613ce38fc57f46b463409ac6a458aa0950 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:25:12 -0800 Subject: [PATCH] .ai save: an artboard the document does not have is refused, as an export refuses it A save as .ai or .ait (file.saveCopy, file.saveAs, document.save and file.saveAsTemplate) set range to "all" before the file was written. The one exception was a legacy .ai of version 7 given a range, which its encoder checks. Every other save accepted an artboard, artboards or range that names an artboard the document does not have (artboards: [5] on a document of three artboards) and wrote every artboard. document.export refuses the same value. The save now refuses such a value with the export's error, under the name of the command that was run, and writes no file. A range that is not text, such as range: 42, was also replaced by "all". The save now leaves it for the encoder, which refuses it as an export does. An artboard the document has, or an empty artboards list, is still saved with every artboard, and a legacy .ai still writes the artboards of its range. --- crates/engine/src/cmd/fileio/save.rs | 29 +++++++++++++- crates/engine/src/tests/legacy_ai.rs | 36 +++++++++++++++++ crates/engine/src/tests/saveoptions.rs | 53 ++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) 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");