Commands: Transform Each, artboards and more refuse a boolean or number given as text

object.transformEach refused reflectX, reflectY, random and copy given
as text, but read strokes, corners, objects and patterns given as text
as the key left out. With Scale Strokes & Effects on,
{"scaleH": 200, "scaleV": 200, "strokes": "false"} scaled the stroke,
recorded an undo step and kept the params for Transform Again, which
repeated the call. artboard.rearrange, artboard.move and
artboard.duplicate read their patterns option the same way, and
artboard.setProps reads its patterns, strokes and corners options the
same way.

These commands now refuse such a value with "`<key>` must be true or
false" before they change anything. So do the other options and
numbers of artboard.setProps and artboard.move, the art of Duplicate,
Copy and Cut Artboard, the shape commands and path.create, path.close,
path.setHandle, path.moveAnchors, the Pencil, Curvature, Reshape
Segment and Blob Brush options, text.create, text.createInPath,
text.setRangeStyle, text.editRange, text.getRange, text.touchType,
text.areaOptions, type.step, the font size steps,
type.opticalMarginAlignment, type.systemLayout, type.fractionalWidths,
type.ruby, the symbol and swatch commands, the Appearance panel's
commands, the gradient and freeform gradient commands, Shape Builder,
Live Paint, Image Trace, the opacity of object.setProps, the radius,
sides and relativeRadii of object.setLiveShape, the reference point of
object.setBounds and the artboard of Align, Distribute and Distribute
Spacing. They refuse a number given as text with "`<key>` must be a
number, not <value>", and a gradient stop's tint with "stop <n>:
`tint` must be a number". A null still reads as the key left out.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-22 06:40:27 +07:00
committed by bflatastic
parent f64db69b53
commit caa6962508
31 changed files with 761 additions and 82 deletions
+8 -4
View File
@@ -372,8 +372,10 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
}
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
/// targeted item (on the first target object), else `default`.
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool) -> Result<bool> {
/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or
/// null is an error of command `cmd`.
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
check_switches(p, &["stroke"], cmd)?;
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
return Ok(b);
}
@@ -486,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
if source == target {
return Err(bad(C, "`source` and `target` are the same object"));
}
let copy = bool_or(p, "copy", false);
let copy = switch_or(p, "copy", false, C)?;
// When both objects come out as they were, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
@@ -549,6 +551,8 @@ 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")?;
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
check_switches(p, &["visible"], "appearance.setItem")?;
let paint = paint_from(s, p)?;
let ids = appearance_targets(s, p)?;
if ids.is_empty() {
@@ -762,7 +766,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
let selected = match ids_param(p, "ids") {
+9 -9
View File
@@ -970,7 +970,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let name = resolve_symbol(d, p, "symbol.place")?;
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?);
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
let parent = s.doc()?.target_parent()?;
@@ -1099,7 +1099,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
return Err(bad(C, format!("no symbol named `{name}`")));
}
let expand = bool_or(p, "expandInstances", true);
let expand = switch_or(p, "expandInstances", true, C)?;
s.edit("Delete Symbol", |d, _| {
let inst = instances_of(d, Some(&name));
if expand {
@@ -1216,10 +1216,10 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points(p, C)?;
let st = s.doc()?;
let name = resolve_symbol(&st.doc, p, C)?;
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
let density = f64_or(p, "density", 5.0).clamp(1.0, 10.0);
let scale = f64_or(p, "scale", 1.0).clamp(0.01, 100.0);
let alt = bool_or(p, "alt", false);
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0);
let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0);
let alt = switch_or(p, "alt", false, C)?;
let size = natural_size(&st.doc, &name);
let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set));
// Only a new Symbol Set needs a layer that takes new art.
@@ -1303,9 +1303,9 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
}
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;
let alt = bool_or(p, "alt", false);
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0;
let alt = switch_or(p, "alt", false, C)?;
let st = s.doc()?;
let color = match p.get("color").and_then(color_value) {
Some(c) => super::paint::new_color(s, p, c),
+10 -7
View File
@@ -248,10 +248,11 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
if pts.is_empty() || !pts.iter().all(|q| finite(*q)) {
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
}
let erase = bool_or(p, "erase", false);
let erase = switch_or(p, "erase", false, C)?;
check_numbers(p, &["tolerance", "gap"], C)?;
// Line pieces and edges are only deleted (erase mode), never merged.
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
let open_filled_as_closed = bool_or(p, "openFilledAsClosed", true);
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?;
let swatches = match p.get("pickColorFrom") {
None | Some(Value::Null) => false,
Some(v) => match v.as_str() {
@@ -382,9 +383,11 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
}
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?;
check_numbers(p, &["gap"], "shapeBuilder.regions")?;
let roots = root_ids(s, p)?;
let d = &s.doc()?.doc;
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p));
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p));
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map
.regions
@@ -714,7 +717,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
None => None,
};
let close = bool_or(p, "closeGaps", false);
let close = switch_or(p, "closeGaps", false, C)?;
let targets = live_targets(s, p)?;
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
@@ -801,11 +804,11 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
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));
let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
if !(0.0..=1000.0).contains(&width) {
return Err(bad(C, "width out of range"));
}
let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0);
let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
let edge = s.edit("Live Paint Bucket", |d, _| {
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
@@ -863,7 +866,7 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default");
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
let enhanced = switch_or(p, "enhanced", false, "imageTrace.make")?;
// (A preset without an enhanced version is traced as it is.)
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
+31 -4
View File
@@ -207,6 +207,7 @@ fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
}
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["radius"], "shape.rectangle")?;
let r = rect_of(p, "shape.rectangle")?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
let live = LiveShape::Rectangle {
@@ -230,6 +231,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?);
let r = f64_req(p, "radius", "shape.polygon")?.abs();
check_numbers(p, &["sides", "rotation"], "shape.polygon")?;
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
let rot = f64_or(p, "rotation", 0.0);
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
@@ -240,6 +242,7 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
fn star(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?);
let r1 = f64_req(p, "radius1", "shape.star")?.abs();
check_numbers(p, &["radius2", "points", "rotation"], "shape.star")?;
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
@@ -251,6 +254,24 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
fn flare(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?);
let numbers = [
"diameter",
"opacity",
"brightness",
"growth",
"fuzziness",
"rays",
"longest",
"rayFuzziness",
"rings",
"largest",
"x2",
"y2",
"direction",
"pathLength",
"seed",
];
check_numbers(p, &numbers, "shape.flare")?;
let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0);
let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5);
let opacity = pct("opacity", 50.0).min(1.0) as f32;
@@ -360,12 +381,13 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?);
check_numbers(p, &["segments"], "shape.spiral")?;
let path = shapes::spiral(
c,
f64_req(p, "radius", "shape.spiral")?,
f64_or(p, "decay", 80.0),
number_or(p, "decay", 80.0, "shape.spiral")?,
p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32,
bool_or(p, "clockwise", true),
switch_or(p, "clockwise", true, "shape.spiral")?,
);
add_art(s, "Spiral", path_kind(path, None), None)
}
@@ -373,7 +395,7 @@ fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
fn arc(s: &mut Session, p: &Value) -> Result<Value> {
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, switch_or(p, "closed", false, "shape.arc")?), None), None)
}
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
@@ -480,7 +502,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
if anchors.is_empty() {
return Err(bad("path.create", "need at least one anchor"));
}
PathData::single(SubPath::new(anchors, bool_or(p, "closed", false)))
PathData::single(SubPath::new(anchors, switch_or(p, "closed", false, "path.create")?))
};
let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
// Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set.
@@ -495,6 +517,11 @@ 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")?;
check_numbers(p, &["size"], "text.create")?;
check_switches(p, &["vertical", "placeholder"], "text.create")?;
if let Some(a) = p.get("area") {
check_numbers(a, &["width", "height"], "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);
+12 -11
View File
@@ -665,9 +665,10 @@ 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 = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let closed = switch_or(p, "closed", false, C)?;
let brush = str_param(p, "style") == Some("brush");
let keep_selected = bool_or(p, "keepSelected", true);
let keep_selected = switch_or(p, "keepSelected", true, C)?;
let filled = switch_or(p, "fill", false, C)?;
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
let at_start = str_param(ext, "end") == Some("start");
@@ -764,7 +765,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "need at least two points"));
}
let sp = fit_freehand(&pts, tol, closed);
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
let fill = if filled { s.paint.fill.clone() } else { Paint::None };
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
// gets black. A brush is its stroke.
let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };
@@ -780,12 +781,11 @@ 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().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::<Result<_>>()?;
if pts.is_empty() {
return Err(bad(C, "need at least one point"));
}
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false));
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?);
if let Some(id) = id_param(p, "id") {
s.edit("Curvature", |d, sel| {
*path_mut(d, id)? = PathData::single(sp);
@@ -831,7 +831,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
None => EndCurve::Keep,
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
};
let corner = bool_or(p, "corner", false);
let corner = switch_or(p, "corner", false, C)?;
let op = match str_param(p, "op") {
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
@@ -1030,7 +1030,7 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
return Err(bad(C, "invalid delta"));
}
let perpendicular = bool_or(p, "perpendicular", false);
let perpendicular = switch_or(p, "perpendicular", false, C)?;
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
@@ -1490,11 +1490,12 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
} else {
Paint::solid(Color::BLACK)
};
let keep_selected = bool_or(p, "keepSelected", false);
let only_selection = bool_or(p, "mergeOnlySelection", false);
let keep_selected = switch_or(p, "keepSelected", false, C)?;
let only_selection = switch_or(p, "mergeOnlySelection", false, C)?;
let merge = switch_or(p, "merge", true, C)?;
let st = s.doc()?;
let mut merge_ids = vec![];
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
if merge && !(only_selection && st.selection.is_empty()) {
let all = Selection::default();
let from = if only_selection { &st.selection } else { &all };
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
+3 -3
View File
@@ -176,7 +176,7 @@ pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
/// automatic points it shows first, and the stops follow the points' colours.
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
let item = item_target(s, p, cmd)?;
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?;
let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?;
if ids.is_empty() {
@@ -308,7 +308,7 @@ fn split_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.splitLine";
let line = index_param(p, "line", C)?;
let segment = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5);
let t = number_or(p, "t", 0.5, C)?;
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
s.select_freeform_point(Some(index));
Ok(json!({ "index": index }))
@@ -332,7 +332,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
fn get(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.get";
let stroke = bool_or(p, "stroke", !s.fill_active);
let stroke = switch_or(p, "stroke", !s.fill_active, C)?;
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
let mut v = vectorcraft_tools::params::freeform_json(&f);
+10 -3
View File
@@ -117,7 +117,12 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
if d.swatch(&name).is_none() && st.get("color").is_some() {
continue;
}
let tint = st.get("tint").and_then(Value::as_f64).map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
let tint = st
.get("tint")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number", k = "tint")))
.transpose()?;
let tint = tint.map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else {
continue;
};
@@ -372,7 +377,9 @@ pub(crate) fn apply_gradient_edit_in(
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.editGradient";
let item = item_target(s, p, C)?;
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
check_switches(p, &["reverse"], C)?;
check_numbers(p, &["angle"], C)?;
let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?;
// How the gradient lies on a stroke (the panel's Stroke buttons).
@@ -554,7 +561,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
}
let p = &p;
let item = item_target(s, p, C)?;
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
let item = item.of_kind(s, !stroke);
let targets = match item {
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
+5
View File
@@ -870,6 +870,11 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
}
None => vec![p],
};
check_switches(p, &["moveArt", "scaleArt", "strokes", "corners", "patterns", "lockedAndHidden"], "artboard.setProps")?;
for q in &entries {
check_switches(q, &["locked"], "artboard.setProps")?;
check_numbers(q, &["x", "y", "width", "height"], "artboard.setProps")?;
}
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
// Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one.
let scale_art = bool_or(p, "scaleArt", false);
+2
View File
@@ -353,6 +353,7 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let random = switch_or(p, "random", false, "object.transformEach")?;
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
let copy = switch_or(p, "copy", false, "object.transformEach")?;
check_switches(p, &["strokes", "corners", "objects", "patterns"], "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"));
@@ -1018,6 +1019,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 = switch_or(p, "moveArtwork", true, CMD)?;
check_switches(p, &["patterns"], 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();
+7 -2
View File
@@ -1155,6 +1155,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")?;
check_numbers(p, &["opacity"], "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") {
@@ -1424,6 +1425,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
check_switches(p, &["patterns"], "object.align")?;
check_numbers(p, &["artboard"], "object.align")?;
if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical"));
}
@@ -1651,6 +1653,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")?;
check_numbers(p, &["artboard"], "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")?;
@@ -1725,6 +1728,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")?;
check_numbers(p, &["artboard"], "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")?;
@@ -1811,7 +1815,7 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
/// art with a width or height below `least` of the one it has keeps `least` of it.
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?;
check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?;
let st = s.doc()?;
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
check_switches(p, &["proportional"], "object.setBounds")?;
@@ -2346,7 +2350,8 @@ 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")?;
check_switches(p, &["invertPie", "makeSidesEqual", "relativeRadii"], "object.setLiveShape")?;
check_numbers(p, &["radius", "sides"], "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,
+1
View File
@@ -351,6 +351,7 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
};
}
if let Some(g) = p.get("gradient") {
check_numbers(g, &["angle"], "paint")?;
// A stop naming a process swatch keeps the swatch's model.
let stored = super::gradient::stop_colors(s, p, g);
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
+2
View File
@@ -72,6 +72,7 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
/// copy commands: by default the artboards the Artboard tool has selected (its active one when none
/// of them is in the document) and its Move/Copy Artwork with Artboard option.
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
check_switches(p, &["art"], cmd)?;
let t = s.tool_options_of("artboard");
let d = &s.doc()?.doc;
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
@@ -159,6 +160,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep
/// their places relative to each other, together right of the last artboard.
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "artboard.duplicate")?;
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
let arts = artboards_art(s, &indices, art, false)?;
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
+3 -2
View File
@@ -162,7 +162,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
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);
let independent = switch_or(p, "independent", false, "path.close")?;
s.edit("Close Path", |d, sel| {
let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
@@ -182,6 +182,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(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
check_switches(p, &["patterns"], "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();
@@ -232,7 +233,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = bool_or(p, "independent", false);
let independent = switch_or(p, "independent", false, "path.setHandle")?;
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
+7 -2
View File
@@ -138,6 +138,7 @@ 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)?;
check_switches(p, &["spot", "global"], 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) };
@@ -229,6 +230,7 @@ pub(super) fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.delete";
let names = names_param(p, C)?;
check_switches(p, &["unlink"], C)?;
let d = &s.doc()?.doc;
// Global swatches going away (named ones and those in named groups): their links are dropped.
let mut global: Vec<String> = vec![];
@@ -304,6 +306,7 @@ 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)?;
check_switches(p, &["fromArtwork", "toGlobal", "includeTints"], C)?;
let colors: Vec<Color> = p
.get("colors")
.and_then(Value::as_array)
@@ -365,6 +368,7 @@ fn insert_before<T>(list: &mut Vec<T>, items: Vec<T>, anchor: Option<&str>, name
fn swatch_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.move";
check_numbers(p, &["to"], C)?;
let names = names_param(p, C)?;
let to = p.get("to").and_then(Value::as_u64).map(index);
let group = str_param(p, "group").map(str::to_string);
@@ -530,6 +534,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
if sw.is_reserved() {
return Err(bad(C, format!("`{name}` can't be edited")));
}
check_switches(p, &["spot", "global"], C)?;
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
let solid = sw.paint.color();
if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) {
@@ -649,8 +654,8 @@ fn swatch_sort(s: &mut Session, _: &Value) -> Result<Value> {
fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.addUsedColors";
let global = bool_or(p, "global", false);
let only_selection = bool_or(p, "selection", false);
let global = switch_or(p, "global", false, C)?;
let only_selection = switch_or(p, "selection", false, C)?;
let (added, linked) = s.edit("Add Used Colors", |d, sel| {
let ids: Vec<NodeId> = if only_selection { sel.objects.clone() } else { d.layers.iter().map(|l| l.id).collect() };
if only_selection && ids.is_empty() {
+18 -10
View File
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some("Cmd+Shift+."),
"{by?: steps (1)} type.step {attribute: size}",
has_selection,
|s, p| type_step(s, &json!({"attribute": "size", "by": f64_or(p, "by", 1.0)}))
|s, p| type_step(s, &json!({"attribute": "size", "by": number_or(p, "by", 1.0, "type.size.increase")?}))
),
cmd!(
"type.size.decrease",
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some("Cmd+Shift+,"),
"{by?: steps (1)} type.step {attribute: size, by: -by}",
has_selection,
|s, p| type_step(s, &json!({"attribute": "size", "by": -f64_or(p, "by", 1.0)}))
|s, p| type_step(s, &json!({"attribute": "size", "by": -number_or(p, "by", 1.0, "type.size.decrease")?}))
),
cmd!(
"type.size.increaseByFive",
@@ -179,10 +179,13 @@ fn byte_param(p: &Value, k: &str) -> Option<usize> {
p.get(k).and_then(Value::as_u64).map(index)
}
fn range_of(p: &Value, len: usize) -> (usize, usize) {
/// The bytes `start..end` of `p` in text of `len` bytes, clamped and ordered (without them, all of
/// it). A `start` or `end` that is not a number or null, such as `"2"`, is an error.
fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> {
check_numbers(p, &["start", "end"], cmd)?;
let a = byte_param(p, "start").unwrap_or(0).min(len);
let b = byte_param(p, "end").unwrap_or(len).min(len);
(a.min(b), a.max(b))
Ok((a.min(b), a.max(b)))
}
fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
@@ -190,7 +193,7 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len);
let (a, b) = range_of(p, len, C)?;
let styled: Option<Vec<TextRun>> = match p.get("runs") {
None | Some(Value::Null) => None,
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(C, format!("bad `runs`: {e}")))?),
@@ -221,9 +224,9 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len);
let (a, b) = range_of(p, len, "text.getRange")?;
let mut runs = edit::slice_runs(&t.runs, a, b);
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
if switch_or(p, "effective", false, "text.getRange")? {
for run in &mut runs {
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
}
@@ -398,6 +401,8 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
impl CharChange {
/// `setup` gives the superscript, subscript and small caps proportions.
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
check_switches(p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "noBreak"], cmd)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?;
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
@@ -652,7 +657,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
let t = text_ref(s, id)?;
change.localize(&t, C)?;
let (a, b) = range_of(p, edit::runs_len(&t.runs));
let (a, b) = range_of(p, edit::runs_len(&t.runs), C)?;
let n = s.edit("Character", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
if a == b && !t.plain_text().is_empty() {
@@ -682,6 +687,7 @@ fn glyph_em(t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.touchType";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
check_numbers(p, &["start"], C)?;
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
return Err(bad(C, "nothing to change: give dx, dy, hScale, vScale or rotation"));
@@ -866,6 +872,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "area type needs a path that encloses an area"));
}
super::paint::check_keep_model(p, C)?;
check_numbers(p, &["size"], C)?;
check_switches(p, &["vertical", "placeholder"], 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") {
@@ -952,7 +960,7 @@ fn type_ranges(
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
// (text, its range; None: all of it).
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") {
Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs))))],
Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs), c)?))],
None => {
let editing = editing_range(s)?.filter(|_| p.get("ids").filter(|v| !v.is_null()).is_none());
match editing {
@@ -990,7 +998,7 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
Some("baselineShift") => Step::BaselineShift,
_ => return Err(bad(C, "attribute must be size|leading|tracking|kerning|baselineShift")),
};
let by = f64_or(p, "by", 1.0);
let by = number_or(p, "by", 1.0, C)?;
if !by.is_finite() {
return Err(bad(C, "by must be a number"));
}
+16 -10
View File
@@ -150,7 +150,7 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
Some(NodeKind::Text(t)) => Some(t.optical_margin),
_ => None,
});
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!first.unwrap_or(false));
let on = switch_or(p, "on", !first.unwrap_or(false), "type.opticalMarginAlignment")?;
s.edit("Optical Margin Alignment", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
@@ -168,7 +168,7 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
/// setting) on the document and every text object, in one undo step. Off is the default.
fn system_layout(s: &mut Session, p: &Value) -> Result<Value> {
let current = s.doc()?.doc.setup.system_layout;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!current);
let on = switch_or(p, "on", !current, "type.systemLayout")?;
s.edit("System Layout", |d, _| {
d.setup.system_layout = on;
let mut ids = vec![];
@@ -198,7 +198,7 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result<Value> {
Some(NodeKind::Text(t)) => Some(t.fractional_widths),
_ => None,
});
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!first.unwrap_or(true));
let on = switch_or(p, "on", !first.unwrap_or(true), "type.fractionalWidths")?;
s.edit("Fractional Widths", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
@@ -219,7 +219,7 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.ruby";
let ids = text_targets(s, p, C)?;
let range = TextRange::parse(p, C)?;
let remove = p.get("remove").and_then(Value::as_bool).unwrap_or(false);
let remove = switch_or(p, "remove", false, C)?;
let reading = str_param(p, "text").map(str::trim).filter(|t| !t.is_empty()).map(str::to_string);
if !remove && reading.is_none() {
return Err(bad(C, "give `text` (the reading) or `remove`"));
@@ -235,11 +235,11 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
Some("end") | Some("right") | Some("bottom") => RubyAlign::End,
Some(_) => return Err(bad(C, "`align` is start, center or end")),
};
let size = p.get("size").and_then(Value::as_f64).unwrap_or(50.0);
let size = number_or(p, "size", 50.0, C)?;
if !size.is_finite() || !(1.0..=100.0).contains(&size) {
return Err(bad(C, "`size` is a percentage of the base text's size, 1 to 100"));
}
let overhang = p.get("overhang").and_then(Value::as_bool).unwrap_or(true);
let overhang = switch_or(p, "overhang", true, C)?;
let mut first: Option<Value> = None;
s.edit("Ruby", |d, _| {
for id in &ids {
@@ -890,6 +890,7 @@ fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent`
/// while the fit isn't Shrink Text).
pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result<Option<AreaFit>> {
check_numbers(p, &["fitMinPercent"], c)?;
let min = p.get("fitMinPercent").and_then(Value::as_f64);
let cur_min = match cur {
AreaFit::ShrinkText { min_percent } => Some(min_percent),
@@ -942,6 +943,7 @@ const AREA_KEYS: [&str; 7] = ["rows", "columns", "gutter", "inset", "firstBaseli
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.areaOptions";
check_numbers(p, &["width", "height"], C)?;
let ids: Vec<NodeId> = {
let d = &s.doc()?.doc;
text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect()
@@ -1236,8 +1238,10 @@ mod area_tests {
// New type inherits the document's setting.
let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap();
assert!(text(&s, made).system_layout);
// A boolean given as text is ignored, so the setting toggles off.
assert_eq!(s.execute("type.systemLayout", &json!({"on": "no"})).unwrap()["on"], false);
// A boolean given as text is an error, and the setting stays on.
assert!(s.execute("type.systemLayout", &json!({"on": "no"})).is_err());
assert!(setup(&s).system_layout && text(&s, id).system_layout);
assert_eq!(s.execute("type.systemLayout", &json!({"on": false})).unwrap()["on"], false);
assert!(!setup(&s).system_layout && !text(&s, id).system_layout && !text(&s, made).system_layout);
s.execute("edit.undo", &json!({})).unwrap();
assert!(setup(&s).system_layout && text(&s, id).system_layout);
@@ -1282,8 +1286,10 @@ mod area_tests {
assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}");
assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}");
assert_eq!(serde_json::to_value(text(&s)).unwrap()["fractionalWidths"], false);
// A boolean given as text is ignored, so the flag toggles back on.
assert_eq!(s.execute("type.fractionalWidths", &json!({"on": "no"})).unwrap()["on"], true);
// A boolean given as text is an error, and the flag stays off.
assert!(s.execute("type.fractionalWidths", &json!({"on": "no"})).is_err());
assert!(!text(&s).fractional_widths);
assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], true);
assert!(text(&s).fractional_widths);
assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false);
assert!(!text(&s).fractional_widths);
+2
View File
@@ -368,6 +368,8 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
check_numbers(p, &["dx", "dy"], "artboard.move")?;
check_switches(p, &["moveArt", "copy", "patterns"], "artboard.move")?;
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let move_art = bool_or(p, "moveArt", false);
let copy = bool_or(p, "copy", false);
+125
View File
@@ -531,6 +531,29 @@ fn align_and_distribute_refuse_a_boolean_given_as_text() {
assert_eq!(state(&s).1, [0.0, 0.0]);
}
/// Align, Distribute and Distribute Spacing to an artboard refuse an `artboard` given as text or as
/// another value that is not a number, naming the key: nothing moves and no undo step is recorded.
/// A null is read as the key left out.
#[test]
fn align_and_distribute_refuse_an_artboard_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 calls = [
("object.align", json!({"horizontal": "right", "to": "artboard", "artboard": "0"})),
("object.distribute", json!({"horizontal": "left", "to": "artboard", "artboard": [0]})),
("object.distributeSpacing", json!({"axis": "horizontal", "to": "artboard", "artboard": true})),
]
.map(|(cmd, p)| not_a_number(cmd, p, "artboard"));
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
s.execute("object.align", &json!({"horizontal": "right", "to": "artboard", "artboard": null})).unwrap();
let right = |id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x1;
assert_eq!([right(a), right(b), right(c)], [s.doc().unwrap().doc.artboards[0].rect.x1; 3]);
}
/// 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.
@@ -808,6 +831,32 @@ pub(crate) fn history(s: &Session) -> (usize, usize, bool) {
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
}
/// A call to `cmd` with `p` that must fail with "`key` must be true or false".
pub(crate) fn not_a_switch(cmd: &str, p: serde_json::Value, key: &str) -> (String, serde_json::Value, String) {
(cmd.to_string(), p, format!("`{key}` must be true or false"))
}
/// A call to `cmd` with `p` that must fail with "`key` must be a number, not <value>", the value
/// `p` gives for `key`.
pub(crate) fn not_a_number(cmd: &str, p: serde_json::Value, key: &str) -> (String, serde_json::Value, String) {
let msg = format!("`{key}` must be a number, not {}", p[key]);
(cmd.to_string(), p, msg)
}
/// The calls among `calls` (a command, its params and the error it must give) that do not fail
/// with that error, or that change the document or its history, as "command params".
pub(crate) fn not_refused(s: &mut Session, calls: &[(String, serde_json::Value, String)]) -> Vec<String> {
calls
.iter()
.filter(|(cmd, p, want)| {
let (before, h) = (s.doc().unwrap().doc.clone(), history(s));
let r = s.execute(cmd, p);
!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want) && s.doc().unwrap().doc == before && history(s) == h)
})
.map(|(cmd, p, _)| format!("{cmd} {p}"))
.collect()
}
/// Select `ids` with a move of them left to redo, and mark the document saved.
pub(crate) fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
@@ -1132,6 +1181,82 @@ fn a_boolean_given_as_text_for_a_transform_is_an_error() {
assert_eq!((b.width(), w), (100.0, before.2));
}
/// Object Properties' opacity, Live Shape Properties' corner radius and polygon sides, and the
/// Transform panel's reference point given as text or as another value that is not a number are an
/// error naming the key, and so is Live Shape Properties' relative rounding given as anything but
/// true or false; the objects and the undo history stay as they were. Numbers and null still work.
#[test]
fn object_properties_live_shapes_and_bounds_refuse_a_number_or_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let mut calls = [
("object.setProps", json!({"id": a.0, "opacity": "50"}), "opacity"),
("object.setLiveShape", json!({"ids": [a.0], "radius": "5"}), "radius"),
("object.setLiveShape", json!({"ids": [poly.0], "sides": "8"}), "sides"),
("object.setBounds", json!({"width": 40, "reference": "0"}), "reference"),
]
.map(|(cmd, p, key)| not_a_number(cmd, p, key))
.to_vec();
calls.push(not_a_switch("object.setLiveShape", json!({"ids": [a.0], "radius": 5, "relativeRadii": "true"}), "relativeRadii"));
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
s.execute("object.setProps", &json!({"id": a.0, "opacity": 50})).unwrap();
s.execute("object.setBounds", &json!({"width": 40, "reference": 0, "x": null})).unwrap();
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
assert_eq!((n.opacity, n.geometric_bounds().unwrap()), (0.5, vectorcraft_geom::Rect::new(0.0, 0.0, 40.0, 20.0)));
}
/// The shape tools' commands and the Pen's `path.create` refuse a number or an on/off option given
/// as text or as another value of the wrong kind, naming the key, and draw nothing; null is read as
/// the key left out.
#[test]
fn shape_commands_refuse_a_number_or_boolean_given_as_text() {
let mut s = session();
let star = json!({"cx": 100, "cy": 100, "radius1": 20});
let flare = json!({"cx": 100, "cy": 100});
let spiral = json!({"cx": 100, "cy": 100, "radius": 40});
let with = |base: &serde_json::Value, key: &str, v: serde_json::Value| {
let mut p = base.clone();
p[key] = v;
p
};
let mut calls: Vec<_> = [
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "radius": "2"}), "radius"),
("shape.polygon", json!({"cx": 100, "cy": 100, "radius": 20, "rotation": "45"}), "rotation"),
("shape.polygon", json!({"cx": 100, "cy": 100, "radius": 20, "sides": "8"}), "sides"),
("shape.star", with(&star, "radius2", json!("10")), "radius2"),
("shape.star", with(&star, "rotation", json!([45])), "rotation"),
("shape.star", with(&star, "points", json!("7")), "points"),
("shape.flare", with(&flare, "diameter", json!("100")), "diameter"),
("shape.flare", with(&flare, "opacity", json!("50")), "opacity"),
("shape.flare", with(&flare, "largest", json!(true)), "largest"),
("shape.flare", with(&flare, "rays", json!("15")), "rays"),
("shape.flare", json!({"cx": 100, "cy": 100, "x2": "300", "y2": 300}), "x2"),
("shape.flare", with(&flare, "direction", json!("45")), "direction"),
("shape.flare", with(&flare, "seed", json!("7")), "seed"),
("shape.spiral", with(&spiral, "decay", json!("80")), "decay"),
("shape.spiral", with(&spiral, "segments", json!("10")), "segments"),
]
.into_iter()
.map(|(cmd, p, key)| not_a_number(cmd, p, key))
.collect();
calls.extend(
[
("shape.spiral", with(&spiral, "clockwise", json!("false")), "clockwise"),
("shape.arc", json!({"x1": 0, "y1": 0, "x2": 50, "y2": 50, "closed": "true"}), "closed"),
("path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 50, "y": 0}], "closed": 1}), "closed"),
]
.map(|(cmd, p, key)| not_a_switch(cmd, p, key)),
);
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
s.execute("shape.star", &json!({"cx": 100, "cy": 100, "radius1": 20, "radius2": null, "points": null, "rotation": null})).unwrap();
s.execute("shape.flare", &json!({"cx": 100, "cy": 100, "diameter": 50, "x2": null, "seed": 7})).unwrap();
s.execute("shape.arc", &json!({"x1": 0, "y1": 0, "x2": 50, "y2": 50, "closed": false})).unwrap();
}
/// #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]
+26
View File
@@ -420,6 +420,32 @@ fn appearance_commands_that_change_nothing_record_no_undo_step() {
}
}
/// The Appearance panel's commands refuse a weight or opacity that is not a number and an on/off
/// option that is not true or false, given as text or as another value of the wrong kind; the
/// objects and the undo history stay as they were. Numbers, true, false and null still work.
#[test]
fn appearance_commands_refuse_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
let a = rect(&mut s, 50.0);
let b = rect(&mut s, 200.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let calls = [
not_a_number("appearance.setItem", json!({"index": 1, "weight": "3"}), "weight"),
not_a_number("appearance.setItem", json!({"index": 0, "opacity": "50"}), "opacity"),
not_a_switch("appearance.setItem", json!({"index": 0, "visible": "false"}), "visible"),
not_a_switch("appearance.transfer", json!({"source": b.0, "target": a.0, "copy": "true"}), "copy"),
not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "reverse": "true"}), "reverse"),
not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "append": 1}), "append"),
];
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 3, "visible": null}));
run(&mut s, "appearance.transfer", json!({"source": b.0, "target": a.0, "copy": true}));
assert_eq!((stroke(&node(&s, a), 1).width, fill_paint(&node(&s, a), 0)), (stroke(&node(&s, b), 1).width, fill_paint(&node(&s, b), 0)));
}
#[test]
fn graphic_style_apply_replaces_the_stack() {
let mut s = session();
+37
View File
@@ -1069,6 +1069,43 @@ fn symbol_stainer_refuses_a_keep_model_given_as_text() {
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
}
/// Place Symbol Instance, Delete Symbol, the Symbol Sprayer and the symbolism tools refuse a number
/// or an on/off option given as text or as another value of the wrong kind, naming the key; the
/// document and the undo history stay as they were. Numbers, true, false and null still work.
#[test]
fn symbol_commands_refuse_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
let (_, name, b) = make_symbol(&mut s);
let at = json!([[b.center().x, b.center().y]]);
let mut calls: Vec<_> = [
("symbol.place", json!({"name": name, "x": "200"}), "x"),
("symbol.place", json!({"name": name, "x": 200, "y": [100]}), "y"),
("symbol.spray", json!({"name": name, "points": at, "radius": "40"}), "radius"),
("symbol.spray", json!({"name": name, "points": at, "density": "5"}), "density"),
("symbol.spray", json!({"name": name, "points": at, "scale": true}), "scale"),
("symbol.adjust", json!({"tool": "size", "points": at, "radius": "40"}), "radius"),
("symbol.adjust", json!({"tool": "size", "points": at, "intensity": "10"}), "intensity"),
]
.into_iter()
.map(|(cmd, p, key)| not_a_number(cmd, p, key))
.collect();
calls.extend(
[
("symbol.spray", json!({"name": name, "points": at, "alt": "true"}), "alt"),
("symbol.adjust", json!({"tool": "size", "points": at, "alt": 1}), "alt"),
("symbol.delete", json!({"name": name, "expandInstances": "false"}), "expandInstances"),
]
.map(|(cmd, p, key)| not_a_switch(cmd, p, key)),
);
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
s.execute("symbol.place", &json!({"name": name, "x": 200, "y": null})).unwrap();
s.execute("symbol.spray", &json!({"name": name, "points": at, "radius": 40, "alt": false})).unwrap();
s.execute("symbol.delete", &json!({"name": name, "expandInstances": true})).unwrap();
assert!(s.doc().unwrap().doc.symbols.is_empty());
}
#[test]
fn symbol_sprayer_tool_gesture_is_one_undo_step() {
let mut s = session();
+46
View File
@@ -716,6 +716,52 @@ fn live_paint_stroke_edge() {
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err());
}
/// Shape Builder refuses an on/off option that is not true or false and a tolerance or gap that is
/// not a number, given as text or as another value of the wrong kind, naming the key; the document
/// and the undo history stay as they were. True, false and null still work.
#[test]
fn shape_builder_refuses_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
two(&mut s);
let merge = |extra: Value| {
let mut p = json!({"points": [[75, 50]]});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let calls = [
not_a_switch("shapeBuilder.merge", merge(json!({"erase": "true"})), "erase"),
not_a_switch("shapeBuilder.merge", merge(json!({"openFilledAsClosed": "false"})), "openFilledAsClosed"),
not_a_number("shapeBuilder.merge", merge(json!({"erase": true, "tolerance": "2"})), "tolerance"),
not_a_number("shapeBuilder.merge", merge(json!({"gap": "5"})), "gap"),
not_a_switch("shapeBuilder.regions", json!({"openFilledAsClosed": 1}), "openFilledAsClosed"),
not_a_number("shapeBuilder.regions", json!({"gap": [5]}), "gap"),
];
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
s.execute("shapeBuilder.merge", &merge(json!({"erase": false, "openFilledAsClosed": null}))).unwrap();
}
/// Live Paint and Image Trace refuse an on/off option that is not true or false and a width or
/// tolerance that is not a number, given as text or as another value of the wrong kind, naming the
/// key; the document and the undo history stay as they were. Numbers and null still work.
#[test]
fn live_paint_and_image_trace_refuse_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
let g = live(&mut s);
let calls = [
not_a_switch("livePaint.gapOptions", json!({"closeGaps": "true"}), "closeGaps"),
not_a_number("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "width": "3"}), "width"),
not_a_number("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "tolerance": [4]}), "tolerance"),
not_a_switch("imageTrace.make", json!({"enhanced": "true"}), "enhanced"),
];
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "width": 3, "tolerance": null})).unwrap();
assert_eq!(node(&s, NodeId(r["edge"].as_u64().unwrap())).appearance.stroke_width(), 3.0);
}
#[test]
fn live_paint_stroke_edge_skips_hidden_edges() {
let mut s = session();
+34
View File
@@ -1230,6 +1230,40 @@ fn move_anchors_reshape_and_close_refuse_a_number_given_as_text() {
assert!(after.subpaths[0].closed && moved == Some(before.subpaths[0].anchors[1].p.x + 10.0), "{after:?}");
}
/// Close Path, Set Handle, Move Anchors and the Pencil, Curvature, Reshape Segment and Blob Brush
/// commands refuse an on/off option given as text or as another value that is not true or false,
/// naming the key, and change nothing; true, false and null still work.
#[test]
fn path_commands_refuse_a_boolean_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 stroke = json!([[100, 300], [150, 340], [200, 300]]);
let calls = [
("path.close", json!({"id": id.0, "independent": "true"}), "independent"),
("path.setHandle", json!({"id": id.0, "anchor": 0, "x": 160, "y": 90, "independent": 1}), "independent"),
("path.moveAnchors", json!({"dx": 10, "patterns": "true"}), "patterns"),
("path.freehand", json!({"points": stroke, "closed": "true"}), "closed"),
("path.freehand", json!({"points": stroke, "keepSelected": "false"}), "keepSelected"),
("path.freehand", json!({"points": stroke, "fill": [true]}), "fill"),
("path.curvature", json!({"points": stroke, "closed": "true"}), "closed"),
("path.curvature", json!({"points": [{"x": 100, "y": 300, "corner": "true"}, [150, 340], [200, 300]]}), "corner"),
("path.curvatureEdit", json!({"id": id.0, "op": "move", "anchor": 1, "x": 300, "y": 150, "corner": "true"}), "corner"),
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "dy": 20, "perpendicular": "true"}), "perpendicular"),
("path.blob", json!({"points": stroke, "keepSelected": "true"}), "keepSelected"),
("path.blob", json!({"points": stroke, "mergeOnlySelection": 1}), "mergeOnlySelection"),
("path.blob", json!({"points": stroke, "merge": "false"}), "merge"),
]
.map(|(cmd, p, key)| super::tests::not_a_switch(cmd, p, key));
let wrong = super::tests::not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "x": 160, "y": 90, "independent": true})).unwrap();
s.execute("path.close", &json!({"id": id.0, "independent": null})).unwrap();
s.execute("path.freehand", &json!({"points": stroke, "closed": false, "keepSelected": null, "fill": true})).unwrap();
s.execute("path.blob", &json!({"points": stroke, "merge": false, "keepSelected": null, "mergeOnlySelection": false})).unwrap();
assert!(path(&s, id).subpaths[0].closed);
}
#[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
+21
View File
@@ -238,6 +238,27 @@ fn lines_join_split_and_follow_the_selection() {
assert_eq!(g["points"][i]["at"], json!([190.0, 190.0]));
}
/// Split Line refuses a `t` that is not a number, and the point commands and the query a `stroke`
/// that is not true or false, given as text or as another value of the wrong kind; the gradient and
/// the undo history stay as they were. Numbers, false and null still work.
#[test]
fn freeform_commands_refuse_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let (mut s, id) = freeform_rect();
run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]}));
let calls = [
not_a_number("paint.freeform.splitLine", json!({"line": 0, "segment": 0, "t": "0.25"}), "t"),
not_a_switch("paint.freeform.addPoint", json!({"at": [150, 150], "stroke": "false"}), "stroke"),
not_a_switch("paint.freeform.get", json!({"stroke": [false]}), "stroke"),
];
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
run(&mut s, "paint.freeform.splitLine", json!({"line": 0, "segment": 0, "t": null}));
let g = run(&mut s, "paint.freeform.get", json!({"stroke": false}));
assert_eq!(g["lines"][0].as_array().map(Vec::len), Some(3));
assert_eq!(points(&s, id).lines[0].len(), 3);
}
#[test]
fn point_commands_need_a_freeform_gradient() {
let mut s = session();
+27
View File
@@ -96,6 +96,33 @@ fn bad_gradients_are_rejected_without_editing() {
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
/// The gradient commands refuse a `reverse` or `stroke` that is not true or false, and an `angle`
/// or a stop's `tint` that is not a number, given as text or as another value of the wrong kind:
/// the object and the undo history stay as they were. True, false, numbers and null still work.
#[test]
fn gradient_commands_refuse_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
let red = s.execute("swatch.new", &json!({"color": "#ff0000", "global": true})).unwrap()["name"].clone();
let stops = |tint: Value| json!([{"offset": 0, "color": "#ff0000", "swatch": red, "tint": tint}, {"offset": 1, "color": "#000000"}]);
let calls = [
not_a_switch("paint.editGradient", json!({"reverse": "true"}), "reverse"),
not_a_switch("paint.editGradient", json!({"angle": 45, "stroke": "false"}), "stroke"),
not_a_switch("paint.setGradientGeom", json!({"aspect": 50, "stroke": 0}), "stroke"),
not_a_number("paint.editGradient", json!({"angle": "45"}), "angle"),
("paint.setFill".to_string(), json!({"gradient": {"angle": "45"}}), "`angle` must be a number, not \"45\"".to_string()),
("paint.editGradient".to_string(), json!({"stops": stops(json!("50"))}), "stop 0: `tint` must be a number".to_string()),
("paint.setFill".to_string(), json!({"gradient": {"stops": stops(json!([50]))}}), "stop 0: `tint` must be a number".to_string()),
];
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
s.execute("paint.editGradient", &json!({"angle": 30, "reverse": false, "stroke": null, "stops": stops(json!(50))})).unwrap();
let g = fill_gradient(&s, id);
assert_eq!((g.angle, g.gradient.stops[0].tint), (30.0, 0.5));
}
#[test]
fn stop_opacity_and_offsets_are_normalised() {
let mut s = session();
+45 -7
View File
@@ -442,9 +442,9 @@ 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.
/// Transform Each refuses Reflect X, Reflect Y, Random, Copy, Scale Strokes & Effects, Scale
/// Corners, Transform Objects or Transform Patterns 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();
@@ -458,14 +458,51 @@ fn transform_each_refuses_a_boolean_given_as_text() {
("random", json!({"moveH": 10, "random": "false"})),
("copy", json!({"copy": "true"})),
("copy", json!({"moveH": 10, "copy": [true]})),
("strokes", json!({"scaleH": 50, "scaleV": 50, "strokes": "false"})),
("corners", json!({"scaleH": 50, "corners": 1})),
("objects", json!({"moveH": 10, "objects": "false"})),
("patterns", json!({"moveH": 10, "patterns": [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();
let p = json!({"moveH": 10, "reflectX": true, "random": false, "copy": null, "objects": null, "patterns": false});
s.execute("object.transformEach", &p).unwrap();
assert_eq!((bounds(&s, a), count(&s)), (Rect::new(10.0, 0.0, 110.0, 100.0), before.2));
}
/// With Scale Strokes & Effects on, Transform Each asked to scale a stroked rectangle with
/// `strokes` given as text, such as "false", fails: the saved document, the undo history and what
/// Transform Again repeats stay as they were, and Transform Again still repeats the Transform Each
/// before it. `strokes: false` scales the rectangle and keeps its stroke weight; `strokes: true`
/// and a null (the preference) scale both.
#[test]
fn transform_each_refuses_strokes_given_as_text_and_keeps_transform_again() {
let mut s = session();
s.prefs.scale_strokes = true;
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
s.execute("object.transformEach", &json!({"moveH": 10})).unwrap();
let saved = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false);
let repeat = |s: &Session| s.doc().unwrap().last_transform.clone();
let before = (saved(&s), history(&s), repeat(&s));
for strokes in [json!("false"), json!("true"), json!(0)] {
let p = json!({"scaleH": 200, "scaleV": 200, "strokes": strokes});
refuses_non_boolean(&mut s, "object.transformEach", &p, "strokes");
assert!((saved(&s), history(&s), repeat(&s)) == before, "{p}");
}
let weight = |s: &Session| node(s, a).appearance.stroke_width();
s.execute("object.transformAgain", &json!({})).unwrap();
assert_eq!((bounds(&s, a), weight(&s)), (Rect::new(20.0, 0.0, 120.0, 100.0), 4.0), "the move before it repeats");
for (strokes, w) in [(json!(false), 4.0), (json!(true), 8.0), (Value::Null, 8.0)] {
let p = json!({"scaleH": 200, "scaleV": 200, "strokes": strokes});
s.execute("object.transformEach", &p).unwrap();
assert_eq!((bounds(&s, a), weight(&s)), (Rect::new(-30.0, -50.0, 170.0, 150.0), w), "{p}");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// #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]
@@ -977,9 +1014,9 @@ 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.
/// Rearrange All Artboards refuses `byColumn`, `moveArtwork` or `patterns` 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();
@@ -992,6 +1029,7 @@ fn rearrange_refuses_a_boolean_given_as_text() {
("byColumn", json!({"layout": "row", "byColumn": 1})),
("moveArtwork", json!({"moveArtwork": "false"})),
("moveArtwork", json!({"columns": 1, "moveArtwork": 0})),
("patterns", json!({"patterns": "true"})),
] {
refuses_non_boolean(&mut s, "artboard.rearrange", &p, key);
assert_eq!((boards(&s), undo_len(&s), bounds(&s, b)), before, "{p}");
+46
View File
@@ -265,6 +265,52 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
}
/// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an
/// option given as text or as another value that is not true or false, such as "true", and
/// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The
/// artboards, the art, the undo history and the clipboard stay as they were; a null reads as the
/// option left out.
#[test]
fn artboard_commands_refuse_a_boolean_or_number_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
let r = rect(&mut s);
s.execute("artboard.new", &json!({})).unwrap();
let mut calls: Vec<_> = [
("artboard.setProps", "scaleArt", json!({"index": 0, "width": 400, "scaleArt": "true"})),
("artboard.setProps", "strokes", json!({"index": 0, "width": 400, "scaleArt": true, "strokes": "false"})),
("artboard.setProps", "corners", json!({"index": 0, "width": 400, "scaleArt": true, "corners": 1})),
("artboard.setProps", "patterns", json!({"index": 0, "x": 50, "moveArt": true, "patterns": "true"})),
("artboard.setProps", "moveArt", json!({"index": 0, "x": 50, "moveArt": "true"})),
("artboard.setProps", "lockedAndHidden", json!({"index": 0, "x": 50, "moveArt": true, "lockedAndHidden": [true]})),
("artboard.setProps", "locked", json!({"artboards": [{"index": 1, "locked": "true"}]})),
("artboard.move", "moveArt", json!({"index": 0, "dx": 10, "moveArt": "true"})),
("artboard.move", "copy", json!({"index": 0, "dx": 10, "copy": 1})),
("artboard.move", "patterns", json!({"index": 0, "dx": 10, "moveArt": true, "patterns": "false"})),
("artboard.duplicate", "patterns", json!({"index": 0, "patterns": "true"})),
("artboard.duplicate", "art", json!({"index": 0, "art": "false"})),
("artboard.copy", "art", json!({"index": 0, "art": "true"})),
("artboard.cut", "art", json!({"index": 1, "art": 0})),
]
.into_iter()
.map(|(cmd, key, p)| not_a_switch(cmd, p, key))
.collect();
for (cmd, key, p) in [
("artboard.setProps", "x", json!({"index": 0, "x": "50"})),
("artboard.setProps", "height", json!({"index": 1, "height": [300]})),
("artboard.move", "dx", json!({"index": 0, "dx": "10"})),
("artboard.move", "dy", json!({"index": 0, "dx": 10, "dy": "5"})),
] {
calls.push(not_a_number(cmd, p, key));
}
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
let p = json!({"index": 0, "dx": 10, "dy": null, "moveArt": true, "copy": null, "patterns": false});
s.execute("artboard.move", &p).unwrap();
assert_eq!(node(&s, r).geometric_bounds().map(|b| b.x0), Some(20.0));
}
/// Artboards 1 and 2, each with a square on it, and an artboard 3 below artboard 1 with a square on
/// it, with the Artboard tool selecting artboards 1 and 2 → the squares' ids, artboard by artboard.
fn two_selected_artboards(s: &mut Session) -> [u64; 3] {
+40
View File
@@ -139,6 +139,46 @@ fn effect_params_are_the_effects_own() {
assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok());
}
/// The Transform effect (`distort.transform`) is held to its params description: `effect.apply`
/// and `effect.setParams` refuse each of its on/off options given as text, such as `reflectX:
/// "false"`, and each of its numbers given as text, such as `copies: "0"`, and leave the effect
/// as it was; true, false and numbers pass. The loop takes the keys from the effect's defaults, so
/// an option the effect gains is checked too.
#[test]
fn transform_effect_refuses_a_boolean_or_number_given_as_text() {
let info = vectorcraft_render::effects::effect_info("distort.transform").unwrap();
let one = |key: &str, v: serde_json::Value| serde_json::Value::Object([(key.to_string(), v)].into_iter().collect());
let apply = |params| json!({"effect": "distort.transform", "params": params});
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
s.execute("effect.apply", &apply(json!({"moveH": 20}))).unwrap();
let state = |s: &Session| {
let st = s.doc().unwrap();
(st.history.undo.len(), st.doc.layers[0].children().unwrap()[0].appearance.effects[0].params.clone())
};
let before = state(&s);
let mut switches = vec![];
for (key, default) in info.defaults.as_object().unwrap() {
let why = match default {
serde_json::Value::Bool(_) => format!("`{key}` must be true or false"),
serde_json::Value::Number(_) => format!("`{key}` must be a number"),
_ => continue,
};
if default.is_boolean() {
switches.push(key.as_str());
}
let text = json!(default.to_string());
assert_eq!(refusal("effect.apply", apply(one(key, text.clone()))), why);
let e = s.execute("effect.setParams", &json!({"index": 0, "params": one(key, text)})).unwrap_err().to_string();
assert!(e.contains(&why), "{e}");
assert!(check_params("effect.apply", &apply(one(key, default.clone()))).is_ok(), "{key}");
}
assert!(["reflectX", "reflectY", "random"].iter().all(|k| switches.contains(k)), "{switches:?}");
assert_eq!(state(&s), before);
s.execute("effect.setParams", &json!({"index": 0, "params": {"reflectX": true, "random": false, "copies": 2}})).unwrap();
}
/// What a command notes for its journal entry is a key its description names, so the entry
/// passes the check a caller's params get.
#[test]
+33
View File
@@ -310,6 +310,39 @@ fn the_swatch_commands_refuse_a_keep_model_given_as_text() {
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))));
}
/// The swatch commands refuse an on/off option given as text or as another value that is not true
/// or false, and `swatch.move` a `to` that is not a number, naming the key; the swatches and the
/// undo history stay as they were. True, false, numbers and null still work.
#[test]
fn the_swatch_commands_refuse_a_boolean_or_number_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
run(&mut s, "paint.setFill", json!({"color": "#3366cc"}));
let name = run(&mut s, "swatch.new", json!({"color": "#000000"}))["name"].clone();
let mut calls: Vec<_> = [
("swatch.new", json!({"color": "#ff0000", "spot": "true"}), "spot"),
("swatch.new", json!({"color": "#ff0000", "global": 1}), "global"),
("swatch.delete", json!({"name": name, "unlink": "false"}), "unlink"),
("swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "includeTints": "true"}), "includeTints"),
("swatch.newGroup", json!({"name": "K", "fromArtwork": "true"}), "fromArtwork"),
("swatch.newGroup", json!({"name": "K", "fromArtwork": true, "toGlobal": "false"}), "toGlobal"),
("swatch.edit", json!({"name": name, "spot": "true"}), "spot"),
("swatch.edit", json!({"name": name, "global": [true]}), "global"),
("swatch.addUsedColors", json!({"global": "true"}), "global"),
("swatch.addUsedColors", json!({"selection": 1}), "selection"),
]
.into_iter()
.map(|(cmd, p, key)| not_a_switch(cmd, p, key))
.collect();
calls.push(not_a_number("swatch.move", json!({"name": name, "to": "0"}), "to"));
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
run(&mut s, "swatch.edit", json!({"name": name, "global": true, "spot": null}));
run(&mut s, "swatch.move", json!({"name": name, "to": 0}));
assert!(doc(&s).swatch(name.as_str().unwrap()).is_some_and(|w| w.global && !w.spot));
}
#[test]
fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() {
use vectorcraft_color::Color;
+73
View File
@@ -112,6 +112,79 @@ fn set_range_style_defaults_to_all_text_and_get_range() {
assert_eq!(r["length"], json!(3));
}
/// The type commands refuse a number or an on/off option given as text or as another value of the
/// wrong kind, naming the key, and leave the document and its undo history as they were; true,
/// false, numbers and null still work.
#[test]
fn type_commands_refuse_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
let id = text(&mut s, "abc");
let frame = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 100, "height": 50})).unwrap()["id"].clone();
let boxed = s.execute("text.create", &json!({"x": 0, "y": 300, "text": "a", "area": {"width": 100, "height": 50}})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let area = json!({"x": 0, "y": 0, "text": "a", "area": {"width": "200", "height": 100}});
let mut calls = vec![("text.create".to_string(), area, "`width` must be a number, not \"200\"".to_string())];
let area = json!({"x": 0, "y": 0, "text": "a", "area": {"width": 200, "height": 100, "fit": "shrinkText", "fitMinPercent": "40"}});
calls.push(("text.create".to_string(), area, "`fitMinPercent` must be a number, not \"40\"".to_string()));
calls.extend(
[
("text.create", json!({"x": 0, "y": 0, "text": "a", "size": "12"}), "size"),
("text.createInPath", json!({"path": frame, "text": "a", "size": [12]}), "size"),
("text.createInPath", json!({"path": frame, "mode": "area", "fitMinPercent": "40"}), "fitMinPercent"),
("text.setRangeStyle", json!({"id": id.0, "size": "30"}), "size"),
("text.setRangeStyle", json!({"id": id.0, "tracking": "50"}), "tracking"),
("text.setRangeStyle", json!({"id": id.0, "hScale": true}), "hScale"),
("text.setRangeStyle", json!({"id": id.0, "strokeWidth": "1"}), "strokeWidth"),
("text.setRangeStyle", json!({"id": id.0, "start": "1", "size": 30}), "start"),
("text.setRangeStyle", json!({"id": id.0, "end": [1], "size": 30}), "end"),
("text.editRange", json!({"id": id.0, "start": 0, "end": "1", "insert": "x"}), "end"),
("text.getRange", json!({"id": id.0, "start": "1"}), "start"),
("text.touchType", json!({"id": id.0, "start": 0, "rotation": "10"}), "rotation"),
("text.touchType", json!({"id": id.0, "start": "0", "rotation": 10}), "start"),
("type.step", json!({"attribute": "size", "by": "2"}), "by"),
("type.step", json!({"attribute": "size", "id": id.0, "start": "1"}), "start"),
("type.size.increase", json!({"by": "2"}), "by"),
("type.size.decrease", json!({"by": [2]}), "by"),
("type.ruby", json!({"text": "a", "size": "60"}), "size"),
("text.areaOptions", json!({"ids": [boxed], "width": "200"}), "width"),
("text.areaOptions", json!({"ids": [boxed], "height": [80]}), "height"),
("text.areaOptions", json!({"ids": [boxed], "fit": "shrinkText", "fitMinPercent": "40"}), "fitMinPercent"),
]
.map(|(cmd, p, key)| not_a_number(cmd, p, key)),
);
calls.extend(
[
("text.create", json!({"x": 0, "y": 0, "text": "a", "vertical": "true"}), "vertical"),
("text.create", json!({"x": 0, "y": 0, "placeholder": 1}), "placeholder"),
("text.createInPath", json!({"path": frame, "vertical": "true"}), "vertical"),
("text.createInPath", json!({"path": frame, "placeholder": "true"}), "placeholder"),
("text.setRangeStyle", json!({"id": id.0, "underline": "true"}), "underline"),
("text.setRangeStyle", json!({"id": id.0, "allCaps": 1}), "allCaps"),
("text.setRangeStyle", json!({"id": id.0, "noBreak": "true"}), "noBreak"),
("text.setRangeStyle", json!({"id": id.0, "size": 30, "tateChuYoko": 1}), "tateChuYoko"),
("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"),
("type.opticalMarginAlignment", json!({"on": "true"}), "on"),
("type.systemLayout", json!({"on": "true"}), "on"),
("type.fractionalWidths", json!({"on": "false"}), "on"),
("type.ruby", json!({"remove": "true"}), "remove"),
("type.ruby", json!({"text": "a", "overhang": "false"}), "overhang"),
]
.map(|(cmd, p, key)| not_a_switch(cmd, p, key)),
);
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
let style = json!({"id": id.0, "size": 30, "underline": true, "tracking": null, "start": null, "noBreak": true});
s.execute("text.setRangeStyle", &style).unwrap();
s.execute("type.size.increase", &json!({"by": null})).unwrap();
s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap();
s.execute("type.ruby", &json!({"text": "a", "size": null, "overhang": false})).unwrap();
let t = obj(&s, id);
let st = &t.runs[0].style;
assert!(st.underline && st.no_break && t.optical_margin && st.size > 30.0, "{st:?}");
assert!(!t.ruby.is_empty() && t.ruby.iter().all(|r| !r.overhang && r.size_percent == 50.0), "{:?}", t.ruby);
}
/// Where each glyph of type object `id` is drawn from (its origin in font units), document space.
fn glyph_pens(s: &Session, id: NodeId) -> Vec<(usize, Point)> {
let t = obj(s, id);