Merge pull request #7623 from dotCooCoo/fix/artboard-history-index-wrong-type

MCP: a malformed artboard or history index is reported as not a whole number
This commit is contained in:
BFlat
2026-11-15 03:22:19 +07:00
committed by GitHub
10 changed files with 265 additions and 86 deletions
+5 -11
View File
@@ -6,8 +6,8 @@ use vectorcraft_doc::Scaling;
use vectorcraft_geom::{Rect, Vec2};
use super::object::choice;
use super::panelcmds::{artboard_index, artboards_art, locked_and_hidden, named_or_selected, note_artboards};
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, switch_or};
use super::panelcmds::{artboard_at, artboards_art, locked_and_hidden, named_or_selected, note_artboards, on_board};
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, index, switch_or};
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
@@ -63,13 +63,7 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> {
check_switches(p, &["patterns"], cmd)?;
let to_artboard = choice(p, cmd, "to", &["selection", "artboard"])? == Some("artboard");
let indices = named_or_selected(s, p, cmd)?;
let given = |key: &str| -> Result<Option<usize>> {
match p.get(key).filter(|v| !v.is_null()) {
Some(v) => artboard_index(&s.doc()?.doc, &json!({ "index": v }), cmd).map(Some),
None => Ok(None),
}
};
let (named, recorded) = (given("artboard")?, given("activeArtboard")?);
let (named, recorded) = (artboard_at(&s.doc()?.doc, p, "artboard", cmd)?, artboard_at(&s.doc()?.doc, p, "activeArtboard", cmd)?);
let move_art = switch_or(p, "moveArt", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true), cmd)?;
let locked_art = locked_and_hidden(s, p, move_art, cmd)?;
note_artboards(s, p, &indices);
@@ -78,8 +72,8 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> {
(false, _) => None,
(true, Some(i)) => Some(i),
(true, None) => {
let active = s.tool_options_of("artboard")["active"].clone();
Some(artboard_index(&s.doc()?.doc, &json!({ "index": active }), cmd)?)
let active = s.tool_options_of("artboard")["active"].as_u64().map(index);
Some(active.map_or_else(|| Err(EngineError::Other("no such artboard".into())), |i| on_board(&s.doc()?.doc, i))?)
}
};
if let Some(i) = reference.filter(|_| named.is_none()) {
+23 -18
View File
@@ -808,11 +808,11 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
let created = date_param(p, "created", C)?;
let st = s.doc()?;
// The row as the panel lists it now: keeping an edit in progress below changes the rows.
let chosen = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => {
let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row)));
Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone())
let chosen = match super::index_opt(p, "index", C)? {
None => None,
Some(row) => {
let v = &p["index"];
Some(st.history.state(&st.doc, row).ok_or_else(|| bad(C, format!("no history state {v}")))?.clone())
}
};
settle_interaction(s)?;
@@ -1276,23 +1276,28 @@ pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_
/// The artboards Paste in Place, in Front or in Back puts a copy on, in artboard order: `artboard`
/// (the active one, #693), else `artboards`, else, while the Artboard tool is chosen with several
/// artboards selected, those (noted in the journal as `artboards`, so a replay pastes on the same
/// ones); `None` when none is named. A named index that is not an artboard of the document, a
/// repeated one or an empty list is an error.
/// ones); `None` when none is named. A named value that is not a whole number (null included), a
/// named index that is not an artboard of the document, a repeated one or an empty list is an
/// error; every value is checked for its form before any is checked against the artboards.
fn paste_targets(s: &mut Session, p: &Value, cmd: &str) -> Result<Option<Vec<usize>>> {
let n = s.doc()?.doc.artboards.len();
let one = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < n);
let single = match p.get("artboard") {
Some(v) => Some(one(v).ok_or_else(|| bad(cmd, "`artboard` must be the index of an artboard of the document"))?),
None => None,
};
let listed = match p.get("artboards") {
let single = p.get("artboard").map(|v| v.as_u64().map(index).ok_or_else(|| bad(cmd, "`artboard` must be a whole number"))).transpose()?;
let entries = match p.get("artboards") {
Some(v) => {
let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`artboards` must be a list of artboard indices"))?;
let mut all = list
.iter()
.map(one)
.collect::<Option<Vec<usize>>>()
.ok_or_else(|| bad(cmd, "`artboards` names an artboard the document does not have"))?;
let all = list.iter().map(|i| i.as_u64().map(index)).collect::<Option<Vec<usize>>>();
Some(all.ok_or_else(|| bad(cmd, "`artboards` must contain whole numbers"))?)
}
None => None,
};
if single.is_some_and(|i| i >= n) {
return Err(bad(cmd, "`artboard` must be the index of an artboard of the document"));
}
let listed = match entries {
Some(mut all) => {
if all.iter().any(|i| *i >= n) {
return Err(bad(cmd, "`artboards` names an artboard the document does not have"));
}
all.sort_unstable();
if all.windows(2).any(|w| w[0] == w[1]) {
return Err(bad(cmd, "`artboards` names an artboard twice"));
+48 -23
View File
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun};
use vectorcraft_geom::{Rect, Vec2};
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or};
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, switch_or};
use crate::{Clipboard, EngineError};
pub fn specs() -> Vec<CommandSpec> {
@@ -137,19 +137,16 @@ pub(crate) fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result<Vec
return artboard_indices(d, p, cmd);
}
if let Some(recorded) = p.get("selectedArtboards") {
return artboard_indices(d, &json!({ "indices": recorded }), cmd);
return listed_artboards(d, recorded, "selectedArtboards", cmd);
}
let mut picked: Vec<u64> = t["selected"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
.collect();
let mut picked: Vec<usize> =
t["selected"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(index).filter(|i| *i < d.artboards.len()).collect();
if picked.is_empty() {
picked.push(t["active"].as_u64().unwrap_or(0));
picked.push(on_board(d, t["active"].as_u64().map_or(0, index))?);
}
artboard_indices(d, &json!({ "indices": picked }), cmd)
picked.sort_unstable();
picked.dedup();
Ok(picked)
}
/// Rename Artboards: one artboard takes the name, and several take it numbered in artboard order, in
@@ -221,23 +218,51 @@ fn artboard_switch_orientation(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "switched": switched }))
}
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
/// [`artboard_indices`] (`missing index`, `no such artboard`).
/// The one artboard `cmd` acts on: `index`, an artboard of `d`. Left out or null, it is
/// `missing index`; a value that is not a whole number is "`index` must be a whole number"; a
/// whole number past the last artboard is `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()))
artboard_at(d, p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))
}
/// The artboard of `d` that `key` of `p` names, or `None` when `p` leaves it out or gives null. A
/// value that is not a whole number is "`{key}` must be a whole number", and a whole number past the
/// last artboard is `no such artboard`.
pub(crate) fn artboard_at(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
super::index_opt(p, key, cmd)?.map(|i| on_board(d, i)).transpose()
}
/// `i` when it is an artboard of `d`, else `no such artboard`.
pub(crate) fn on_board(d: &vectorcraft_doc::Document, i: usize) -> Result<usize> {
match i < d.artboards.len() {
true => Ok(i),
false => Err(EngineError::Other("no such artboard".into())),
}
}
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
/// in order and once each.
/// in order and once each. `indices` is read as [`listed_artboards`] reads it, and `index` as
/// [`artboard_index`] reads it.
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let listed = match (p.get("indices"), p.get("index")) {
(Some(v), _) => point_indices(v),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(cmd, "missing index")),
};
let mut indices =
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
match p.get("indices") {
Some(v) => listed_artboards(d, v, "indices", cmd),
None => Ok(vec![artboard_index(d, p, cmd)?]),
}
}
/// `v`, the value of `key`, as artboards of `d`, in order and once each. A value that is not a list
/// is "`{key}` must be a list of artboard indices", an empty list is "`{key}` must be a non-empty
/// list of artboard indices", and a list with an entry that is not a whole number is "`{key}` must
/// contain whole numbers"; a whole number past the last artboard is `no such artboard`. Every entry
/// is checked for its form before any is checked against the artboards.
fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result<Vec<usize>> {
let entries = v.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be a list of artboard indices")))?;
if entries.is_empty() {
return Err(bad(cmd, format!("`{key}` must be a non-empty list of artboard indices")));
}
let listed = entries.iter().map(Value::as_u64).collect::<Option<Vec<u64>>>();
let listed = listed.ok_or_else(|| bad(cmd, format!("`{key}` must contain whole numbers")))?;
let mut indices = listed.into_iter().map(|i| on_board(d, index(i))).collect::<Result<Vec<usize>>>()?;
indices.sort_unstable();
indices.dedup();
Ok(indices)
+32 -7
View File
@@ -1034,14 +1034,39 @@ fn in_place_pastes_put_a_copy_on_each_artboard_named() {
// `artboard` wins over `artboards`.
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2, "artboards": [0, 3]})).unwrap());
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 5.0), "{cmd}");
// A list that is empty, names an artboard the document lacks or names one twice, or isn't a
// list: an error, and nothing pasted.
let before = doc(&s).clone();
for bad in [json!([]), json!([0, 4]), json!([1, 1]), json!([0, -1]), json!(2), json!("0,1")] {
assert!(s.execute(cmd, &json!({"artboards": bad})).is_err(), "{cmd} with artboards {bad}");
// A list that is empty, names an artboard the document lacks or names one twice, holds a
// value that is not a whole number, or isn't a list, and an `artboard` that is not a whole
// number or not an artboard: an error naming the fault, and nothing pasted. Both keys are
// checked when both are given.
let (before, undo) = (doc(&s).clone(), s.doc().unwrap().history.undo.len());
let (not_list, missing, not_whole) = (
"`artboards` must be a list of artboard indices",
"`artboards` names an artboard the document does not have",
"`artboards` must contain whole numbers",
);
let cases = [
(json!({"artboards": []}), not_list),
(json!({"artboards": 2}), not_list),
(json!({"artboards": "0,1"}), not_list),
(json!({"artboards": [0, 4]}), missing),
(json!({"artboards": [1, 1]}), "`artboards` names an artboard twice"),
(json!({"artboards": [0, -1]}), not_whole),
(json!({"artboards": [0, 1.5]}), not_whole),
(json!({"artboards": [4, "1"]}), not_whole),
(json!({"artboard": 1, "artboards": [7]}), missing),
(json!({"artboard": 1, "artboards": ["1"]}), not_whole),
(json!({"artboard": 4}), "`artboard` must be the index of an artboard of the document"),
(json!({"artboard": -1}), "`artboard` must be a whole number"),
(json!({"artboard": 1.5}), "`artboard` must be a whole number"),
(json!({"artboard": "1"}), "`artboard` must be a whole number"),
(json!({"artboard": null}), "`artboard` must be a whole number"),
(json!({"artboard": "1", "artboards": [9]}), "`artboard` must be a whole number"),
];
for (p, want) in cases {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert_eq!(e, format!("invalid parameters for `{cmd}`: {want}"), "{cmd} {p}");
}
assert!(s.execute(cmd, &json!({"artboard": 1, "artboards": [7]})).is_err(), "{cmd}: a bad list with `artboard`");
assert_eq!(doc(&s), &before, "{cmd}");
assert_eq!((doc(&s), s.doc().unwrap().history.undo.len()), (&before, undo), "{cmd}");
}
}
+16 -5
View File
@@ -79,18 +79,29 @@ fn the_source_stays_as_it_is() {
assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved");
}
/// An index that names no state, or is not a whole number, is an error and changes nothing: no
/// document, no step and no Untitled number is used up.
/// An index that names no state (`no history state N`), or is not a whole number ("`index` must be
/// a whole number"), is an error and changes nothing: no document, no step and no Untitled number
/// is used up.
#[test]
fn a_bad_index_is_an_error_and_changes_nothing() {
let mut s = three_rectangles_one_undone();
let source = s.doc().unwrap().doc.clone();
let untitled = s.peek_untitled();
let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)];
for index in bad {
let bad: [(Value, bool); 8] = [
(json!(4), true),
(json!(99), true),
(json!(u64::MAX), true),
(json!(-1), false),
(json!(1.5), false),
(json!("1"), false),
(json!(true), false),
(json!([1]), false),
];
for (index, whole) in bad {
let e = s.execute(CMD, &json!({ "index": index })).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}");
assert!(e.to_string().contains(&format!("no history state {index}")), "{e}");
let want = if whole { format!("no history state {index}") } else { "`index` must be a whole number".to_string() };
assert_eq!(e.to_string(), format!("invalid parameters for `{CMD}`: {want}"), "{index}");
assert_eq!(s.documents().len(), 1, "{index}");
assert_eq!(steps(&s), (2, 1), "{index}");
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}");
+10 -7
View File
@@ -611,15 +611,18 @@ fn edit_copy_and_cut_read_the_recorded_artboards() {
s.execute("select.none", &json!({})).unwrap();
assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}");
let before = documents(&s);
for bad in [
json!({"selectedArtboards": [9]}),
json!({"selectedArtboards": []}),
json!({"selectedArtboards": "1"}),
json!({"selectedArtboards": null}),
json!({"selectedArtboards": [1], "art": "yes"}),
for (bad, want) in [
(json!({"selectedArtboards": [9]}), "no such artboard"),
(json!({"selectedArtboards": []}), "`selectedArtboards` must be a non-empty list of artboard indices"),
(json!({"selectedArtboards": "1"}), "`selectedArtboards` must be a list of artboard indices"),
(json!({"selectedArtboards": null}), "`selectedArtboards` must be a list of artboard indices"),
(json!({"selectedArtboards": [1, "0"]}), "`selectedArtboards` must contain whole numbers"),
(json!({"selectedArtboards": [1, -1]}), "`selectedArtboards` must contain whole numbers"),
(json!({"selectedArtboards": [1], "art": "yes"}), "`art` must be true or false"),
] {
let err = s.execute(id, &bad).unwrap_err().to_string();
assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}");
assert!(err.ends_with(want), "{id} {bad}: {err}");
assert!(err.contains(id) || want == "no such artboard", "{id} {bad}: {err}");
assert_eq!(documents(&s), before, "{id} {bad}");
assert!(s.clipboard.artboards.is_empty(), "{id} {bad}");
}
+13 -5
View File
@@ -1979,9 +1979,10 @@ 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 Options and Fit to Selected Art fail with `no such artboard` on a whole-number `index`
/// past the last artboard, with "`index` must be a whole number" on one that is negative,
/// fractional or a string, and with `missing index` on a null one, 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() {
@@ -1992,9 +1993,16 @@ fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboa
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 (index, error) in [
(json!(99), None),
(json!(-1), Some("`index` must be a whole number")),
(json!(1.5), Some("`index` must be a whole number")),
(json!("1"), Some("`index` must be a whole number")),
(Value::Null, Some("missing index")),
] {
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 want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}"));
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
}
}
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
+103 -5
View File
@@ -492,9 +492,11 @@ fn new_and_renamed_artboards_trim_their_names() {
assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]);
}
/// 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.
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on a
/// whole-number `index` past the last artboard, with "`index` must be a whole number" on one that
/// is negative, fractional, a string, true or a list, and with `missing index` on a null one,
/// instead of acting on the Artboard tool's active artboard. 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();
@@ -502,10 +504,22 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
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] {
let not_whole = Some("`index` must be a whole number");
let cases = [
(json!(99), None),
(json!(u64::MAX), None),
(json!(-1), not_whole),
(json!(1.5), not_whole),
(json!("1"), not_whole),
(json!(true), not_whole),
(json!([1]), not_whole),
(serde_json::Value::Null, Some("missing index")),
];
for (index, error) in cases {
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 want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}"));
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
}
}
let st = s.doc().unwrap();
@@ -513,6 +527,90 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
}
/// The artboard commands that take `indices` (and `selectedArtboards`, a replay of the journal)
/// name the fault: a value that is not a list, an empty list and a list with an entry that is not a
/// whole number each have their own message naming the key, and a whole number past the last
/// artboard is `no such artboard`, also when another entry is malformed after it. The artboards,
/// the art, the undo history and the clipboard stay as they were.
#[test]
fn artboard_lists_name_the_fault_and_the_key() {
let mut s = session();
rect(&mut s);
s.execute("artboard.new", &json!({})).unwrap();
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
let cmds = [
("artboard.duplicate", json!({})),
("artboard.copy", json!({})),
("artboard.cut", json!({})),
("artboard.rename", json!({"name": "A"})),
("artboard.switchOrientation", json!({})),
("artboard.delete", json!({})),
("artboard.fitToArt", json!({})),
("artboard.move", json!({"dx": 10, "dy": 0})),
("artboard.align", json!({"horizontal": "left"})),
];
for key in ["indices", "selectedArtboards"] {
let cases = [
(json!(null), format!("`{key}` must be a list of artboard indices")),
(json!(1), format!("`{key}` must be a list of artboard indices")),
(json!("0,1"), format!("`{key}` must be a list of artboard indices")),
(json!([]), format!("`{key}` must be a non-empty list of artboard indices")),
(json!([0, -1]), format!("`{key}` must contain whole numbers")),
(json!([0, 1.5]), format!("`{key}` must contain whole numbers")),
(json!(["1"]), format!("`{key}` must contain whole numbers")),
(json!([null]), format!("`{key}` must contain whole numbers")),
(json!([9, "1"]), format!("`{key}` must contain whole numbers")),
(json!([0, 9]), "no such artboard".to_string()),
];
for (cmd, base) in &cmds {
if key == "selectedArtboards" && matches!(*cmd, "artboard.delete" | "artboard.fitToArt" | "artboard.move") {
continue;
}
for (value, want) in &cases {
let mut p = base.clone();
p[key] = value.clone();
let want = if want.starts_with('`') { format!("invalid parameters for `{cmd}`: {want}") } else { want.clone() };
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{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.artboards.is_empty(), "nothing copied");
}
/// Align, Distribute and Distribute Spacing for artboards name the key that holds a malformed
/// artboard to align to (`artboard` or `activeArtboard`), not `index`; a null one is left out, and
/// a whole number past the last artboard is `no such artboard`.
#[test]
fn artboard_align_names_the_key_of_a_malformed_reference() {
let mut s = session();
s.execute("artboard.new", &json!({})).unwrap();
s.execute("artboard.new", &json!({})).unwrap();
let cmds = [
("artboard.align", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})),
("artboard.distribute", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})),
("artboard.distributeSpacing", json!({"indices": [0, 1, 2], "to": "artboard"})),
];
for (cmd, base) in &cmds {
for key in ["artboard", "activeArtboard"] {
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
for value in [json!(-1), json!(1.5), json!("1"), json!(true)] {
let mut p = base.clone();
p[key] = value;
let want = format!("invalid parameters for `{cmd}`: `{key}` must be a whole number");
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
}
let mut p = base.clone();
p[key] = json!(9);
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
assert_eq!((boards(&s), s.doc().unwrap().history.undo.len()), (was, n), "{cmd} {key}: nothing moved");
p[key] = json!(null);
s.execute(cmd, &p).unwrap();
}
}
}
/// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an
/// option given as text or as another value that is not true or false, such as "true", and
/// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The
+14 -5
View File
@@ -667,7 +667,7 @@ sampler.
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted or null `index` uses the current state. An `index` that names no state fails with `no history state N`, one that is not a whole number with `` `index` must be a whole number ``, and either changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. |
For example, create editable Japanese area type with the engine's vertical writing mode:
@@ -6799,7 +6799,10 @@ copies in artboard order); `artboard` wins when both are given. Without either,
several artboards selected, they paste a copy on each of those, and the journal records them as `artboards`; a
recorded action leaves both out. Art copied from no artboard keeps its place on the first artboard named and its
offset to that one on the others. An index the document does not have, a repeated index, a value that is not an index
or an empty list fails, and nothing is pasted. Copied artboards come back once, where they were, whichever artboards
or an empty list fails, and nothing is pasted. An `artboard` that is not a whole number, null included, fails with
`` `artboard` must be a whole number ``, and an `artboards` entry that is not one with
`` `artboards` must contain whole numbers ``; both keys are checked when both are given. Copied artboards come back
once, where they were, whichever artboards
are named. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`)
pastes objects back into the layers and sublayers they came from: in the same document while those are still there,
else by the names of the layer and the layers around it (the topmost chain that matches the most names, a shown and
@@ -6842,9 +6845,15 @@ also move the pattern tiles of the copies when `patterns` is true (default: pref
`artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard.move` and `artboard.rearrange` take `lockedAndHidden?`
(default: prefs `moveLockedWithArtboard`), and with the Artboard tool chosen `edit.copy` and `edit.cut` pass it on. When
art goes along, the journal records the value, so a replay under another preference takes the same art.
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.
Every artboard command that takes an `index` fails with `no such artboard` when the `index` is a whole number past
the last artboard, with `` `index` must be a whole number `` when it is negative, fractional, a string or another value
that is not a whole number, and with `missing index` when it is null, also in a command that picks an artboard when
`index` is left out. An `indices` (or a replayed `selectedArtboards`) that is not a list fails with
`` `indices` must be a list of artboard indices ``, an empty one with
`` `indices` must be a non-empty list of artboard indices ``, one with an entry that is not a whole number with
`` `indices` must contain whole numbers ``, each naming its key, and one with a whole number past the last artboard
with `no such artboard`. `artboard.align`, `artboard.distribute` and `artboard.distributeSpacing` name `artboard` or
`activeArtboard` the same way. The document and the clipboard stay as they were.
These artboard commands refuse a string, a number or another value that is not true or false with
`` `art` must be true or false ``, naming the key, and leave the document, its undo history and the clipboard as they
were: `moveArt`, `scaleArt`, `strokes`, `corners`, `patterns`, `lockedAndHidden` and an artboard's `locked`,
+1
View File
@@ -1144,6 +1144,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
`path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size.
`path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`.
`path.setHandle`, `path.moveAnchor`, `text.setPathHandle` (`anchor`), `path.insertAnchor` (`segment`), `artboard.reorder`, `library.moveGroup`, `graphicStyle.move` (`to`), `path.appendAnchor`, `path.close`, `path.reverse` (`subpath`) and the ruler origin commands (`artboard`) refuse a value that is not a whole number of 0 or more, such as `"3"` or `2.5`, with `` `anchor` must be a whole number ``, naming the key, instead of reporting it as missing or reading it as the default subpath or artboard; a null `artboard` is artboard 0 (`tests::params::a_malformed_index_is_not_missing_or_its_default`).
The artboard commands that take `index`, `indices` or a replayed `selectedArtboards`, the artboard Align and Distribute commands (`artboard`, `activeArtboard`), `edit.newDocumentFromState` (`index`) and Paste in Place, in Front and in Back (`artboard`, `artboards`) report a value that is not a whole number as such, naming the key, and keep `no such artboard`, `no history state N` and the paste range messages for a whole number that names nothing; a null artboard `index` is `missing index`, and an `indices` value that is not a list, an empty list and a malformed entry each have their own message (`tests::panelcmds::artboard_lists_name_the_fault_and_the_key`).
`path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged.
Direct Selection's marquee selects the frame anchors of area type that lie inside it, and a click on a frame anchor selects that one; Shift toggles them. The selected frame anchors show filled. A drag of one of them moves all of them, the arrow keys nudge them, and the type reflows at its size (`text.reshapeArea`, which takes `items` to move the anchors of several frames in one step), as [Create text](http://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) and [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describe. When the selection also holds other objects, path anchors or ruler guides, or when another change to the document or the selection follows the pick, the type is selected whole.
A press on a selected object's centre point (Attributes panel › Show Center) selects the object whole, and a drag from it moves the object as one Move, held by that point (#4891; test `the_centre_point_selects_and_moves_the_object_whole`).