Type: use pathops' number and length helpers; refuse a number with trailing text

text.setStyle now reads `size` and `tracking` with `number_given` and
`length_given` from pathops.rs instead of its own copies.

Both helpers read a string only when all of it is a number, or for
`length_given` a length with a unit (`"5 mm"`). Before, `"20abc"` read
as 20, so `text.setStyle` applied a 20 pt size and Offset Path a 10 pt
offset for `"10abc"`. Such a string is now refused with "`size` must be
a number, not \"20abc\"" and the document is left as it was.
This commit is contained in:
dotCooCoo
2026-11-10 16:00:07 -08:00
parent 272dab7b0a
commit ea612dbf19
4 changed files with 28 additions and 27 deletions
+23 -7
View File
@@ -221,25 +221,41 @@ pub(crate) fn len_param(p: &Value, key: &str) -> Option<f64> {
}
}
/// The number `p` gives `cmd` for `key`, as [`num_param`] reads it, or `None` when `p` leaves it out
/// or gives null. Any other value, such as `true` or `"wide"`, is an error: "`{key}` must be a
/// number, not {v}".
/// The number `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string
/// counts only when all of it is a number (`"20"`). Any other value, such as `true`, `"wide"` or
/// `"20abc"`, is an error: "`{key}` must be a number, not {v}".
pub(crate) fn number_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => num_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
Some(v) => match v {
Value::String(s) => whole_number(s),
_ => num_param(p, key),
}
.map(Some)
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
/// The distance `p` gives `cmd` for `key`, as [`len_param`] reads it (`"5 mm"` included), or `None`
/// when `p` leaves it out or gives null. Any other value is an error, as [`number_given`] gives it.
/// The distance `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string
/// counts only when all of it is a length that [`vectorcraft_doc::Unit::parse`] reads (`"5 mm"`) or a
/// number. Any other value, such as `"20abc"`, is an error, as [`number_given`] gives it.
pub(crate) fn length_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => len_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
Some(v) => match v {
Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| whole_number(s)),
_ => num_param(p, key),
}
.map(Some)
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
/// `s` as a finite number when all of it, spaces around it aside, is one.
fn whole_number(s: &str) -> Option<f64> {
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
+1 -20
View File
@@ -8,7 +8,7 @@ use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAl
use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
use super::pathops::{len_param, num_param, shape_node};
use super::pathops::{len_param, length_given, number_given, shape_node};
use super::{
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok,
str_param, switch_or, targets, text_param,
@@ -810,25 +810,6 @@ pub(crate) fn justify_param(v: &str) -> Option<Justify> {
})
}
/// The number `p` gives `cmd` for `key`, as [`num_param`] reads it (`"20"` included), or `None` when
/// `p` leaves it out or gives null. Any other value, such as `true` or `"wide"`, is an error:
/// "`{key}` must be a number, not {v}".
fn number_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => num_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
/// The distance `p` gives `cmd` for `key`, as [`len_param`] reads it (`"5 mm"` included), or `None`
/// when `p` leaves it out or gives null. Any other value is an error, as [`number_given`] gives it.
fn length_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => len_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setStyle";
let font = text_param(p, "font", C)?.map(str::to_string);
+2
View File
@@ -863,6 +863,8 @@ fn path_commands_refuse_a_param_of_the_wrong_type() {
for (cmd, p, want) in [
("object.path.offsetPath", json!({"offset": true}), number("offset", "true")),
("object.path.offsetPath", json!({"offset": "wide"}), number("offset", "\"wide\"")),
("object.path.offsetPath", json!({"offset": "10abc"}), number("offset", "\"10abc\"")),
("object.path.offsetPath", json!({"offset": 5, "miterLimit": "4x"}), number("miterLimit", "\"4x\"")),
("object.path.offsetPath", json!({"offset": 5, "joins": 3}), "`joins` must be a string".to_string()),
("object.path.offsetPath", json!({"offset": 5, "joins": "pointy"}), "`joins` must be one of miter, round, bevel".to_string()),
("object.path.offsetPath", json!({"offset": 5, "miterLimit": [4]}), number("miterLimit", "[4]")),
+2
View File
@@ -505,6 +505,8 @@ fn character_ruby_and_path_handle_refuse_a_param_of_the_wrong_type() {
[
("text.setStyle", json!({"size": true}), "size"),
("text.setStyle", json!({"size": "big"}), "size"),
("text.setStyle", json!({"size": "20abc"}), "size"),
("text.setStyle", json!({"tracking": "20abc"}), "tracking"),
("text.setStyle", json!({"font": "Inter", "size": [12]}), "size"),
("text.setStyle", json!({"tracking": "wide"}), "tracking"),
("text.setStyle", json!({"size": 30, "tracking": false}), "tracking"),