.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.
This commit is contained in:
dotCooCoo
2026-11-04 13:16:24 +07:00
committed by bflatastic
parent a35f3a0fee
commit d18499613c
3 changed files with 117 additions and 1 deletions
+28 -1
View File
@@ -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::<ArtboardPick>(&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<i
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
let legacy = super::legacy_ai::wanted(cmd, plan.format, &params)?;
if matches!(plan.format.id, "ai" | "ait") {
named_artboards_exist(cmd, plan.format, &doc, &params)?;
}
if let (Some(o), "ai" | "ait") = (params.as_object_mut(), plan.format.id) {
// Save keeps every artboard; exports may choose a subset instead. A legacy .ai file per
// artboard keeps a range given: the artboards to save.
let chosen = legacy && plan.format.id == "ai" && o.get("range").and_then(Value::as_str).is_some_and(|r| !r.trim().is_empty());
if !chosen {
// A `range` that isn't text is left for the encoder to refuse, as an export refuses it.
let text = o.get("range").is_none_or(|r| r.is_string() || r.is_null());
if !chosen && text {
o.insert("range".into(), json!("all"));
}
}
+36
View File
@@ -525,6 +525,42 @@ fn a_range_saves_those_artboards_and_save_writes_them_again() {
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{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["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<u8> {
let img = image::RgbaImage::from_fn(32, 32, |x, y| image::Rgba(if (x < 16) == (y < 16) { a } else { b }));
+53
View File
@@ -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");