Commands: a boolean or number given as text is refused

Squashed from #3428 onto main.
This commit is contained in:
bflatastic
2026-10-22 00:10:57 +07:00
parent 14f804cdee
commit e7cdf154ca
32 changed files with 1467 additions and 105 deletions
+1
View File
@@ -548,6 +548,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = index_param(p, "index", "appearance.setItem")?;
super::paint::check_keep_model(p, "appearance.setItem")?;
let paint = paint_from(s, p)?;
let ids = appearance_targets(s, p)?;
if ids.is_empty() {
+1
View File
@@ -1301,6 +1301,7 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) {
return Err(bad(C, format!("unknown tool `{tool}`")));
}
super::paint::check_keep_model(p, C)?;
let pts = points(p, C)?;
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0;
+2
View File
@@ -752,6 +752,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.fill";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
super::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let group = id_param(p, "group");
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
@@ -781,6 +782,7 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.strokeEdge";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
super::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0));
if !(0.0..=1000.0).contains(&width) {
+1
View File
@@ -494,6 +494,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let x = f64_req(p, "x", "text.create")?;
let y = f64_req(p, "y", "text.create")?;
super::paint::check_keep_model(p, "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
+43 -21
View File
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Liquify",
[],
None,
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
has_doc,
liquify
),
@@ -300,7 +300,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
return Err(bad(C, "left/right must be non-negative widths"));
}
let adjust = bool_or(p, "adjustAdjoining", false);
let adjust = switch_or(p, "adjustAdjoining", false, C)?;
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
// history stay as they are.
let mut pos = 0;
@@ -405,7 +405,7 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Array(a)) => {
let mut pts = vec![];
for q in a {
let v: Vec<f64> = q.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let v: Vec<f64> = q.as_array().and_then(|x| x.iter().map(Value::as_f64).collect()).unwrap_or_default();
if v.len() != 3 || v.iter().any(|x| !x.is_finite()) || v[1] < 0.0 || v[2] < 0.0 {
return Err(bad(C, "points must be [t, left, right] with non-negative widths"));
}
@@ -632,9 +632,17 @@ impl LiquifyStroke {
}
}
/// The number keys of `object.liquify`. [`LiquifyParams::from_json`] reads a value that is not a
/// number as the key left out.
const LIQUIFY_NUMBERS: &[&str] =
&["diameter", "width", "height", "angle", "intensity", "detail", "simplify", "rate", "complexity", "horizontal", "vertical"];
fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.liquify";
check_numbers(p, LIQUIFY_NUMBERS, C)?;
let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?;
// `LiquifyParams::from_json` uses a key's default for a value that is not true or false.
check_switches(p, &["usePressure", "simplifyOn", "affectAnchors", "affectIn", "affectOut"], C)?;
let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?;
if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) {
return Err(bad(C, "points must be finite"));
@@ -701,9 +709,10 @@ fn puppet_ids(p: &Value, default: impl FnOnce() -> Result<Vec<NodeId>>) -> Resul
fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.puppetWarp";
check_numbers(p, &["expand"], C)?;
let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?;
let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?;
let rest = bool_or(p, "rest", false);
let rest = switch_or(p, "rest", false, C)?;
if (pins.is_empty() && !rest) || pins.len() != moved.len() {
return Err(bad(C, "pins and moved must be non-empty lists of the same length"));
}
@@ -715,7 +724,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(),
Some(Value::Array(a)) if a.len() == pins.len() && a.iter().all(|v| v.is_null() || v.is_number()) => {
a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect()
}
Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")),
};
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
@@ -756,6 +767,7 @@ pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
}
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["expand"], "object.puppetWarp.pins")?;
let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?;
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
@@ -798,9 +810,10 @@ pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> R
}
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.set";
let old = grid_of(&s.doc()?.doc);
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let reproject = bool_or(p, "reproject", false);
let g = old.merged(p).map_err(|e| bad(C, e))?;
let reproject = switch_or(p, "reproject", false, C)?;
// When the document holds this grid already, no object moves, and the document and its undo
// history stay as they are.
if grid_kept(&s.doc()?.doc, &g) {
@@ -823,7 +836,12 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
}
fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
let want = p.get("visible").and_then(Value::as_bool);
const C: &str = "perspective.grid.show";
// A missing or null `visible` toggles the grid.
let want = match p.get("visible") {
None | Some(Value::Null) => None,
Some(_) => Some(switch_or(p, "visible", false, C)?),
};
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
Ok(json!({ "visible": g.visible }))
}
@@ -1037,7 +1055,9 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "from and to must be finite"));
}
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false));
let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?);
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
check_switches(p, &["snap"], C)?;
// Snap to Grid: a correction for the objects on the first one's plane and depth.
let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
@@ -1088,13 +1108,13 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.transform";
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
let dz = f64_or(p, "depth", 0.0);
let dz = number_or(p, "depth", 0.0, C)?;
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
}
let ids = roots(s, p)?;
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let copy = bool_or(p, "copy", false);
let copy = switch_or(p, "copy", false, C)?;
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
let to = depth + dz;
if to.abs() > persp::MAX_DEPTH {
@@ -1126,6 +1146,7 @@ pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.plane.move";
check_numbers(p, &["offset", "by"], C)?;
let old = grid_of(&s.doc()?.doc);
let plane = plane_param(&old, p, C)?;
let from = old.offset(plane);
@@ -1223,11 +1244,12 @@ fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.nudge";
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
return Err(bad(C, "dx and dy give the arrow's direction"));
}
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 };
let copy = switch_or(p, "copy", false, C)?;
let ids = selected_roots(s)?;
// The step is measured on the page at the centre of the selection's perspective box.
let st = s.doc()?;
@@ -1235,10 +1257,7 @@ fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
persp_move(
s,
&json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}),
)
persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy}))
}
fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1247,11 +1266,14 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, C)?;
let snap = bool_or(p, "snap", g.snap);
let snap = switch_or(p, "snap", g.snap, C)?;
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
let map = match point_param(p, "at") {
Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?),
None => None,
let map = match p.get("at") {
None | Some(Value::Null) => None,
Some(v) => {
let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?)
}
};
let undo_before = s.doc()?.history.undo.len();
let r = s.execute(&command, &params)?;
+40 -29
View File
@@ -152,24 +152,40 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------- helpers ----------
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
/// `[[x,y]…]` or `[{x,y}…]`.
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
}
/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite
/// number.
fn point_entry(v: &Value, cmd: &str) -> Result<Point> {
let xy = match v {
Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)),
Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)),
_ => None,
};
xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers"))
}
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
/// `[{x,y,pressure}…]` (0..1).
/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a
/// number nor null, is an error.
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
let pts: Vec<(Point, Option<f64>)> = a
.iter()
.filter_map(|v| match v {
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
_ => None,
.map(|v| {
let pressure = match v {
Value::Array(xy) => xy.get(2),
_ => v.get("pressure"),
};
match pressure {
None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))),
Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))),
}
})
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
.collect();
.collect::<Result<_>>()?;
if pts.is_empty() {
return Err(bad(cmd, "need at least one point"));
}
@@ -648,7 +664,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let brush = str_param(p, "style") == Some("brush");
let keep_selected = bool_or(p, "keepSelected", true);
@@ -724,7 +740,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
let within = f64_or(rd, "within", 12.0);
let within = number_or(rd, "within", 12.0, C)?;
if !within.is_finite() || within < 0.0 {
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
}
@@ -764,16 +780,8 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.curvature";
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
let pts: Vec<(Point, bool)> = arr
.iter()
.filter_map(|v| {
let pt = match v {
Value::Array(xy) => Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?),
_ => Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?),
};
(pt.x.is_finite() && pt.y.is_finite()).then(|| (pt, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))
})
.collect();
let pts: Vec<(Point, bool)> =
arr.iter().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
if pts.is_empty() {
return Err(bad(C, "need at least one point"));
}
@@ -807,6 +815,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
check_numbers(p, &["x", "y", "t"], C)?;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
@@ -980,9 +989,11 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
check_numbers(p, &["x", "y"], C)?;
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => Some(Point::new(x, y)),
_ => None,
(None, None) => None,
_ => return Err(bad(C, "x and y must be given together")),
};
if target.is_some_and(|q| !within_input_bound(q)) {
return Err(bad(C, "x and y must be finite"));
@@ -1014,8 +1025,8 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
return Err(bad(C, "invalid delta"));
}
@@ -1132,7 +1143,7 @@ fn knife(s: &mut Session, p: &Value) -> Result<Value> {
fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.eraseRegion";
let pts = points_param(p, "points", C)?;
let size = f64_or(p, "size", 10.0);
let size = number_or(p, "size", 10.0, C)?;
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
@@ -1174,7 +1185,7 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>)
fn erase_segments(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.eraseSegments";
let pts = points_param(p, "points", C)?;
let w = f64_or(p, "width", 8.0);
let w = number_or(p, "width", 8.0, C)?;
if !(w.is_finite() && w > 0.0) {
return Err(bad(C, "width must be positive"));
}
@@ -1317,8 +1328,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.smoothRegion";
let pts = points_param(p, "points", C)?;
let radius = f64_or(p, "radius", 12.0).max(0.0);
let tol = f64_or(p, "fidelity", 2.5).clamp(0.05, 100.0);
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(0.05, 100.0);
let st = s.doc()?;
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
let mut plan = vec![];
@@ -1365,7 +1376,7 @@ fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.joinScrub";
let pts = points_param(p, "points", C)?;
let tol = f64_or(p, "tolerance", 12.0).max(0.0);
let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0);
let st = s.doc()?;
// Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start).
let poly = {
@@ -1464,7 +1475,7 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
fn blob(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.blob";
let pts = points_param(p, "points", C)?;
let size = f64_or(p, "size", 10.0);
let size = number_or(p, "size", 10.0, C)?;
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
+2
View File
@@ -244,6 +244,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Bool(true)) => s.selected_freeform_point(),
Some(_) => return Err(bad(C, "`line` must be true or false")),
};
super::paint::check_keep_model(p, C)?;
let given = given_color(s, p);
let index = edit(s, p, C, |f, sp| {
let q = sp.to_paint * at;
@@ -266,6 +267,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.setPoint";
let i = point_index(s, p, C)?;
super::paint::check_keep_model(p, C)?;
let given = given_color(s, p);
edit(s, p, C, |f, sp| {
check_index(f, i)?;
+1
View File
@@ -381,6 +381,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
};
super::paint::check_keep_model(p, C)?;
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
let stored = stop_colors(s, p, p);
// Stops naming swatches take their colours from them.
+18 -12
View File
@@ -411,6 +411,7 @@ const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
check_numbers(p, &["value", "steps", "distance"], cmd)?;
let smooth = switch_or(p, "smooth", false, cmd)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
let mode = str_param(p, "spacing");
@@ -428,7 +429,7 @@ fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpa
steps("steps", n)?
} else if let Some(d) = num("distance") {
BlendSpacing::Distance(d)
} else if bool_or(p, "smooth", false) {
} else if smooth {
BlendSpacing::SmoothColor
} else {
current
@@ -643,7 +644,7 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
Some("in") => Some(false),
Some(_) => return Err(bad(C, "handle must be in or out")),
};
let independent = bool_or(p, "independent", false);
let independent = switch_or(p, "independent", false, C)?;
edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| {
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
if let Some(out) = handle {
@@ -697,12 +698,12 @@ fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.reshapeSegment";
let seg = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
return Err(bad(C, "t, dx and dy must be finite"));
}
let perpendicular = bool_or(p, "perpendicular", false);
let perpendicular = switch_or(p, "perpendicular", false, C)?;
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
@@ -991,10 +992,11 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
bend = f64_or(p, "bend", bend).clamp(-100.0, 100.0);
h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0);
v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0);
let given = switch_or(p, "horizontal", horizontal, cmd)?;
horizontal = match str_param(p, "orientation") {
Some("vertical") => false,
Some("horizontal") => true,
_ => bool_or(p, "horizontal", horizontal),
_ => given,
};
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
}
@@ -1119,7 +1121,7 @@ fn env_reset_warp(s: &mut Session, p: &Value) -> Result<Value> {
fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.resetWithMesh";
let maintain = bool_or(p, "maintainShape", true);
let maintain = switch_or(p, "maintainShape", true, C)?;
let kinds = reshaped(s, |env, kind| {
let own = match kind {
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
@@ -1150,11 +1152,11 @@ fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeO
None => cur.preserve_shape,
};
Ok(EnvelopeOptions {
anti_alias: bool_or(p, "antiAlias", cur.anti_alias),
anti_alias: switch_or(p, "antiAlias", cur.anti_alias, cmd)?,
preserve_shape,
distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance),
distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients),
distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills),
distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?,
distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?,
distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?,
})
}
@@ -1249,6 +1251,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.options";
check_numbers(p, &["fidelity"], C)?;
check_numbers(p, WARP_NUMBERS, C)?;
check_switches(p, &["horizontal"], C)?;
let fid = p.get("fidelity").and_then(Value::as_f64);
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
return Err(bad(C, "fidelity must be 0..100"));
@@ -1330,9 +1333,10 @@ fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
}
fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.editContents";
let envs = selected_of(s, is_envelope);
let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. }));
let want = bool_or(p, "editing", !cur);
let want = switch_or(p, "editing", !cur, C)?;
s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| {
let mut content_ids = vec![];
for e in &envs {
@@ -1535,6 +1539,7 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.setPointColor";
check_numbers(p, &["opacity"], C)?;
super::paint::check_keep_model(p, C)?;
let index = index_param(p, "index", C)?;
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let opacity = p.get("opacity").and_then(Value::as_f64);
@@ -1573,6 +1578,7 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.addLine";
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
super::paint::check_keep_model(p, C)?;
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
+14 -13
View File
@@ -348,11 +348,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let mh = f64_or(p, "moveH", 0.0).clamp(-1.0e7, 1.0e7);
let mv = f64_or(p, "moveV", 0.0).clamp(-1.0e7, 1.0e7);
let rot = f64_or(p, "rotate", 0.0);
let rx = bool_or(p, "reflectX", false);
let ry = bool_or(p, "reflectY", false);
let random = bool_or(p, "random", false);
let rx = switch_or(p, "reflectX", false, "object.transformEach")?;
let ry = switch_or(p, "reflectY", false, "object.transformEach")?;
let random = switch_or(p, "random", false, "object.transformEach")?;
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
let copy = bool_or(p, "copy", false);
let copy = switch_or(p, "copy", false, "object.transformEach")?;
let objects = super::object::transforms_objects(p);
if sh == 0.0 || sv == 0.0 {
return Err(bad("object.transformEach", "scale must be non-zero"));
@@ -598,7 +598,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
let gray = bool_or(p, "gray", false);
let gray = switch_or(p, "gray", false, C)?;
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
@@ -613,7 +613,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let ppx = (xf * Point::new(1.0, 0.0) - xf * Point::ZERO).hypot().max(1e-9);
let ppy = (xf * Point::new(0.0, 1.0) - xf * Point::ZERO).hypot().max(1e-9);
let (gx, gy) = ((f64_or(p, "spacingX", 0.0).max(0.0) / ppx).min(tw * 0.9), (f64_or(p, "spacingY", 0.0).max(0.0) / ppy).min(th * 0.9));
let delete = bool_or(p, "deleteRaster", false);
let delete = switch_or(p, "deleteRaster", false, C)?;
let (gid, n) = s.edit("Create Object Mosaic", |d, sel| {
let mut tiles = vec![];
for r in 0..rows {
@@ -670,7 +670,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(C, "select an image"))?;
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
if p.get("trim").and_then(Value::as_bool) == Some(true) {
if switch_or(p, "trim", false, C)? {
return trim_image(s, id, (**im).clone());
}
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
@@ -836,9 +836,9 @@ impl Session {
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.createTrimMarks";
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
let style = match str_param(p, "style") {
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
None => s.crop_mark_style(),
let style = match p.get("style") {
None | Some(Value::Null) => s.crop_mark_style(),
Some(v) => v.as_str().and_then(MarkStyle::parse).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
};
let d = TrimMarks::default();
let marks = TrimMarks {
@@ -849,7 +849,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
weight: f64_or(p, "weight", d.weight).max(0.01),
};
let st = s.doc()?;
let rects: Vec<Rect> = if bool_or(p, "allArtboards", st.selection.is_empty()) {
let rects: Vec<Rect> = if switch_or(p, "allArtboards", st.selection.is_empty(), C)? {
st.doc.artboards.iter().map(|a| a.rect).collect()
} else {
st.doc.bounds_of(&selected_roots(s)?, true).into_iter().collect()
@@ -993,9 +993,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
use super::newdoc::ArtboardLayout as L;
// A value given (not null) must be one of the names.
let named = |key: &str| p.get(key).filter(|v| !v.is_null()).map(|v| v.as_str().unwrap_or_default());
let by_column = switch_or(p, "byColumn", false, CMD)?;
let layout = match named("layout") {
Some(l) => L::parse(l).ok_or_else(|| bad(CMD, "layout must be gridByRow, gridByColumn, row or column"))?,
None if bool_or(p, "byColumn", false) => L::GridByColumn,
None if by_column => L::GridByColumn,
None => L::GridByRow,
};
let rtl = match named("order") {
@@ -1016,7 +1017,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
};
check_numbers(p, &["spacing"], CMD)?;
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
let move_art = bool_or(p, "moveArtwork", true);
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
let locked_and_hidden = s.prefs.move_locked_with_artboard;
let d = &s.doc()?.doc;
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
+28 -6
View File
@@ -318,20 +318,42 @@ pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
/// Error unless each of `keys` that `p` gives is a number (or null): an optional number read with
/// [`f64_or`] would otherwise fall back to its default for a string such as `"60 mm"` (#1952).
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other
/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}".
pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
/// Error unless each of `keys` that `p` gives is a number (or null), as [`number_or`] reads them: an
/// optional number read with [`f64_or`] would otherwise fall back to its default for a string such as
/// `"60 mm"` (#1952).
pub(crate) fn check_numbers(p: &Value, keys: &[&str], cmd: &str) -> Result<()> {
for key in keys {
match p.get(*key) {
None | Some(Value::Null) | Some(Value::Number(_)) => {}
Some(v) => return Err(bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
number_or(p, key, 0.0, cmd)?;
}
Ok(())
}
pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
p.get(key).and_then(Value::as_bool).unwrap_or(default)
}
/// The true or false `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any
/// other value, such as `"false"`, is an error: "`{key}` must be true or false".
pub(crate) fn switch_or(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
}
}
/// Error unless each of `keys` that `p` gives is true or false (or null), as [`switch_or`] reads
/// them.
pub(crate) fn check_switches(p: &Value, keys: &[&str], cmd: &str) -> Result<()> {
for key in keys {
switch_or(p, key, false, cmd)?;
}
Ok(())
}
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
p.get(key).and_then(Value::as_str)
}
+23
View File
@@ -356,6 +356,11 @@ pub(crate) fn transforms_objects(p: &Value) -> bool {
bool_or(p, "objects", true)
}
/// The on/off params of the transform commands that [`apply_transform`], [`scaling`],
/// [`transform_patterns`] and [`transforms_objects`] read. The transform commands in this file
/// check them with [`check_switches`] before they change anything.
const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"];
/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off.
pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) {
if objects {
@@ -505,6 +510,8 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
}
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?;
check_switches(p, &["anchors"], "object.transform")?;
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let (ids, anchors) = match ids_param(p, "ids") {
Some(v) => (outermost(&s.doc()?.doc, v), false),
@@ -515,12 +522,15 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["dx", "dy"], "object.move")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.move")?;
let ids = selected_roots_to_transform(s)?;
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["dx", "dy"], "object.nudge")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?;
check_switches(p, &["big"], "object.nudge")?;
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
@@ -549,6 +559,8 @@ fn about(o: Point, a: Affine) -> Affine {
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], "object.rotate")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.rotate")?;
check_switches(p, &["absolute"], "object.rotate")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let mut angle = f64_or(p, "angle", 0.0);
@@ -562,6 +574,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["sy"], "object.scale")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.scale")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
@@ -573,6 +586,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let m = match p.get("axis") {
@@ -593,6 +607,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], "object.shear")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.shear")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
@@ -605,6 +620,7 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
/// that `p` sets to true or false (a recorded Transform Again's) takes precedence. A Transform
/// Each is repeated by running it again (its transform is per object, about each one's bounds).
fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.transformAgain")?;
if let Some(again) = s.doc()?.last_perspective.clone() {
return super::distortcmds::transform_again(s, &again);
}
@@ -1138,6 +1154,7 @@ fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?;
let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
let blend = match str_param(p, "blend") {
@@ -1389,6 +1406,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let preview = preview_bounds(s, p, "object.align")?;
let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
check_switches(p, &["patterns"], "object.align")?;
if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical"));
}
@@ -1607,6 +1625,7 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distribute")?;
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?;
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
@@ -1680,6 +1699,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distributeSpacing")?;
let ids = selected_roots(s)?;
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
@@ -1768,6 +1788,8 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?;
let st = s.doc()?;
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
check_switches(p, &["proportional"], "object.setBounds")?;
if transforms_objects(p)
&& !bool_or(p, "copy", false)
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
@@ -2299,6 +2321,7 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["invertPie", "makeSidesEqual"], "object.setLiveShape")?;
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
let items = match p.get("items").filter(|v| !v.is_null()) {
None => None,
+23 -12
View File
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}",
always,
|s, p| {
s.fill_active = p.get("fill").and_then(Value::as_bool).unwrap_or(!s.fill_active);
s.fill_active = switch_or(p, "fill", !s.fill_active, "paint.toggleActive")?;
Ok(json!({ "fillActive": s.fill_active }))
}
),
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let paint = Paint::solid(s.last_solid);
apply_to_proxy(s, p, paint)
apply_to_proxy(s, p, paint, "paint.lastColor")
}
),
cmd!(
@@ -117,7 +117,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
apply_to_proxy(s, p, paint)
apply_to_proxy(s, p, paint, "paint.lastGradient")
}
),
cmd!(
@@ -287,8 +287,15 @@ fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
}
/// `stroke` param, defaulting to the proxy that is in front.
fn stroke_param(s: &Session, p: &Value) -> bool {
p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active)
fn stroke_param(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
switch_or(p, "stroke", !s.fill_active, cmd)
}
/// Error unless `p` gives `keepModel` as true, false or null, or leaves it out ("`keepModel` must
/// be true or false"). [`paint_from`] calls it first, and each command whose params text lists
/// `keepModel` calls it before it changes anything.
pub(crate) fn check_keep_model(p: &Value, cmd: &str) -> Result<()> {
check_switches(p, &["keepModel"], cmd)
}
/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in
@@ -319,11 +326,13 @@ pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
/// model ([`new_color_model`]), converted through the color settings.
/// model ([`new_color_model`]), converted through the color settings. A `none` or `keepModel`
/// other than true, false or null is an error.
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
check_keep_model(p, "paint")?;
let model = new_color_model(s, p);
let new_color = |c: Color| stored_color(model, c);
if bool_or(p, "none", false) {
if switch_or(p, "none", false, "paint")? {
return Ok(Some(Paint::None));
}
if let Some(name) = str_param(p, "swatch") {
@@ -391,13 +400,14 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
if p.get("tint").is_some() && p.get("swatch").is_none() {
return Err(bad(cmd, "`tint` goes with a global or spot `swatch`"));
}
check_keep_model(p, cmd)?;
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
apply_paint(s, p, paint, fill)
}
/// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it.
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
let fill = !stroke_param(s, p);
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result<Value> {
let fill = !stroke_param(s, p, cmd)?;
apply_paint(s, p, paint, fill)
}
@@ -406,6 +416,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
/// remember the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
let focus = switch_or(p, "focus", true, cmd)?;
let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?;
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
if p.get("ids").is_none()
@@ -431,7 +442,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
super::typecmd::refresh_bounds(t);
Ok(super::appearance::count_changed(d, &before, &[id]))
})?;
if bool_or(p, "focus", true) {
if focus {
s.fill_active = fill;
}
s.remember_paint(&paint);
@@ -514,7 +525,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
}
s.paint.stroke = unplaced(&paint);
}
if bool_or(p, "focus", true) {
if focus {
s.fill_active = fill;
}
s.remember_paint(&paint);
@@ -666,7 +677,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
/// default when there are none) and remember the first target's new paint. The Appearance panel's
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let stroke = stroke_param(s, p);
let stroke = stroke_param(s, p, cmd)?;
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => super::edit::selected_roots(s)?,
+7 -3
View File
@@ -158,7 +158,10 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let h_in = point_param(p, "in");
let h_in = match p.get("in").filter(|h| !h.is_null()) {
None => None,
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
};
let independent = bool_or(p, "independent", false);
s.edit("Close Path", |d, sel| {
let path = path_mut(d, id)?;
@@ -178,7 +181,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
// Objects selected whole move as objects, and their pattern tiles move with them when
// `patterns` (default: General › Transform Pattern Tiles) is on.
let whole = s.doc()?.selection.whole();
@@ -725,6 +728,7 @@ fn open_run(mut anchors: Vec<vectorcraft_geom::Anchor>) -> Option<SubPath> {
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?;
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let tol = f64_or(p, "tol", 3.0).max(0.0);
let radius = p.get("radius").and_then(Value::as_f64);
@@ -773,7 +777,7 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
+12 -8
View File
@@ -366,7 +366,7 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
let base = GridDefinition::default();
let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?"))));
let presets = file.presets.iter().map(check).collect::<Result<Vec<_>>>()?;
let replace = bool_or(p, "replace", false);
let replace = switch_or(p, "replace", false, C)?;
let mut imported = vec![];
for mut preset in presets {
// A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays.
@@ -390,9 +390,13 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "imported": imported }))
}
/// Toggle (or set, with `on`) a view option of the grid.
fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
let want = p.get("on").and_then(Value::as_bool);
/// Toggle (or set, with `on`) a view option of the grid for the command `cmd`.
fn view_option(s: &mut Session, p: &Value, cmd: &str, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
// A missing or null `on` toggles the option.
let want = match p.get("on") {
None | Some(Value::Null) => None,
Some(_) => Some(switch_or(p, "on", false, cmd)?),
};
let mut g = silent(s, |g| {
let f = field(g);
*f = want.unwrap_or(!*f);
@@ -401,19 +405,19 @@ fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &m
}
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.locked)
view_option(s, p, "perspective.grid.lock", |g| &mut g.locked)
}
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.lock_station)
view_option(s, p, "perspective.grid.lockStation", |g| &mut g.lock_station)
}
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.snap)
view_option(s, p, "perspective.grid.snap", |g| &mut g.snap)
}
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.rulers)
view_option(s, p, "perspective.grid.rulers", |g| &mut g.rulers)
}
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
+7
View File
@@ -137,6 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.new";
let mode = str_param(p, "mode");
super::paint::check_keep_model(p, C)?;
let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) {
(Some(cs), _) => {
let stored = |c: Color| if mode.is_some() { c } else { super::paint::new_color(s, p, c) };
@@ -302,6 +303,7 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.newGroup";
let mut names = str_list(p, "swatches");
super::paint::check_keep_model(p, C)?;
let colors: Vec<Color> = p
.get("colors")
.and_then(Value::as_array)
@@ -538,6 +540,10 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(C, format!("`{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name)")));
}
super::paint::check_keep_model(p, C)?;
if let Some(v) = p.get("paint") {
super::paint::check_keep_model(v, C)?;
}
// A replacement paint of the swatch's kind (a dropped colour or edited gradient).
let paint = match p.get("paint").filter(|v| !v.is_null()) {
Some(v) => match (if given("mode") { paint_from(s, &keeping_model(v))? } else { paint_from(s, v)? }, &sw.paint) {
@@ -875,6 +881,7 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}"))))
.collect::<Result<_>>()?;
super::paint::check_keep_model(p, C)?;
// A color its swatch already has keeps its model. The others take the document's.
let old: Vec<Option<Color>> =
s.doc()?.doc.swatch_groups.iter().find(|g| g.name == group).map(|g| g.swatches.iter().map(|w| w.paint.color()).collect()).unwrap_or_default();
+2
View File
@@ -619,6 +619,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
},
};
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
super::paint::check_keep_model(p, C)?;
change.store_colors(s, p);
if change.is_empty() {
return Err(bad(C, "nothing to change"));
@@ -839,6 +840,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) {
return Err(bad(C, "area type needs a path that encloses an area"));
}
super::paint::check_keep_model(p, C)?;
let style = super::create::new_type_style(s, p);
let text = str_param(p, "text").unwrap_or("").to_string();
let start = match point_param(p, "at") {
+1
View File
@@ -691,6 +691,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?),
None => None,
};
super::paint::check_keep_model(p, C)?;
let fill = match p.get("fill") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(vectorcraft_color::Paint::None),
+127
View File
@@ -500,6 +500,37 @@ fn align_without_a_direction_is_refused() {
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 50.0, "nothing moved");
}
/// Align, Distribute and Distribute Spacing refuse a `patterns` given as text or as another value
/// that is not true or false, naming the key: nothing moves and no undo step is recorded. True,
/// false and null still work.
#[test]
fn align_and_distribute_refuse_a_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
let c = rect(&mut s, 70.0, 60.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
let state = |s: &Session| {
let d = &s.doc().unwrap().doc;
(history(s), [b, c].map(|id| d.node(id).unwrap().geometric_bounds().unwrap().x0))
};
let before = state(&s);
let calls = [
("object.align", json!({"horizontal": "left", "patterns": "true"})),
("object.distribute", json!({"horizontal": "left", "patterns": "false"})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 5, "patterns": 1})),
];
for (cmd, p) in calls {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`patterns` must be true or false"), "{cmd} {p}: {r:?}");
assert_eq!(state(&s), before, "{cmd} {p}");
}
s.execute("object.align", &json!({"horizontal": "left", "patterns": null})).unwrap();
s.execute("object.distribute", &json!({"vertical": "top", "patterns": false})).unwrap();
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "patterns": true})).unwrap();
assert_eq!(state(&s).1, [0.0, 0.0]);
}
/// Align, Distribute and Distribute Spacing return an error that lists the choices of a param
/// given a value outside them: nothing moves and no undo step is recorded. A null value counts as
/// not given, and Distribute Spacing without an `axis` spaces the objects horizontally.
@@ -1058,6 +1089,49 @@ fn a_string_for_an_optional_transform_number_is_an_error() {
assert!((bounds(&s).width() - 60.0).abs() < 1e-9);
}
/// A transform option given as text or as another value that is not true or false (`"false"`, 1)
/// is an error naming the key, with Scale Strokes & Effects on. The object, its stroke and its undo
/// history stay as they were, and no copy is made. False and null still work: `object.scale` with
/// `strokes: false` scales the object and leaves its stroke width as it is.
#[test]
fn a_boolean_given_as_text_for_a_transform_is_an_error() {
let mut s = session();
s.execute("prefs.set", &json!({"key": "scaleStrokes", "value": true})).unwrap();
let a = rect(&mut s, 10.0, 10.0, 50.0, 40.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.move", &json!({"dx": 5})).unwrap();
let bounds = |s: &Session| s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
let state = |s: &Session| {
let d = &s.doc().unwrap().doc;
(history(s), bounds(s), d.node(a).unwrap().appearance.stroke_width(), d.layers[0].children().unwrap().len())
};
let before = state(&s);
let calls = [
("object.transform", json!({"matrix": [2, 0, 0, 2, 0, 0], "strokes": "false"}), "strokes"),
("object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "anchors": "true"}), "anchors"),
("object.move", json!({"dx": 5, "copy": "true"}), "copy"),
("object.move", json!({"dx": 5, "objects": 0}), "objects"),
("object.nudge", json!({"dx": 1, "big": "true"}), "big"),
("object.rotate", json!({"angle": 45, "absolute": "true"}), "absolute"),
("object.scale", json!({"sx": 200, "strokes": "false"}), "strokes"),
("object.scale", json!({"sx": 200, "corners": [true]}), "corners"),
("object.reflect", json!({"patterns": "true"}), "patterns"),
("object.shear", json!({"angle": 10, "typeAreas": 1}), "typeAreas"),
("object.transformAgain", json!({"copy": "true"}), "copy"),
("object.setBounds", json!({"width": 100, "proportional": "true"}), "proportional"),
("object.setBounds", json!({"width": 100, "copy": "false"}), "copy"),
];
for (cmd, p, key) in calls {
let r = s.execute(cmd, &p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { cmd: c, msg }) if c == cmd && *msg == want), "{cmd} {p}: {r:?}");
assert_eq!(state(&s), before, "{cmd} {p}");
}
s.execute("object.scale", &json!({"sx": 200, "strokes": false, "corners": null, "copy": false})).unwrap();
let (b, w) = (bounds(&s), s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_width());
assert_eq!((b.width(), w), (100.0, before.2));
}
/// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H
/// (`object.setBounds`) are the box around those anchors, and only they move or scale.
#[test]
@@ -1288,6 +1362,33 @@ fn object_properties_already_set_record_no_undo_step() {
assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err());
}
/// Object Properties refuses `visible`, `locked`, `isolate` or `knockoutShape` given as text or as
/// another value that is not true or false, naming the key, and leaves the object and the undo
/// history as they were. True and false still set the value, and null leaves it as it is.
#[test]
fn object_properties_refuse_a_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let props = |s: &Session| {
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
(n.visible, n.locked, n.isolate, n.knockout_shape)
};
let before = (history(&s), props(&s));
for (key, p) in [
("visible", json!({"id": a.0, "visible": "false"})),
("locked", json!({"id": a.0, "locked": "true"})),
("isolate", json!({"id": a.0, "opacity": 50, "isolate": 1})),
("knockoutShape", json!({"id": a.0, "knockoutShape": [true]})),
] {
let r = s.execute("object.setProps", &p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{p}: {r:?}");
assert_eq!((history(&s), props(&s)), before, "{p}");
}
s.execute("object.setProps", &json!({"id": a.0, "visible": false, "locked": null, "isolate": true, "knockoutShape": null})).unwrap();
assert_eq!(props(&s), (false, false, true, false));
}
/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle,
/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the
/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that
@@ -2596,6 +2697,32 @@ fn object_mosaic_refuses_a_size_or_spacing_given_as_text() {
assert_eq!(r["tiles"], json!(2));
}
/// Gray or Delete Raster given as text or as another value that is not true or false is an error naming the key. No
/// mosaic is made and the image stays. True and null still work.
#[test]
fn object_mosaic_refuses_a_boolean_given_as_text() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let images = |s: &Session| {
s.doc().unwrap().doc.layers[0].children().unwrap().iter().filter(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))).count()
};
let before = (undo(&s), images(&s));
for (p, msg) in [
(json!({"gray": "true"}), "`gray` must be true or false"),
(json!({"columns": 2, "gray": 1}), "`gray` must be true or false"),
(json!({"deleteRaster": "false"}), "`deleteRaster` must be true or false"),
(json!({"deleteRaster": [true]}), "`deleteRaster` must be true or false"),
] {
let r = s.execute("object.createObjectMosaic", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!((undo(&s), images(&s)), before, "{p}");
}
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "gray": true, "deleteRaster": null})).unwrap();
assert_eq!((r["tiles"].clone(), images(&s)), (json!(2), 1));
}
#[test]
fn snap_to_pixel_rounds_drawing_and_moves() {
let mut s = session();
+47
View File
@@ -137,6 +137,32 @@ fn moving_a_key_point_moves_the_key_and_the_other_way_round() {
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 0, "x": f64::MAX, "y": 0})).is_err());
}
/// Moving a spine point refuses an `independent`, and reshaping a spine segment a `perpendicular`,
/// given as text or as another value that is not true or false, and the blend stays as it was; null
/// is read as the key left out.
#[test]
fn spine_edits_refuse_a_boolean_given_as_text() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "smooth", "x": 75, "y": 5})).unwrap();
let before = node(&s, g);
for (cmd, key, p) in [
("object.blend.spine.moveAnchor", "independent", json!({"id": g.0, "anchor": 1, "handle": "out", "x": 75, "y": 40, "independent": "true"})),
("object.blend.spine.moveAnchor", "independent", json!({"id": g.0, "anchor": 1, "x": 55, "y": 60, "independent": 1})),
("object.blend.spine.reshapeSegment", "perpendicular", json!({"id": g.0, "segment": 0, "dx": 10, "dy": 40, "perpendicular": "true"})),
("object.blend.spine.reshapeSegment", "perpendicular", json!({"id": g.0, "segment": 0, "dy": 40, "perpendicular": [false]})),
] {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be true or false")), "{cmd} {p}: {r:?}");
assert_eq!(node(&s, g), before, "{cmd} {p}");
}
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "handle": "out", "x": 75, "y": 40, "independent": null})).unwrap();
let a = spec(&s, g).spine.unwrap().subpaths[0].anchors[1];
assert_eq!((a.h_out, a.kind), (Point::new(75.0, 40.0), vectorcraft_geom::AnchorKind::Smooth), "the point stays smooth");
s.execute("object.blend.spine.reshapeSegment", &json!({"id": g.0, "segment": 0, "dy": 40, "perpendicular": null})).unwrap();
}
#[test]
fn only_points_without_a_key_can_be_deleted() {
let mut s = session();
@@ -170,6 +196,27 @@ fn spine_commands_refuse_an_index_that_is_not_a_whole_number() {
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p, Point::new(55.0, 60.0));
}
/// Reshape Spine Segment refuses a `t`, `dx` or `dy` given as text or as another value that is not
/// a number, instead of bending the spine by 0 or at the segment's middle, and the blend stays as
/// it was; null is read as the key left out.
#[test]
fn spine_reshape_segment_refuses_a_number_given_as_text() {
let mut s = session();
let (_, _, g) = blend(&mut s);
let before = node(&s, g);
for key in ["t", "dx", "dy"] {
for v in [json!("10"), json!([10]), json!(true)] {
let mut p = json!({"id": g.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 40});
p[key] = v.clone();
let r = s.execute("object.blend.spine.reshapeSegment", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a number, not {v}")), "{p}: {r:?}");
assert_eq!(node(&s, g), before, "{p}");
}
}
s.execute("object.blend.spine.reshapeSegment", &json!({"id": g.0, "segment": 0, "t": null, "dx": null, "dy": 40})).unwrap();
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the bent spine");
}
#[test]
fn info_lists_the_spine() {
let mut s = session();
+23
View File
@@ -1046,6 +1046,29 @@ fn symbol_stainer_rgb_color_in_a_cmyk_document_separates_through_the_color_setti
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
}
/// In a CMYK document the Symbol Stainer refuses a `keepModel` given as text or as another value that
/// is not true or false, and the instance and the undo history stay as they were. Null is read as
/// `keepModel` left out.
#[test]
fn symbol_stainer_refuses_a_keep_model_given_as_text() {
use vectorcraft_color::{Color, Paint, cms};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
let (inst, _, b) = make_symbol(&mut s);
let at = json!([[b.center().x, b.center().y]]);
let state = |s: &Session| (node(s, inst), s.doc().unwrap().history.undo.len());
let before = state(&s);
for keep in [json!("true"), json!(1), json!("false"), json!([true])] {
let p = json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10, "keepModel": keep});
let r = s.execute("symbol.adjust", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{p}: {r:?}");
assert_eq!(state(&s), before, "{p}");
}
s.execute("symbol.adjust", &json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10, "keepModel": null})).unwrap();
let red = cms::active().convert(&Color::rgb(1.0, 0.0, 0.0), cms::Model::Cmyk, cms::active().settings().intent);
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
}
#[test]
fn symbol_sprayer_tool_gesture_is_one_undo_step() {
let mut s = session();
+277
View File
@@ -70,6 +70,18 @@ fn close(a: Point, b: Point, tol: f64) -> bool {
a.distance(b) <= tol
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false.
fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// Values for an on/off param that are not true, false or null.
fn not_booleans() -> [serde_json::Value; 4] {
[json!("true"), json!(1), json!("false"), json!([true])]
}
// ---------- width ----------
#[test]
@@ -110,6 +122,22 @@ fn width_profile_set_and_clear() {
assert_eq!(profile(&s, id), None);
}
/// Width Profile refuses a point with a place or width given as text, null or another value that is
/// not a number, such as `[0.5, "1", 2, 2]`, and leaves the profile and the undo history as they were.
#[test]
fn width_profile_set_refuses_a_width_given_as_text() {
let mut s = session();
let id = line(&mut s);
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 1, 1], [1, 1, 1]]})).unwrap();
let before = (profile(&s, id), undo_len(&s));
for points in [json!([[0.5, "1", 2, 2]]), json!([[0.5, 1, 2, "3"]]), json!([[0.5, null, 2, 2]]), json!([[true, 0.5, 2, 2]])] {
let r = s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": points}));
let want = "points must be [t, left, right] with non-negative widths";
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{points}: {r:?}");
assert_eq!((profile(&s, id), undo_len(&s)), before, "{points}");
}
}
#[test]
fn width_tool_gesture_is_one_undo_step() {
let mut s = session();
@@ -178,6 +206,26 @@ fn width_commands_that_change_nothing_record_no_undo_step() {
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Width Point refuses an `adjustAdjoining` given as text or as another value that is not true or
/// false, for a new point and for a point given by its index: the profile and the undo history stay
/// as they were. Null is read as the key left out, and the points either side keep their widths.
#[test]
fn width_point_set_refuses_adjust_adjoining_given_as_text() {
let mut s = session();
let id = line(&mut s);
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 10})).unwrap();
let before = (profile(&s, id), undo_len(&s));
for v in not_booleans() {
let added = json!({"id": id.0, "t": 0.25, "left": 4, "right": 4, "adjustAdjoining": v});
refuses_non_boolean(&mut s, "stroke.widthPoint.set", &added, "adjustAdjoining");
let moved = json!({"id": id.0, "index": 1, "t": 0.5, "left": 4, "right": 4, "adjustAdjoining": v});
refuses_non_boolean(&mut s, "stroke.widthPoint.set", &moved, "adjustAdjoining");
assert_eq!((profile(&s, id), undo_len(&s)), before, "{v}");
}
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.25, "left": 4, "right": 4, "adjustAdjoining": null})).unwrap();
assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 1.0, 1.0), (0.25, 0.8, 0.8), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)]);
}
// ---------- liquify ----------
fn warp_params() -> serde_json::Value {
@@ -253,6 +301,54 @@ fn liquify_tool_replays_from_the_journal() {
assert_eq!(path(&t, a), after);
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
fn refuses_non_number(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be a number, not {}", p[key]);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// Liquify refuses a brush size, angle, intensity, detail, simplify, rate, complexity or wrinkle
/// amount given as text or as another value that is not a number, with the tool that uses the key and
/// with one that does not: the path and the undo history stay as they were. Null is read as the key
/// left out.
#[test]
fn liquify_refuses_a_number_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = (path(&s, a), undo_len(&s));
let with = |tool: &str, key: &str, v: serde_json::Value| {
let mut p = warp_params();
p["tool"] = json!(tool);
p[key] = v;
(key.to_string(), p)
};
let calls = [
with("warp", "diameter", json!("40")),
with("warp", "width", json!("80")),
with("pucker", "height", json!([80])),
with("twirl", "angle", json!("45")),
with("bloat", "intensity", json!("80%")),
with("warp", "detail", json!(true)),
with("warp", "simplify", json!("50")),
with("twirl", "rate", json!("90")),
with("crystallize", "complexity", json!("3")),
with("wrinkle", "horizontal", json!("50")),
with("wrinkle", "vertical", json!({"value": 50})),
with("warp", "complexity", json!("3")),
];
for (key, p) in calls {
refuses_non_number(&mut s, "object.liquify", &p, &key);
assert_eq!((path(&s, a), undo_len(&s)), before, "{p}");
}
let mut p = warp_params();
p["diameter"] = json!(null);
p["intensity"] = json!(null);
assert_eq!(s.execute("object.liquify", &p).unwrap()["ids"], json!([a.0]));
assert_eq!(undo_len(&s), before.1 + 1);
}
/// A Liquify stroke that reshapes no path (the brush reaches none) records no undo step, run as a
/// command or dragged with the tool, and the command replies with no ids.
#[test]
@@ -270,6 +366,32 @@ fn liquify_that_reshapes_no_path_records_no_undo_step() {
assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0));
}
/// Liquify refuses a `usePressure`, `simplifyOn`, `affectAnchors`, `affectIn` or `affectOut` given as
/// text or as another value that is not true or false, whichever tool it is given with: the path and
/// the undo history stay as they were. Null is read as the key left out.
#[test]
fn liquify_refuses_a_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = (path(&s, a), undo_len(&s));
let crystallize = json!({"tool": "crystallize", "points": [[300, 150]], "diameter": 100, "intensity": 100});
let keys = [("usePressure", warp_params()), ("simplifyOn", warp_params()), ("affectAnchors", crystallize.clone())];
let keys = keys.into_iter().chain([("affectIn", crystallize.clone()), ("affectOut", crystallize), ("affectOut", warp_params())]);
for (key, mut p) in keys {
for v in not_booleans() {
p[key] = v;
refuses_non_boolean(&mut s, "object.liquify", &p, key);
}
}
assert_eq!((path(&s, a), undo_len(&s)), before, "nothing changed");
let mut p = warp_params();
p["usePressure"] = json!(null);
p["simplifyOn"] = json!(null);
assert_eq!(s.execute("object.liquify", &p).unwrap()["ids"], json!([a.0]));
assert_eq!(undo_len(&s), before.1 + 1);
}
fn session_with_rect() -> Session {
let mut s = session();
rect(&mut s, 100.0, 100.0, 200.0, 200.0);
@@ -299,6 +421,32 @@ fn puppet_warp_satisfies_pins_and_keeps_area() {
assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[1, 1]], "moved": []})).is_err());
}
/// Puppet Warp and Puppet Warp Pins refuse an `expand` given as text or as another value that is not
/// a number, and Puppet Warp refuses an angle that is not a number or null, such as "90": the art and
/// the undo history stay as they were. A null `expand` is read as the key left out.
#[test]
fn puppet_warp_refuses_a_number_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
let before = (path(&s, a), undo_len(&s));
let with = |key: &str, v: serde_json::Value| {
let mut p = json!({"id": a.0, "pins": [[120, 150], [380, 150]], "moved": [[120, 150], [370, 90]]});
p[key] = v;
p
};
refuses_non_number(&mut s, "object.puppetWarp", &with("expand", json!("5")), "expand");
refuses_non_number(&mut s, "object.puppetWarp", &with("expand", json!([5])), "expand");
refuses_non_number(&mut s, "object.puppetWarp.pins", &json!({"id": a.0, "expand": "5"}), "expand");
for angles in [json!(["90", null]), json!([null, true])] {
let r = s.execute("object.puppetWarp", &with("angles", angles.clone()));
let want = "angles must be a list as long as pins (degrees or null)";
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{angles}: {r:?}");
}
assert_eq!((path(&s, a), undo_len(&s)), before);
s.execute("object.puppetWarp", &with("expand", json!(null))).unwrap();
assert_eq!(undo_len(&s), before.1 + 1);
}
#[test]
fn puppet_tool_drag_warps_selection() {
let mut s = session();
@@ -358,6 +506,23 @@ fn puppet_warp_that_moves_nothing_records_no_undo_step() {
}
}
/// Puppet Warp refuses a `rest` given as text or as another value that is not true or false: the art
/// and the undo history stay as they were. Null is read as the key left out.
#[test]
fn puppet_warp_refuses_rest_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
let before = (path(&s, a), undo_len(&s));
let warp = |rest| json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]], "rest": rest});
for v in not_booleans() {
refuses_non_boolean(&mut s, "object.puppetWarp", &warp(v), "rest");
}
assert_eq!((path(&s, a), undo_len(&s)), before, "nothing changed");
s.execute("object.puppetWarp", &warp(json!(null))).unwrap();
assert_eq!(undo_len(&s), before.1 + 1);
assert!(path(&s, a).bounds().unwrap().y0 < 80.0);
}
// ---------- perspective ----------
fn grid(s: &Session) -> PerspectiveGrid {
@@ -484,6 +649,24 @@ fn rectangle_tool_draws_in_perspective_on_the_active_plane() {
assert!(s.doc().unwrap().doc.node(id).is_none());
}
/// Draw in Perspective refuses a `snap` given as text or as another value that is not true or false
/// before it draws: no shape is made and the undo history stays as it was. Null is read as the key
/// left out.
#[test]
fn perspective_draw_refuses_snap_given_as_text() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
let before = (history(&s), s.doc().unwrap().selection.objects.clone());
let draw = |snap| json!({"command": "shape.rectangle", "params": {"x": 300, "y": 320, "width": 50, "height": 80}, "snap": snap});
for v in not_booleans() {
refuses_non_boolean(&mut s, "perspective.draw", &draw(v), "snap");
}
assert_eq!((history(&s), s.doc().unwrap().selection.objects.clone()), before, "nothing drawn");
let r = s.execute("perspective.draw", &draw(json!(null))).unwrap();
assert_eq!((r["plane"].clone(), history(&s).0), (json!("left"), before.0.0 + 1));
}
#[test]
fn perspective_selection_moves_within_the_plane() {
let mut s = session();
@@ -516,6 +699,43 @@ fn perspective_move_attaches_loose_objects_to_the_given_plane() {
assert!(s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "none"})).is_err());
}
/// Transform in Perspective refuses a `depth`, Nudge in Perspective refuses a `dx` or `dy`, and Move Plane
/// refuses an `offset` or `by` given as text or as another value that is not a number, such as "10". Draw in
/// Perspective refuses an `at` that is not two numbers. The object, the grid and the undo history stay as they
/// were. A null `depth` or `at` is read as the key left out.
#[test]
fn perspective_commands_refuse_a_number_given_as_text() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = (path(&s, a), grid(&s).definition_json(), undo_len(&s));
let calls = [
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": "10"}), "depth"),
("perspective.nudge", json!({"dx": "1", "dy": 0}), "dx"),
("perspective.nudge", json!({"dx": 0, "dy": [1]}), "dy"),
("perspective.plane.move", json!({"plane": "right", "offset": "10"}), "offset"),
("perspective.plane.move", json!({"plane": "right", "offset": "10", "by": 5}), "offset"),
("perspective.plane.move", json!({"plane": "right", "by": true}), "by"),
];
for (c, p, key) in calls {
refuses_non_number(&mut s, c, &p, key);
assert_eq!((path(&s, a), grid(&s).definition_json(), undo_len(&s)), before, "{c} {p}");
}
let draw = |at: serde_json::Value| json!({"command": "shape.rectangle", "params": {"x": 450, "y": 380, "width": 20, "height": 20}, "plane": "right", "at": at});
for at in [json!(["450", 380]), json!("450, 380"), json!([450]), json!([450, 380, 0])] {
let r = s.execute("perspective.draw", &draw(at.clone()));
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`at` must be [x, y]"), "at {at}: {r:?}");
assert_eq!((path(&s, a), grid(&s).definition_json(), undo_len(&s)), before, "at {at}");
}
s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 10, 0], "depth": null})).unwrap();
assert_eq!(undo_len(&s), before.2 + 1);
s.execute("perspective.draw", &draw(json!(null))).unwrap();
assert_eq!(undo_len(&s), before.2 + 2);
}
/// With the grid defined, Define Perspective Grid with the values the grid has (Reproject or not),
/// Attach to Active Plane with no object, Release with Perspective on art not in perspective, Move
/// Plane to where the plane is (its objects moved with it or not), and Move Plane to Match Object for
@@ -552,6 +772,28 @@ fn perspective_grid_commands_that_change_nothing_record_no_undo_step() {
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Define Perspective Grid refuses a `reproject`, and Show Perspective Grid a `visible`, given as text
/// or as another value that is not true or false: the grid, the object on it and the undo history
/// stay as they were. A null `reproject` leaves the object where it is, and a null `visible` toggles
/// the grid, as the keys left out do.
#[test]
fn perspective_grid_commands_refuse_a_boolean_given_as_text() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
let state = |s: &Session| (grid(s).definition_json(), grid(s).visible, path(s, a), history(s));
let before = state(&s);
for v in not_booleans() {
refuses_non_boolean(&mut s, "perspective.grid.set", &json!({"horizon": 250, "reproject": v}), "reproject");
refuses_non_boolean(&mut s, "perspective.grid.show", &json!({"visible": v}), "visible");
assert_eq!(state(&s), before, "{v}");
}
s.execute("perspective.grid.set", &json!({"horizon": 250, "reproject": null})).unwrap();
assert_eq!((grid(&s).horizon, path(&s, a)), (250.0, before.2));
assert_eq!(s.execute("perspective.grid.show", &json!({"visible": null})).unwrap(), json!({"visible": !before.1}));
}
/// For an object in perspective, Move in Perspective by 0 (along its plane or its normal),
/// Transform in Perspective by the identity, Transform Again after each, and a Perspective Selection
/// drag that ends where it started record no undo step. So do a Move by 0 and a 1 pt Nudge that
@@ -615,3 +857,38 @@ fn perspective_moves_that_move_nothing_record_no_undo_step() {
assert_ne!(r, json!({"ids": [a.0]}));
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Move in Perspective refuses a `copy`, `perpendicular` or `snap`, Transform in Perspective a `copy`,
/// and Nudge in Perspective a `big` or `copy`, given as text or as another value that is not true or
/// false: the object stays where it is, nothing is copied, and the undo history stays as it was. Null
/// is read as the key left out.
#[test]
fn perspective_moves_refuse_a_boolean_given_as_text() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let state = |s: &Session| (path(s, a), history(s), s.doc().unwrap().selection.objects.clone());
let before = state(&s);
let (from, to) = (json!([480, 410]), json!([500, 420]));
for v in not_booleans() {
for key in ["copy", "perpendicular", "snap"] {
refuses_non_boolean(&mut s, "perspective.move", &json!({"from": from, "to": to, key: v}), key);
}
refuses_non_boolean(&mut s, "perspective.transform", &json!({"matrix": [1.5, 0, 0, 1.5, 0, 0], "copy": v}), "copy");
for key in ["big", "copy"] {
refuses_non_boolean(&mut s, "perspective.nudge", &json!({"dx": 1, "dy": 0, key: v}), key);
}
assert_eq!(state(&s), before, "{v}");
}
let calls = [
("perspective.move", json!({"from": from, "to": to, "copy": null, "perpendicular": null, "snap": null})),
("perspective.transform", json!({"matrix": [1.5, 0, 0, 1.5, 0, 0], "copy": null})),
("perspective.nudge", json!({"dx": 1, "dy": 0, "big": null, "copy": null})),
];
for (i, (c, p)) in calls.into_iter().enumerate() {
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}");
assert_eq!(history(&s).0, before.1.0 + i + 1, "{c} {p}");
}
}
+176
View File
@@ -867,6 +867,72 @@ fn draw2_commands_reject_bad_params() {
}
}
/// The Pencil refuses a `fidelity` or a redraw `within` given as text or as another value that is
/// not a number, and draws or redraws nothing; null is read as the key left out.
#[test]
fn pencil_refuses_a_fidelity_or_within_given_as_text() {
let mut s = session();
let id = line(&mut s, 100.0, 100.0, 300.0, 100.0);
let stroke = json!({"points": [[100, 100], [150, 140], [200, 140], [250, 100]]});
refuses_a_number_given_as_text(&mut s, "path.freehand", &stroke, "fidelity");
for v in [json!("12"), json!([12]), json!(false)] {
let p = with(&stroke, json!({"redraw": {"id": id.0, "within": v}}));
refuses_with(&mut s, "path.freehand", &p, &format!("`within` must be a number, not {v}"));
}
let before = path(&s, id);
s.execute("path.freehand", &with(&stroke, json!({"fidelity": null, "redraw": {"id": id.0, "within": null}}))).unwrap();
assert!(path(&s, id) != before, "the stroke redraws the line within 12 pt");
}
/// The drawing tools' commands refuse a point whose x or y is not a number, such as "10" or a point
/// written as text, and a pen pressure given as text, instead of leaving the point out or drawing it
/// at full pressure; the document stays as it was.
#[test]
fn drawing_commands_refuse_a_point_or_pressure_given_as_text() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let good = [json!([90, 150]), json!([150, 150]), json!([210, 150])];
let cmds = ["path.freehand", "path.knife", "path.eraseRegion", "path.eraseSegments", "path.smoothRegion", "path.joinScrub", "path.blob"];
for cmd in cmds.into_iter().chain(["path.curvature"]) {
for bad in [json!(["150", 150]), json!({"x": 150, "y": "150"}), json!("150, 150"), json!([150]), json!(null)] {
let points: Vec<_> = good.iter().cloned().chain([bad]).collect();
refuses_with(&mut s, cmd, &json!({"points": points}), "x and y must both be numbers");
}
}
for cmd in cmds {
for bad in [json!([150, 150, "0.5"]), json!({"x": 150, "y": 150, "pressure": "0.5"})] {
let points: Vec<_> = good.iter().cloned().chain([bad]).collect();
refuses_with(&mut s, cmd, &json!({"points": points}), "`pressure` must be a number, not \"0.5\"");
}
}
let pressed = json!({"points": [[100, 400], [150, 420, null], {"x": 200, "y": 400, "pressure": 0.5}], "keepSelected": false});
s.execute("path.freehand", &pressed).unwrap();
}
/// The Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a size, width, radius,
/// fidelity or tolerance given as text or as another value that is not a number, and change
/// nothing; null is read as the key left out.
#[test]
fn drawing_commands_refuse_a_size_given_as_text() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let stroke = json!({"points": [[90, 150], [150, 150], [210, 150]]});
for (cmd, key) in [
("path.eraseRegion", "size"),
("path.eraseSegments", "width"),
("path.smoothRegion", "radius"),
("path.smoothRegion", "fidelity"),
("path.joinScrub", "tolerance"),
("path.blob", "size"),
] {
refuses_a_number_given_as_text(&mut s, cmd, &stroke, key);
}
let erased = s.execute("path.eraseRegion", &with(&stroke, json!({"size": null}))).unwrap();
assert_eq!(erased["ids"].as_array().map(Vec::len), Some(2), "the default 10 pt Eraser cuts the square in two: {erased}");
}
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
fn two_subpaths(s: &mut Session) -> NodeId {
@@ -898,6 +964,33 @@ fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json
}
}
/// Runs `cmd` with `p` and `key` set to each of "10", [10] and true: each call fails with
/// "`key` must be a number, not <value>", and the document and the undo history stay as they were.
fn refuses_a_number_given_as_text(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
for v in [json!("10"), json!([10]), json!(true)] {
let mut p = p.clone();
p[key] = v.clone();
let before = s.doc().unwrap().doc.clone();
let undo = undo_steps(s);
let r = s.execute(cmd, &p);
let want = format!("`{key}` must be a number, not {v}");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
}
}
/// Runs `cmd` with `p`: the call fails with `want`, and the document and the undo history stay as
/// they were.
fn refuses_with(s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str) {
let before = s.doc().unwrap().doc.clone();
let undo = undo_steps(s);
let r = s.execute(cmd, p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{cmd} {p}: {r:?}");
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
}
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
/// subpath 0 alone.
@@ -1054,6 +1147,89 @@ fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
}
/// `path.reshapeSegment` refuses a `t`, `dx` or `dy` given as text or as another value that is not
/// a number, instead of bending the segment by 0 or at its middle; `dy: 20` bends it, with a null
/// `t` read as the middle.
#[test]
fn reshape_segment_refuses_a_number_given_as_text() {
let mut s = session();
let id = curve(&mut s);
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
for key in ["t", "dx", "dy"] {
refuses_a_number_given_as_text(&mut s, "path.reshapeSegment", &p, key);
}
let before = path(&s, id);
s.execute("path.reshapeSegment", &with(&p, json!({"t": null}))).unwrap();
assert!(path(&s, id) != before, "the segment bends");
}
/// `path.convertAnchor` refuses an `x` or `y` given as text, and an `x` without a `y` or a `y`
/// without an `x`, instead of converting the anchor without a handle position; both as numbers
/// place the out handle there.
#[test]
fn convert_anchor_refuses_a_handle_position_given_as_text_or_alone() {
let mut s = session();
let id = curve(&mut s);
let p = json!({"id": id.0, "anchor": 1, "to": "smooth", "x": 330, "y": 150});
for key in ["x", "y"] {
refuses_a_number_given_as_text(&mut s, "path.convertAnchor", &p, key);
}
for lone in [json!({"y": null}), json!({"x": null}), json!({"y": null, "to": "corner"})] {
refuses_with(&mut s, "path.convertAnchor", &with(&p, lone), "x and y must be given together");
}
s.execute("path.convertAnchor", &p).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[1].h_out, Point::new(330.0, 150.0));
}
/// The Curvature tool's edits and Add Anchor Point refuse a `t`, `x` or `y` given as text or as
/// another value that is not a number, instead of inserting at the middle of the segment or leaving
/// the new anchor where it was inserted.
#[test]
fn curvature_edit_and_insert_anchor_refuse_a_number_given_as_text() {
let mut s = session();
let id = curve(&mut s);
let insert = json!({"id": id.0, "op": "insert", "segment": 0, "t": 0.25});
let moved = json!({"id": id.0, "op": "insert", "segment": 0, "t": 0.25, "x": 150, "y": 160});
refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &insert, "t");
for key in ["x", "y"] {
refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &moved, key);
refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &json!({"id": id.0, "op": "move", "anchor": 1, "x": 300, "y": 150}), key);
}
refuses_a_number_given_as_text(&mut s, "path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.25}), "t");
s.execute("path.curvatureEdit", &moved).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 160.0));
s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": null})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 4, "a null t inserts at the middle");
}
/// Move Anchors, the Reshape tool's command and Close Path refuse a number or handle given as text
/// or as another value that is not a number, and change nothing; null is read as the key left out.
#[test]
fn move_anchors_reshape_and_close_refuse_a_number_given_as_text() {
let mut s = session();
let id = curve(&mut s);
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
let nudge = json!({"dx": 10, "dy": 0});
for key in ["dx", "dy"] {
refuses_a_number_given_as_text(&mut s, "path.moveAnchors", &nudge, key);
}
let reshape = json!({"id": id.0, "x": 200, "y": 100, "dx": 0, "dy": 30, "tol": 3, "radius": 50});
for key in ["dx", "dy", "tol", "radius"] {
refuses_a_number_given_as_text(&mut s, "path.reshape", &reshape, key);
}
for h in [json!("200, 150"), json!([200]), json!([200, "150"]), json!({"x": 200, "y": 150})] {
refuses_with(&mut s, "path.close", &json!({"id": id.0, "in": h}), "`in` must be [x, y]");
}
let before = path(&s, id);
s.execute("path.moveAnchors", &json!({"dx": 10, "dy": null})).unwrap();
s.execute("path.reshape", &with(&reshape, json!({"radius": null}))).unwrap();
s.execute("path.close", &json!({"id": id.0, "in": null})).unwrap();
// The Reshape tool's grab adds an anchor between the two, so the moved anchor is the last.
let after = path(&s, id);
let moved = after.subpaths[0].anchors.last().map(|a| a.p.x);
assert!(after.subpaths[0].closed && moved == Some(before.subpaths[0].anchors[1].p.x + 10.0), "{after:?}");
}
#[test]
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
+100
View File
@@ -257,6 +257,50 @@ fn cmyk_session() -> Session {
s
}
/// `cmd` with `p` fails with "`keepModel` must be true or false".
fn refuses_keep_model(s: &mut Session, cmd: &str, p: &Value) {
let r = s.execute(cmd, p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{cmd} {p}: {r:?}");
}
/// How many steps there are to undo.
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// In a CMYK document, `paint.setFill`, `paint.setStroke`, `appearance.setItem`, `livePaint.fill`,
/// `livePaint.strokeEdge` and a `shapeBuilder.merge` fill refuse a `keepModel` given as text or as
/// another value that is not true or false. The objects, the defaults for new art and the undo
/// history stay as they were. Null is read as `keepModel` left out.
#[test]
fn the_paint_commands_refuse_a_keep_model_given_as_text() {
let mut s = cmyk_session();
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.paint.clone(), undo_len(s));
let before = state(&s);
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]);
let calls = [
("paint.setFill", json!({"color": "#ff0000", "keepModel": "true"})),
("paint.setStroke", json!({"color": "#ff0000", "keepModel": 1})),
("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": "false"})),
("appearance.setItem", json!({"index": 0, "color": "#ff0000", "keepModel": "true"})),
("livePaint.fill", json!({"ids": [a.0, b.0], "point": [20, 30], "color": "#ff0000", "keepModel": [true]})),
("shapeBuilder.merge", json!({"ids": [a.0, b.0], "points": [[20, 30], [80, 30]], "fill": {"color": "#ff0000", "keepModel": "true"}})),
];
for (cmd, p) in &calls {
refuses_keep_model(&mut s, cmd, p);
assert_eq!(state(&s), before, "{cmd} {p}");
}
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000", "keepModel": null})).unwrap();
assert_eq!(node(&s, a).appearance.fill_paint().color(), Some(managed_cmyk(Color::rgb8(255, 0, 0))));
let r = s.execute("livePaint.fill", &json!({"ids": [a.0, b.0], "point": [20, 30], "color": "#ff0000"})).unwrap();
let g = NodeId(r["group"].as_u64().unwrap());
let before = state(&s);
refuses_keep_model(&mut s, "livePaint.strokeEdge", &json!({"group": g.0, "point": [10, 30], "color": "#ff0000", "keepModel": "true"}));
assert_eq!(state(&s), before, "livePaint.strokeEdge");
}
/// RGB colors given to the type commands in a CMYK document separate through the color settings,
/// as `paint.setFill` stores them: `text.create {color}`, `text.setStyle {fill}` and
/// `text.setRangeStyle {fill, stroke}`. CMYK colors are stored as given.
@@ -280,6 +324,30 @@ fn rgb_type_colors_given_to_a_cmyk_document_separate_through_the_color_settings(
assert_eq!(runs(&s)[0].1, Some(Color::rgb8(83, 170, 223)), "keepModel keeps RGB");
}
/// In a CMYK document, `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle`
/// refuse a `keepModel` given as text or as another value that is not true or false: no type is made,
/// and the type, the path and the undo history stay as they were. Null is read as `keepModel` left out.
#[test]
fn the_type_commands_refuse_a_keep_model_given_as_text() {
let mut s = cmyk_session();
let t = id_of(s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hello"})).unwrap());
let path = rect(&mut s, 200.0);
let before = (s.doc().unwrap().doc.clone(), undo_len(&s));
let calls = [
("text.create", json!({"x": 10, "y": 90, "text": "Hi", "color": "#ff0000", "keepModel": "true"})),
("text.createInPath", json!({"path": path.0, "mode": "area", "text": "Hi", "color": "#ff0000", "keepModel": 1})),
("text.setStyle", json!({"ids": [t.0], "fill": "#ff0000", "keepModel": "false"})),
("text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 2, "fill": "#ff0000", "keepModel": [true]})),
];
for (cmd, p) in &calls {
refuses_keep_model(&mut s, cmd, p);
assert_eq!((s.doc().unwrap().doc.clone(), undo_len(&s)), before, "{cmd} {p}");
}
s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 0, "end": 2, "fill": "#ff0000", "keepModel": null})).unwrap();
let NodeKind::Text(o) = node(&s, t).kind else { panic!("not type") };
assert_eq!(o.runs[0].style.fill.color(), Some(managed_cmyk(Color::rgb8(255, 0, 0))));
}
/// `shapeBuilder.merge {fill: "#rrggbb"}` in a CMYK document fills the merged shape as
/// `fill: {color}` does.
#[test]
@@ -375,6 +443,38 @@ fn rgb_gradient_and_mesh_colors_given_to_a_cmyk_document_separate_through_the_co
assert!(mesh(&s).contains(&orange), "{:?}", mesh(&s));
}
/// In a CMYK document, `paint.editGradient`, `paint.freeform.addPoint` and `setPoint`, and
/// `object.mesh.setPointColor` and `addLine` refuse a `keepModel` given as text or as another value
/// that is not true or false. The gradient, the mesh and the undo history stay as they were. Null is
/// read as `keepModel` left out.
#[test]
fn the_gradient_and_mesh_commands_refuse_a_keep_model_given_as_text() {
let mut s = cmyk_session();
let a = rect(&mut s, 10.0);
let (cyan, magenta) = (json!({"c": 100, "m": 0, "y": 0, "k": 0}), json!({"c": 0, "m": 100, "y": 0, "k": 0}));
let points = json!([{"at": [20, 20], "color": cyan}, {"at": [50, 50], "color": magenta}]);
s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
let m = rect(&mut s, 300.0);
s.execute("object.mesh.create", &json!({"ids": [m.0], "rows": 1, "cols": 1})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = (s.doc().unwrap().doc.clone(), undo_len(&s));
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#53aadf"}]);
let calls = [
("paint.editGradient", json!({"stops": stops, "keepModel": "true"})),
("paint.freeform.addPoint", json!({"at": [40, 20], "color": "#ff0000", "keepModel": "true"})),
("paint.freeform.setPoint", json!({"index": 1, "color": "#ff0000", "keepModel": 1})),
("object.mesh.setPointColor", json!({"id": m.0, "index": 1, "color": "#ff0000", "keepModel": "false"})),
("object.mesh.addLine", json!({"id": m.0, "x": 320, "y": 30, "color": "#ff0000", "keepModel": [true]})),
];
for (cmd, p) in &calls {
refuses_keep_model(&mut s, cmd, p);
assert_eq!((s.doc().unwrap().doc.clone(), undo_len(&s)), before, "{cmd} {p}");
}
s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 1, "color": "#ff0000", "keepModel": null})).unwrap();
let NodeKind::Mesh(g) = node(&s, m).kind else { panic!("not a mesh") };
assert_eq!(g.points[1].color, managed_cmyk(Color::rgb8(255, 0, 0)));
}
#[test]
fn the_options_are_a_preference() {
let mut s = Session::new();
+122
View File
@@ -80,6 +80,13 @@ fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false.
fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
// ---------- Blend ----------
#[test]
@@ -171,6 +178,38 @@ fn blend_options_refuse_a_number_given_as_text() {
assert_eq!(s.prefs.blend_options, defaults, "the options new blends start with stayed");
}
/// Make and Blend Options refuse a `smooth` given as text or as another value that is not true or
/// false, also beside Specified Steps: Make makes no blend, and the selected blend and the options new
/// blends start with keep their spacing. Null is read as `smooth` left out, and true still gives
/// Smooth Color.
#[test]
fn blend_make_and_options_refuse_a_smooth_given_as_text() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
sel(&mut s, &[a, b]);
let n = undo_len(&s);
let calls = [json!({"smooth": "true"}), json!({"smooth": 1}), json!({"steps": 4, "smooth": "false"}), json!({"smooth": [true]})];
for p in &calls {
refuses_non_boolean(&mut s, "object.blend.make", p, "smooth");
}
assert_eq!(undo_len(&s), n, "nothing changed");
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not blended");
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "smooth": null})).unwrap());
let n = undo_len(&s);
for p in &calls {
refuses_non_boolean(&mut s, "object.blend.options", p, "smooth");
}
assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "the blend kept its options");
s.execute("object.blend.options", &json!({"smooth": true})).unwrap();
assert!(matches!(&node(&s, g).kind, NodeKind::Blend { spec, .. } if spec.spacing == BlendSpacing::SmoothColor));
s.execute("select.none", &json!({})).unwrap();
let defaults = s.prefs.blend_options;
for p in &calls {
refuses_non_boolean(&mut s, "object.blend.options", p, "smooth");
}
assert_eq!(s.prefs.blend_options, defaults, "the options new blends start with stayed");
}
#[test]
fn blend_mid_step_interpolates_geometry_and_colour() {
let mut s = session();
@@ -534,6 +573,24 @@ fn envelope_options_and_edit_contents() {
assert_eq!(s.doc().unwrap().selection.objects, vec![e]);
}
/// Edit Contents refuses an `editing` given as text or as another value that is not true or false,
/// and the envelope, the selection and the undo history stay as they were; a null `editing` toggles,
/// as a missing one does.
#[test]
fn envelope_edit_contents_refuses_editing_given_as_text() {
let mut s = session();
let a = text_rect(&mut s);
sel(&mut s, &[a]);
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
let before = (node(&s, e), undo_len(&s), s.doc().unwrap().selection.objects.clone());
for p in [json!({"editing": "true"}), json!({"editing": 1}), json!({"editing": "false"}), json!({"editing": [true]})] {
refuses_non_boolean(&mut s, "object.envelope.editContents", &p, "editing");
assert_eq!((node(&s, e), undo_len(&s), s.doc().unwrap().selection.objects.clone()), before, "{p}");
}
let r = s.execute("object.envelope.editContents", &json!({"editing": null})).unwrap();
assert_eq!((r["editing"].clone(), s.doc().unwrap().selection.objects.clone()), (json!(true), vec![a]));
}
/// The warp's `bend`, and `h` and `v` by those keys or by their long names, given as text.
fn warp_numbers_as_text() -> [(&'static str, Value); 5] {
[
@@ -601,6 +658,37 @@ fn envelope_reset_with_mesh_refuses_a_number_given_as_text() {
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
}
/// Make with Warp and Reset with Warp refuse a `horizontal` given as text or as another value that
/// is not true or false, also beside an `orientation`, and Reset with Mesh refuses such a
/// `maintainShape`: no envelope is made, and a warp envelope keeps its warp. Null is read as the key
/// left out.
#[test]
fn envelope_make_and_reset_refuse_a_boolean_given_as_text() {
let mut s = session();
let a = text_rect(&mut s);
sel(&mut s, &[a]);
let n = undo_len(&s);
let warps = [json!({"horizontal": "false"}), json!({"style": "arch", "horizontal": 0}), json!({"orientation": "vertical", "horizontal": "true"})];
for p in &warps {
refuses_non_boolean(&mut s, "object.envelope.makeWithWarp", p, "horizontal");
}
assert_eq!(undo_len(&s), n, "nothing changed");
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped");
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50, "horizontal": null})).unwrap());
let (before, n) = (node(&s, e), undo_len(&s));
for p in &warps {
refuses_non_boolean(&mut s, "object.envelope.resetWithWarp", p, "horizontal");
}
for p in [json!({"maintainShape": "false"}), json!({"rows": 2, "cols": 2, "maintainShape": 0})] {
refuses_non_boolean(&mut s, "object.envelope.resetWithMesh", &p, "maintainShape");
}
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
s.execute("object.envelope.resetWithWarp", &json!({"horizontal": false})).unwrap();
assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { horizontal: false, .. })));
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2, "maintainShape": null})).unwrap();
assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 2, cols: 2, .. })));
}
/// Envelope Options refuses a fidelity, bend or distortion given as text, for the selected
/// envelope and for the options new envelopes get, and changes neither.
#[test]
@@ -623,6 +711,40 @@ fn envelope_options_refuse_a_number_given_as_text() {
assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed");
}
/// Envelope Options refuses Anti-Alias, the three Distort options or a warp's `horizontal` given as
/// text or as another value that is not true or false, for a selected warp envelope, a selected mesh
/// envelope and the options new envelopes get, and changes none of them; null keeps the current
/// value, as a missing key does.
#[test]
fn envelope_options_refuse_a_boolean_given_as_text() {
let mut s = session();
let a = text_rect(&mut s);
sel(&mut s, &[a]);
let warp = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
let b = rect(&mut s, 400.0, 100.0, 100.0, 100.0);
sel(&mut s, &[b]);
let mesh = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap());
let calls = [
("antiAlias", json!({"antiAlias": "false"})),
("distortAppearance", json!({"fidelity": 80, "distortAppearance": 1})),
("distortLinearGradients", json!({"distortLinearGradients": "true"})),
("distortPatternFills", json!({"distortPatternFills": [true]})),
("horizontal", json!({"horizontal": "false"})),
];
for ids in [vec![warp], vec![mesh], vec![]] {
sel(&mut s, &ids);
let before = (node(&s, warp), node(&s, mesh), undo_len(&s));
for (key, p) in &calls {
refuses_non_boolean(&mut s, "object.envelope.options", p, key);
}
assert_eq!((node(&s, warp), node(&s, mesh), undo_len(&s)), before, "nothing changed with {ids:?} selected");
}
assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed");
sel(&mut s, &[warp]);
s.execute("object.envelope.options", &json!({"antiAlias": false, "distortAppearance": null, "horizontal": null})).unwrap();
assert!(matches!(&node(&s, warp).kind, NodeKind::Envelope { options, .. } if !options.anti_alias && options.distort_appearance));
}
#[test]
fn envelope_renders_distorted() {
let mut s = session();
+43
View File
@@ -331,6 +331,49 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}
/// Invert Pie and Make Sides Equal given as text or as another value that is not true or false are
/// an error naming the key: the ellipse's pie, the polygon and the undo history stay as they were.
/// True makes the change, and false and null leave the shape as it is.
#[test]
fn invert_pie_and_make_sides_equal_refuse_a_boolean_given_as_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
run(&mut s, json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 90}));
let polygon = id(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap());
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let shapes = |s: &Session| {
let d = &s.doc().unwrap().doc;
[ellipse, polygon].map(|i| match &d.node(i).unwrap().kind {
NodeKind::Path { live: Some(l), .. } => (**l).clone(),
k => panic!("{k:?}"),
})
};
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let before = (undo(&s), shapes(&s));
for (key, p) in [
("invertPie", json!({"id": ellipse.0, "invertPie": "true"})),
("invertPie", json!({"id": ellipse.0, "pieEnd": 180, "invertPie": 1})),
("makeSidesEqual", json!({"id": polygon.0, "makeSidesEqual": "true"})),
("makeSidesEqual", json!({"id": polygon.0, "sides": 5, "makeSidesEqual": [true]})),
] {
let r = s.execute("object.setLiveShape", &p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{p}: {r:?}");
assert_eq!((undo(&s), shapes(&s)), before, "{p}");
}
for p in [json!({"id": ellipse.0, "invertPie": false}), json!({"id": polygon.0, "makeSidesEqual": null})] {
run(&mut s, p);
}
assert_eq!((undo(&s), shapes(&s)), before);
run(&mut s, json!({"id": ellipse.0, "invertPie": true}));
run(&mut s, json!({"id": polygon.0, "makeSidesEqual": true}));
let [LiveShape::Ellipse { pie, .. }, l] = shapes(&s) else { panic!("an ellipse and a polygon") };
assert_eq!(pie, (90.0, 360.0));
assert!(l.polygon_sides_equal());
}
/// The Selection tool's pie widgets: dragging the End widget from 0° to straight up sets Pie End
/// Angle to 90 in one undo step; on an inverted pie a drag moves only the dragged angle; a
/// double-click on a widget makes the ellipse whole again in one step.
+98
View File
@@ -90,6 +90,13 @@ fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false.
fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
// ---------- Object → Lock / Hide ----------
#[test]
@@ -429,6 +436,30 @@ fn transform_each_refuses_a_number_given_as_text() {
assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0));
}
/// Transform Each refuses Reflect X, Reflect Y, Random or Copy given as text or as another value
/// that is not true or false, and the object stays as it was and is not copied; true, false and null
/// still work.
#[test]
fn transform_each_refuses_a_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
let before = (undo_len(&s), bounds(&s, a), count(&s));
for (key, p) in [
("reflectX", json!({"reflectX": "true"})),
("reflectY", json!({"moveH": 10, "reflectY": 1})),
("random", json!({"moveH": 10, "random": "false"})),
("copy", json!({"copy": "true"})),
("copy", json!({"moveH": 10, "copy": [true]})),
] {
refuses_non_boolean(&mut s, "object.transformEach", &p, key);
assert_eq!((undo_len(&s), bounds(&s, a), count(&s)), before, "{p}");
}
s.execute("object.transformEach", &json!({"moveH": 10, "reflectX": true, "random": false, "copy": null})).unwrap();
assert_eq!((bounds(&s, a), count(&s)), (Rect::new(10.0, 0.0, 110.0, 100.0), before.2));
}
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
#[test]
@@ -673,6 +704,24 @@ fn crop_image_refuses_a_rect_given_as_text() {
assert!(close(b.x0, 0.0) && close(b.y0, 0.0), "{b:?}");
}
/// Crop Image refuses a `trim` given as text or as another value that is not true or false, and the
/// image stays as it was; a null `trim` crops to `rect` as a missing one does.
#[test]
fn crop_image_refuses_a_trim_given_as_text() {
let mut s = session();
let a = rect(&mut s, -20.0, -20.0, 100.0, 100.0);
sel(&mut s, &[a]);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
let before = (undo_len(&s), node(&s, img));
for p in [json!({"trim": "true"}), json!({"trim": "false", "rect": [10, 10, 50, 40]}), json!({"trim": 1})] {
refuses_non_boolean(&mut s, "object.cropImage", &p, "trim");
assert_eq!((undo_len(&s), node(&s, img)), before, "{p}");
}
s.execute("object.cropImage", &json!({"trim": null, "rect": [10, 10, 50, 40]})).unwrap();
let b = bounds(&s, img);
assert!(close(b.x0, 10.0) && close(b.y0, 10.0) && close(b.width(), 50.0) && close(b.height(), 40.0), "{b:?}");
}
#[test]
fn crop_image_disabled_without_image() {
let mut s = session();
@@ -745,6 +794,32 @@ fn trim_marks_refuse_a_number_given_as_text() {
s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20, "bleed": null})).unwrap();
}
/// Create Trim Marks refuses an `allArtboards` that is not true or false and a `style` that is not
/// text, around the selection and around the artboards, and makes no marks; null takes the default.
#[test]
fn trim_marks_refuse_a_boolean_or_style_of_the_wrong_kind() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
let style = "`style` must be roman or japanese";
let calls = [
(json!({"allArtboards": "true"}), "`allArtboards` must be true or false"),
(json!({"allArtboards": 0}), "`allArtboards` must be true or false"),
(json!({"style": 5}), style),
(json!({"allArtboards": true, "style": true}), style),
(json!({"style": ["roman"]}), style),
];
for ids in [vec![a], vec![]] {
sel(&mut s, &ids);
let before = (undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len());
for (p, msg) in &calls {
let r = s.execute("object.createTrimMarks", p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!((undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()), before, "{p}");
}
}
s.execute("object.createTrimMarks", &json!({"allArtboards": null, "style": null})).unwrap();
}
// ---------- Convert to Shape ----------
#[test]
@@ -896,6 +971,29 @@ fn rearrange_refuses_a_spacing_given_as_text() {
assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0));
}
/// Rearrange All Artboards refuses `byColumn` or `moveArtwork` given as text or as another value
/// that is not true or false, and the artboards and the art stay where they were; `moveArtwork:
/// false` leaves the art in place.
#[test]
fn rearrange_refuses_a_boolean_given_as_text() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
let b = rect(&mut s, 1050.0, 25.0, 10.0, 10.0);
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let before = (boards(&s), undo_len(&s), bounds(&s, b));
for (key, p) in [
("byColumn", json!({"byColumn": "true"})),
("byColumn", json!({"layout": "row", "byColumn": 1})),
("moveArtwork", json!({"moveArtwork": "false"})),
("moveArtwork", json!({"columns": 1, "moveArtwork": 0})),
] {
refuses_non_boolean(&mut s, "artboard.rearrange", &p, key);
assert_eq!((boards(&s), undo_len(&s), bounds(&s, b)), before, "{p}");
}
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50, "byColumn": null, "moveArtwork": false})).unwrap();
assert_eq!((boards(&s)[1], bounds(&s, b)), (Rect::new(0.0, 650.0, 400.0, 950.0), before.2));
}
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
+72
View File
@@ -227,6 +227,78 @@ fn focus_false_keeps_the_active_proxy() {
assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000");
}
/// Toggle Fill/Stroke Focus, Invert, Complement, Apply Last Color, Apply Last Gradient,
/// `paint.setFill` and `paint.setStroke` refuse a `fill`, `stroke`, `focus` or `none` given as text or
/// as another value that is not true or false. The object, the defaults for new art, the proxy in
/// front and the undo history stay as they were, with the object selected and with nothing selected.
/// Null is read as the key left out.
#[test]
fn proxy_commands_refuse_a_boolean_given_as_text() {
let mut s = session();
let id = rect(&mut s);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let calls = [
("paint.toggleActive", json!({"fill": "false"}), "fill"),
("paint.invert", json!({"stroke": "true"}), "stroke"),
("paint.complement", json!({"stroke": 1}), "stroke"),
("paint.lastColor", json!({"stroke": "true"}), "stroke"),
("paint.lastGradient", json!({"stroke": [true]}), "stroke"),
("paint.setStroke", json!({"color": "#0000ff", "focus": "false"}), "focus"),
("paint.setFill", json!({"none": "true"}), "none"),
("paint.setFill", json!({"color": "#0000ff", "none": "false"}), "none"),
];
let state = |s: &Session| (node(s, id), s.paint.clone(), s.fill_active, history(s));
for selected in [true, false] {
if !selected {
s.execute("select.none", &json!({})).unwrap();
}
let before = state(&s);
for (cmd, p, key) in &calls {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
assert_eq!(state(&s), before, "{cmd} {p}, selected: {selected}");
}
}
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let r = s.execute("paint.toggleActive", &json!({"fill": null})).unwrap();
assert_eq!(r["fillActive"], json!(false), "a null `fill` swaps the proxy in front");
s.execute("paint.setFill", &json!({"color": "#0000ff", "none": null, "focus": null})).unwrap();
assert_eq!((node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), s.fill_active), ("#0000ff".to_string(), true));
}
/// `appearance.setItem`, `livePaint.fill`, `livePaint.strokeEdge`, a `shapeBuilder.merge` `fill`
/// object, `swatch.new` and `swatch.edit`'s `paint` refuse a `none` given as text or as another value
/// that is not true or false. The document, the defaults for new art and the undo history stay as
/// they were. Null is read as `none` left out.
#[test]
fn the_commands_that_take_a_paint_refuse_a_none_given_as_text() {
let mut s = session();
let id = rect(&mut s);
let name = s.execute("swatch.new", &json!({"color": "#00ff00"})).unwrap()["name"].clone();
let refused = |r: &Result<Value>| matches!(r, Err(EngineError::BadParams { msg, .. }) if msg == "`none` must be true or false");
let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.paint.clone(), history(s));
let before = state(&s);
let calls = [
("appearance.setItem", json!({"index": 0, "ids": [id.0], "none": "true"})),
("livePaint.fill", json!({"ids": [id.0], "point": [20, 30], "none": 1})),
("shapeBuilder.merge", json!({"ids": [id.0], "points": [[20, 30]], "fill": {"none": "true"}})),
("swatch.new", json!({"none": [true]})),
("swatch.edit", json!({"name": name, "paint": {"none": "false"}})),
];
for (cmd, p) in &calls {
let r = s.execute(cmd, p);
assert!(refused(&r), "{cmd} {p}: {r:?}");
assert_eq!(state(&s), before, "{cmd} {p}");
}
let r = s.execute("livePaint.fill", &json!({"ids": [id.0], "point": [20, 30], "color": "#ff0000", "none": null})).unwrap();
let g = NodeId(r["group"].as_u64().unwrap());
let before = state(&s);
let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [10, 30], "none": "true"}));
assert!(refused(&r), "livePaint.strokeEdge: {r:?}");
assert_eq!(state(&s), before, "livePaint.strokeEdge");
}
#[test]
fn a_mixed_selection_shows_a_question_mark_proxy() {
let mut s = session();
+57
View File
@@ -28,6 +28,18 @@ fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false.
fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// Values for an on/off param that are not true, false or null.
fn not_booleans() -> [Value; 4] {
[json!("true"), json!(1), json!("false"), json!([true])]
}
// ---------- M8.8: Define Grid ----------
#[test]
@@ -209,6 +221,24 @@ fn presets_export_import_and_live_in_the_preferences() {
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().perspective_presets.is_empty());
}
/// Import Perspective Grid Presets refuses a `replace` given as text or as another value that is not
/// true or false: the saved presets stay as they were. Null is read as the key left out, so the
/// imported presets whose names are taken get a number.
#[test]
fn presets_import_refuses_replace_given_as_text() {
let mut s = session();
run(&mut s, "perspective.presets.save", json!({"name": "Street", "gridline": 1}));
let text = run(&mut s, "perspective.presets.export", json!({}))["data"].as_str().unwrap().to_string();
let before = s.prefs.perspective_presets.clone();
for v in not_booleans() {
refuses_non_boolean(&mut s, "perspective.presets.import", &json!({"data": text, "replace": v}), "replace");
assert_eq!(s.prefs.perspective_presets, before, "{v}");
}
assert_eq!(run(&mut s, "perspective.presets.import", json!({"data": text, "replace": null}))["imported"], json!(["Street 2"]));
assert_eq!(run(&mut s, "perspective.presets.import", json!({"data": text, "replace": true}))["imported"], json!(["Street"]));
assert_eq!(s.prefs.perspective_presets.len(), 2);
}
// ---------- M8.10: view options ----------
use vectorcraft_geom::Point;
@@ -246,6 +276,33 @@ fn view_options_toggle_without_undo_steps_and_survive_presets() {
assert_eq!(PerspectiveGrid::from_doc(&back), Some(g));
}
/// Lock Grid, Lock Station Point, Snap to Grid and Show Rulers refuse an `on` given as text or as
/// another value that is not true or false: the grid and the undo history stay as they were. Null is
/// read as the key left out, so it toggles the option.
#[test]
fn view_options_refuse_on_given_as_text() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.lock", json!({"on": true}));
let before = (grid(&s), undo_len(&s));
for id in ["perspective.grid.lock", "perspective.grid.lockStation", "perspective.grid.snap", "perspective.grid.rulers"] {
for v in not_booleans() {
refuses_non_boolean(&mut s, id, &json!({"on": v}), "on");
assert_eq!((grid(&s), undo_len(&s)), before, "{id} {v}");
}
}
for (id, on) in [
("perspective.grid.lock", false),
("perspective.grid.lockStation", true),
("perspective.grid.snap", false),
("perspective.grid.rulers", true),
] {
assert_eq!(run(&mut s, id, json!({"on": null})), json!({"on": on}), "{id} toggles");
assert_eq!(run(&mut s, id, json!({"on": on})), json!({"on": on}), "{id} is set");
}
assert_eq!(undo_len(&s), before.1);
}
#[test]
fn lock_grid_keeps_the_widgets_still_but_planes_switch() {
let mut s = session();
+32
View File
@@ -278,6 +278,38 @@ fn a_cmyk_document_stores_rgb_colors_given_to_the_swatch_commands_as_cmyk() {
assert_eq!(color(&s, &r["names"][0]), Some(green));
}
/// In a CMYK document `swatch.new` (with `color`, `colors` or `mode`), `swatch.newGroup`,
/// `swatch.edit` (its `keepModel` and its `paint`'s) and `swatch.editGroup` refuse a `keepModel` given
/// as text or as another value that is not true or false, and the swatches and the undo history stay
/// as they were. Null is read as `keepModel` left out.
#[test]
fn the_swatch_commands_refuse_a_keep_model_given_as_text() {
use vectorcraft_color::Color;
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
let cms = vectorcraft_color::cms::active();
let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent);
let name = run(&mut s, "swatch.new", json!({"color": "#000000", "keepModel": true}))["name"].clone();
run(&mut s, "swatch.newGroup", json!({"name": "G", "colors": ["#00ff00"]}));
let before = (doc(&s).clone(), undo_len(&s));
let calls = [
("swatch.new", json!({"color": "#ff0000", "keepModel": "true"})),
("swatch.new", json!({"colors": ["#ff0000"], "keepModel": 1})),
("swatch.new", json!({"color": "#ff0000", "mode": "rgb", "keepModel": "false"})),
("swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "keepModel": "true"})),
("swatch.edit", json!({"name": name, "color": "#ff0000", "keepModel": [true]})),
("swatch.edit", json!({"name": name, "paint": {"color": "#ff0000", "keepModel": "true"}, "mode": "cmyk"})),
("swatch.editGroup", json!({"group": "G", "colors": ["#ff0000"], "keepModel": "true"})),
];
for (cmd, p) in &calls {
let r = s.execute(cmd, p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{cmd} {p}: {r:?}");
assert_eq!((doc(&s).clone(), undo_len(&s)), before, "{cmd} {p}");
}
let r = run(&mut s, "swatch.new", json!({"color": "#ff0000", "keepModel": null}));
assert_eq!(doc(&s).swatch(r["name"].as_str().unwrap()).and_then(|w| w.paint.color()), Some(managed(Color::rgb(1.0, 0.0, 0.0))));
}
#[test]
fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() {
use vectorcraft_color::Color;