Merge pull request #5517 from dotCooCoo/fix/ai-save-missing-artboard

.ai save: an artboard the document does not have is refused, as an export refuses it
This commit is contained in:
BFlat
2026-11-04 13:36:27 +07:00
committed by GitHub
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");