Merge pull request #2851 from dotCooCoo/fix/live-commands-refuse-text-numbers

Blends, meshes and envelopes: a number given as text is refused
This commit is contained in:
BFlat
2026-10-18 13:25:16 +07:00
committed by GitHub
4 changed files with 208 additions and 1 deletions
+14 -1
View File
@@ -410,6 +410,7 @@ const DEFAULT_STEPS: u32 = 5;
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 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");
@@ -971,7 +972,11 @@ fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result<Value> {
// ---------- Envelope ----------
/// The number keys of a warp: `bend`, and the two distortions by their short and long names.
const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verticalDistortion"];
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
check_numbers(p, WARP_NUMBERS, cmd)?;
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
@@ -1005,6 +1010,7 @@ fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, f
/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`.
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
check_numbers(p, &["rows", "cols", "columns"], cmd)?;
let rows = f64_or(p, "rows", default.0 as f64);
let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" };
let cols = f64_or(p, cols_key, default.1 as f64);
@@ -1241,6 +1247,8 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
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)?;
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"));
@@ -1386,8 +1394,12 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?,
None => MeshAppearance::Flat,
};
check_numbers(p, &["highlight"], C)?;
let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0);
let at = point_param(p, "at");
let at = match p.get("at") {
None | Some(Value::Null) => None,
Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?),
};
let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) };
let roots = roots_param(s, p)?;
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
@@ -1522,6 +1534,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)?;
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);
+184
View File
@@ -73,6 +73,13 @@ fn all_anchors(nodes: &[Node]) -> Vec<Point> {
v
}
/// `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: &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:?}");
}
// ---------- Blend ----------
#[test]
@@ -116,6 +123,54 @@ fn blend_steps_that_are_not_a_whole_number_are_refused() {
assert_eq!(steps_of(&s, g).len(), 6);
}
/// Make refuses Specified Steps or Distance given as text or as another value that is not a number,
/// and makes no blend; `steps: 8` as a number makes 8 steps.
#[test]
fn blend_make_refuses_a_number_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);
for (key, p) in [
("steps", json!({"steps": "8"})),
("distance", json!({"distance": "10"})),
("value", json!({"spacing": "steps", "value": "3"})),
("value", json!({"spacing": "distance", "value": [5]})),
("distance", json!({"spacing": "distance", "distance": true})),
] {
refuses_non_number(&mut s, "object.blend.make", &p, key);
}
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": 8})).unwrap());
assert_eq!(steps_of(&s, g).len(), 10);
}
/// Blend Options refuses Specified Steps or Distance given as text, for the selected blends and for
/// the options new blends start with, and changes neither.
#[test]
fn blend_options_refuse_a_number_given_as_text() {
let mut s = session();
let (_, _, g) = blend(&mut s, json!({"steps": 3}));
let n = undo_len(&s);
let calls = [
("steps", json!({"steps": "4"})),
("value", json!({"spacing": "steps", "value": "4"})),
("distance", json!({"distance": "20"})),
("distance", json!({"spacing": "distance", "value": null, "distance": "20"})),
];
for (key, p) in &calls {
refuses_non_number(&mut s, "object.blend.options", p, key);
}
assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "the blend kept its options");
s.execute("select.none", &json!({})).unwrap();
let defaults = s.prefs.blend_options;
for (key, p) in &calls {
refuses_non_number(&mut s, "object.blend.options", p, key);
}
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();
@@ -479,6 +534,95 @@ fn envelope_options_and_edit_contents() {
assert_eq!(s.doc().unwrap().selection.objects, vec![e]);
}
/// 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] {
[
("bend", json!({"bend": "40"})),
("h", json!({"h": "10"})),
("v", json!({"v": "-10"})),
("horizontalDistortion", json!({"horizontalDistortion": "10"})),
("verticalDistortion", json!({"verticalDistortion": "10%"})),
]
}
/// Make with Warp refuses a bend or distortion given as text, and makes no envelope.
#[test]
fn envelope_make_with_warp_refuses_a_number_given_as_text() {
let mut s = session();
let a = text_rect(&mut s);
sel(&mut s, &[a]);
let n = undo_len(&s);
for (key, p) in warp_numbers_as_text() {
refuses_non_number(&mut s, "object.envelope.makeWithWarp", &p, key);
}
assert_eq!(undo_len(&s), n, "nothing changed");
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped");
}
/// Reset with Warp refuses a bend or distortion given as text, and the envelope keeps its warp.
#[test]
fn envelope_reset_with_warp_refuses_a_number_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, n) = (node(&s, e), undo_len(&s));
for (key, p) in warp_numbers_as_text() {
refuses_non_number(&mut s, "object.envelope.resetWithWarp", &p, key);
}
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
}
/// Make with Mesh refuses rows or columns given as text, and makes no envelope.
#[test]
fn envelope_make_with_mesh_refuses_a_number_given_as_text() {
let mut s = session();
let a = text_rect(&mut s);
sel(&mut s, &[a]);
let n = undo_len(&s);
for (key, p) in [("rows", json!({"rows": "3"})), ("cols", json!({"cols": "3"})), ("columns", json!({"rows": 2, "columns": "3"}))] {
refuses_non_number(&mut s, "object.envelope.makeWithMesh", &p, key);
}
assert_eq!(undo_len(&s), n, "nothing changed");
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped");
}
/// Reset with Mesh refuses rows or columns given as text, and the envelope keeps its mesh.
#[test]
fn envelope_reset_with_mesh_refuses_a_number_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.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap());
let (before, n) = (node(&s, e), undo_len(&s));
for (key, p) in [("rows", json!({"rows": "3"})), ("cols", json!({"cols": "3"})), ("columns", json!({"columns": "3", "maintainShape": false}))] {
refuses_non_number(&mut s, "object.envelope.resetWithMesh", &p, key);
}
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
}
/// 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]
fn envelope_options_refuse_a_number_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, n) = (node(&s, e), undo_len(&s));
let mut calls = vec![("fidelity", json!({"fidelity": "80", "antiAlias": false}))];
calls.extend(warp_numbers_as_text());
for (key, p) in &calls {
refuses_non_number(&mut s, "object.envelope.options", p, key);
}
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
s.execute("select.none", &json!({})).unwrap();
for (key, p) in &calls {
refuses_non_number(&mut s, "object.envelope.options", p, key);
}
assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed");
}
#[test]
fn envelope_renders_distorted() {
let mut s = session();
@@ -634,6 +778,46 @@ fn mesh_commands_refuse_an_index_or_count_that_is_not_a_whole_number() {
assert_eq!(mesh_of(&s, a).points[4].p, Point::new(40.0, 60.0));
}
/// Create Gradient Mesh refuses rows, columns or a highlight given as text, and an `at` that is not
/// two numbers, and makes no mesh; `at: [50, 40]` with a null highlight makes one.
#[test]
fn mesh_create_refuses_a_number_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 n = undo_len(&s);
for (key, p) in [
("rows", json!({"rows": "3"})),
("cols", json!({"cols": "2"})),
("columns", json!({"columns": "2"})),
("highlight", json!({"appearance": "center", "highlight": "50"})),
] {
refuses_non_number(&mut s, "object.mesh.create", &p, key);
}
for at in [json!(["50", "40"]), json!([50]), json!(50), json!({"x": 50, "y": 40})] {
let r = s.execute("object.mesh.create", &json!({ "at": at }));
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`at` must be [x, y]"), "{at}: {r:?}");
}
assert_eq!(undo_len(&s), n, "nothing changed");
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "still a path");
s.execute("object.mesh.create", &json!({"at": [50, 40], "highlight": null})).unwrap();
assert_eq!((mesh_of(&s, a).rows, mesh_of(&s, a).cols), (2, 2));
}
/// Set Mesh Point Color refuses an opacity given as text, and the point keeps its color and opacity.
#[test]
fn mesh_set_point_color_refuses_an_opacity_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
let (before, n) = (mesh_of(&s, a), undo_len(&s));
for p in [json!({"id": a.0, "index": 3, "color": "#00ff00", "opacity": "0.5"}), json!({"id": a.0, "index": 3, "opacity": "50%"})] {
refuses_non_number(&mut s, "object.mesh.setPointColor", &p, "opacity");
}
assert_eq!((mesh_of(&s, a), undo_len(&s)), (before, n), "nothing changed");
}
#[test]
fn mesh_create_at_point_single_undo() {
let mut s = session();
+8
View File
@@ -880,6 +880,14 @@ journal recorded by an older version replays as it did, except for an effect `co
`` `columns` must be a number, not "4" ``. Each message names the key that was given, and the command then changes
nothing.
The blend, envelope and gradient mesh commands refuse a string or another value that is not a number with the same
message, as in `` `steps` must be a number, not "8" ``, and change nothing: `steps`, `distance` and `value` in
`object.blend.make` and `object.blend.options`; `bend`, `h`, `v`, `horizontalDistortion` and `verticalDistortion` in
`object.envelope.makeWithWarp`, `resetWithWarp` and `options`; `rows`, `cols` and `columns` in
`object.envelope.makeWithMesh`, `resetWithMesh` and `object.mesh.create`; `fidelity` in `object.envelope.options`;
`highlight` in `object.mesh.create`; and `opacity` in `object.mesh.setPointColor`. A null is read as a key left out.
`object.mesh.create` refuses an `at` that is not two numbers with `` `at` must be [x, y] ``.
A step can use an earlier step's result, in `run` as in the `command_batch` tool. A parameter string that is exactly
`$N` or `$N.path` takes that value from the result of step `N` (counting from 0, in the order they ran; `run --in`'s
open isn't a step), and `$last…` from the step before. A path goes into objects by key and arrays by index:
+2
View File
@@ -803,6 +803,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too.
- **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`.
- **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.
- **Full font names (#1258):** OpenType name ID 4 identifies its face alongside family/legacy/PostScript names, including localized names, for loaded fonts, cataloged files and Missing Fonts searches. Synthetic name-table regressions cover full names distinct from their PostScript name, real-family precedence and variable fonts' default faces. Windows font-service activation remains unverified; scores and hours are unchanged.