Artboards: commands given an index that names no artboard fail instead of editing another one

artboard.setProps and artboard.fitToSelection read an `index` that is
negative, fractional, a string or null as 0 and edited the first
artboard, and artboard.setProps edited the first artboard when the
call named no `index`. artboard.duplicate, artboard.copy and
artboard.cut took the Artboard tool's artboard for such an `index`.
artboard.reorder failed for it with "missing index".

These commands now read `index` as artboard.delete, artboard.move and
artboard.fitToArt do, and fail with "no such artboard" when it names
no artboard; the document and the clipboard stay as they were.
artboard.setProps without `index` fails with "missing index". Without
`index`, artboard.fitToSelection still fits the first artboard and the
copy commands still take the Artboard tool's artboard.

Docs: docs/mcp.md.
This commit is contained in:
dotCooCoo
2026-10-17 13:20:23 +07:00
committed by bflatastic
parent 92647fa5b6
commit ee61583606
5 changed files with 69 additions and 8 deletions
+2 -2
View File
@@ -834,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(p, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
@@ -933,8 +933,8 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let st = s.doc()?;
let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? };
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
+17 -6
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
let i = artboard_index(&s.doc()?.doc, p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
@@ -70,10 +70,21 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy
/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option.
fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
/// An `index` that names no artboard is an error.
fn board_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(usize, bool)> {
let t = s.tool_options_of("artboard");
let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize;
(i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))
let i = match p.get("index") {
Some(_) => artboard_index(&s.doc()?.doc, p, cmd)?,
None => t["active"].as_u64().unwrap_or(0) as usize,
};
Ok((i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
}
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
/// [`artboard_indices`] (`missing index`, `no such artboard`).
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
}
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
@@ -106,7 +117,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
}
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let (i, art) = board_and_art(s, p, "artboard.duplicate")?;
let art = artboard_art(s, i, art)?;
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() };
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
@@ -119,7 +130,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut
/// then deletes them.
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let (i, art) = board_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
let art = artboard_art(s, i, art)?;
let st = s.doc()?;
if cut && st.doc.artboards.len() <= 1 {
+25
View File
@@ -736,6 +736,31 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
}
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names
/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as
/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
/// artboard.
#[test]
fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let (was, n) = (boards(&s), undo_len(&s));
for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] {
for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] {
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
}
}
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
assert_eq!(e, "invalid parameters for `artboard.setProps`: missing index");
assert_eq!((boards(&s), undo_len(&s)), (was.clone(), n), "the artboards and the undo steps as they were");
s.execute("artboard.fitToSelection", &json!({})).unwrap();
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 150.0, 150.0), was[1]], "the first artboard by default");
}
// ---------- Edit Colors ----------
#[test]
+21
View File
@@ -130,6 +130,27 @@ fn duplicate_artboard_copies_its_art() {
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
}
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
/// artboards, the art and the clipboard stay as they were.
#[test]
fn artboard_copies_refuse_an_index_that_names_no_artboard() {
let mut s = session();
rect(&mut s);
s.execute("artboard.new", &json!({})).unwrap();
s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap());
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] {
for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] {
let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) };
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
}
}
let st = s.doc().unwrap();
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboard.is_none(), "nothing copied");
}
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
/// it right of the last artboard, Paste in Place where it was, also in another document; each paste
/// is one undo step, and the tool takes the pasted artboard.
+4
View File
@@ -2287,6 +2287,10 @@ pastes only the art), in this document or another; the result's `artboard` is th
does the same in one step → `{index, ids}`, and `artboard.move {copy: true, moveArt: true}` is the Artboard tool's
Alt-drag. These two commands also move the pattern tiles of the copies when `patterns` is true (default: prefs
`transformPatternTiles`); `edit.paste` leaves the tiles of a pasted artboard's art where they were.
`artboard.copy`, `artboard.cut` and `artboard.duplicate` take the Artboard tool's artboard when the call names no
`index`. Every artboard command that takes an `index` fails with `no such artboard` when the `index` is not the
0-based index of an artboard (past the last one, negative, fractional, a string or null), and the document and the
clipboard stay as they were.
Scale Artwork with Artboard (#602): `artboard.setProps {index, x?, y?, width?, height?, scaleArt: true}` resizes an
artboard and scales the art fully inside it, and its artboard guides, from the old rectangle onto the new one, each side