Merge main into feat/script-console

This commit is contained in:
dotCooCoo
2026-11-10 17:27:35 -08:00
71 changed files with 2143 additions and 223 deletions
@@ -263,6 +263,22 @@ fn record_again(s: &mut Session, again: Value) -> Result<()> {
Ok(())
}
/// `d` with each of `ids` that is not in perspective attached to `fallback` as [`persp_edit`] attaches
/// it; `d` itself when every one is in perspective already, there is no `fallback`, or one of them
/// cannot attach.
fn with_fallback<'a>(d: &'a Document, ids: &[NodeId], fallback: Option<Plane>) -> std::borrow::Cow<'a, Document> {
let mut g = grid_of(d);
let loose: Vec<NodeId> = ids.iter().copied().filter(|id| g.attachment_of(d, *id).is_none()).collect();
let Some(plane) = fallback.filter(|_| !loose.is_empty()) else { return std::borrow::Cow::Borrowed(d) };
let mut out = d.clone();
for id in loose {
if attach_in(&mut out, &mut g, id, plane, false, None).is_err() {
return std::borrow::Cow::Borrowed(d);
}
}
std::borrow::Cow::Owned(out)
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
@@ -285,9 +301,11 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
let constrain = switch_or(p, "constrain", false, C)?;
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
check_switches(p, &["snap"], C)?;
// Shift and Snap to Grid read the objects as they are once those not in perspective are attached.
let doc = if perp { std::borrow::Cow::Borrowed(&*s.doc()?.doc) } else { with_fallback(&s.doc()?.doc, &ids, fallback) };
// Shift: the constrained end point, read on the first object's plane and depth.
let snap_to = if constrain && !perp {
let doc = &s.doc()?.doc;
let doc = &*doc;
let g = grid_of(doc);
g.plane_bounds(doc, &ids)
.and_then(|(plane, depth, _)| {
@@ -305,10 +323,10 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
let fix = if perp {
None
} else {
let doc = &s.doc()?.doc;
crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to))
.and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction)))
crate::cmd::perspgrid::snap_fix(&doc, &ids, p, (from, snap_to))
.and_then(|(plane, depth, correction)| Some((plane, grid_of(&doc).plane_delta(plane, depth, from, to)?, correction)))
};
drop(doc);
let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| {
let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1);
let (direction, dv) =
+1 -1
View File
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Split Path",
[],
None,
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two; one of the two new end points is left selected → {ids}",
has_doc,
split
),
+10 -3
View File
@@ -200,9 +200,16 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(EngineError::Other("no such segment".into())),
};
let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k]));
// The cut leaves one of its two coincident end points selected, as `path.cutAtAnchors`
// does, so a Direct Selection drag pulls the path apart there.
let pick = |sel: &mut vectorcraft_doc::Selection, id: NodeId, si: usize| {
sel.clear();
sel.add(id);
sel.anchors.entry(id).or_default().insert((si, 0));
};
if sp.closed {
path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?;
sel.set([id]);
pick(sel, id, si);
return Ok(vec![id]);
}
let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?;
@@ -211,12 +218,12 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
let node = sibling_with(d, id, PathData::single(right))?;
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let nid = d.insert(parent, idx + 1, node)?;
sel.set([id, nid]);
pick(sel, nid, 0);
Ok(vec![id, nid])
} else {
path.subpaths[si] = left;
path.subpaths.insert(si + 1, right);
sel.set([id]);
pick(sel, id, si + 1);
Ok(vec![id])
}
})?;
@@ -1018,6 +1018,65 @@ mod tests {
}
}
/// Source file names that differ only in case (`a.svg` and `sub/A.svg`) get Export for Screens
/// folders of their own: names are compared without case, so the second folder is numbered,
/// since Windows and macOS would make `a` and `A` one folder.
#[test]
fn batch_override_export_numbers_folders_that_differ_only_in_case() {
let src = tmp("screens-case-src");
std::fs::create_dir_all(src.join("sub")).unwrap();
write_svg(&src, "a.svg", "#f00");
write_svg(&src.join("sub"), "A.svg", "#0f0");
let dest = tmp("screens-case-out");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"includeSubdirs": true,
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["A-2", "a"], "{r}");
for (folder, fill) in [("a", "#ff0000"), ("A-2", "#00ff00")] {
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
assert!(svg.contains(fill), "{folder}: {svg}");
}
}
/// A source file with nothing left of its name without its extension (` .svg`) gets the folder
/// `file`, numbered before a source file named `file.svg` takes it.
#[test]
fn batch_override_export_names_the_folder_of_an_empty_name_file() {
let src = tmp("screens-empty-src");
write_svg(&src, " .svg", "#f00");
write_svg(&src, "file.svg", "#0f0");
let dest = tmp("screens-empty-out");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["file", "file-2"], "{r}");
for (folder, fill) in [("file", "#ff0000"), ("file-2", "#00ff00")] {
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
assert!(svg.contains(fill), "{folder}: {svg}");
}
}
/// A source file whose name without its extension ends in a period (`a..svg`) gets the folder
/// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder
/// name, so `a.` and `a` would be one folder there.
+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<_>>() })
}
+66 -23
View File
@@ -15,13 +15,13 @@ use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::Rect;
use vectorcraft_geom::{Point, Rect};
use super::edit::{outermost, selected_roots};
use super::pathops::Beside;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_doc, has_selection, ids_param, matrix_param, ok, point_param,
str_param, targets,
CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, finite_numbers, has_doc, has_selection, ids_param,
matrix_param, ok, switch_or, targets, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Options…",
["Object", "Repeat"],
None,
"{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, flipRows?, flipCols? | angle?} set the options of the selected repeats",
"{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?} set the options of the selected repeats",
has_repeat,
repeat_options
),
@@ -287,9 +287,12 @@ fn after_edit_mode(s: &mut Session, layer: Option<NodeId>) -> Result<()> {
Ok(())
}
/// The tile type `p` gives `cmd` (`tileType`, `brickOffset`), else `current` with a given brick offset.
/// A `tileType` that is not a string, or a `brickOffset` that is not a number, is an error.
fn tile_type_param(p: &Value, cmd: &str, current: TileType) -> Result<TileType> {
check_numbers(p, &["brickOffset"], cmd)?;
let off = p.get("brickOffset").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o });
match str_param(p, "tileType") {
match text_param(p, "tileType", cmd)? {
Some(t) => TileType::parse(t, off).ok_or_else(|| bad(cmd, format!("unknown tileType `{t}` ({})", TileType::IDS.join("|")))),
None => Ok(match (current, off) {
(TileType::BrickByRow { .. }, Some(o)) => TileType::BrickByRow { offset: o },
@@ -311,12 +314,13 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "select the art for the pattern tile"));
}
let tt = tile_type_param(p, C, TileType::Grid)?;
check_numbers(p, &["width", "height"], C)?;
let (w, h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64));
if w.is_some_and(|v| !(v.is_finite() && v > 0.0)) || h.is_some_and(|v| !(v.is_finite() && v > 0.0)) {
return Err(bad(C, "width/height must be > 0"));
}
let edit = bool_or(p, "edit", true);
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let edit = switch_or(p, "edit", true, C)?;
let wanted = text_param(p, "name", C)?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Make Pattern", |d, sel| {
let name = match &wanted {
Some(n) => unique_pattern_name(d, n),
@@ -343,7 +347,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
fn pattern_edit(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.pattern.edit";
let name = match str_param(p, "name") {
let name = match text_param(p, "name", C)? {
Some(n) => n.to_string(),
None => {
// The selected object's fill (or stroke) pattern.
@@ -419,7 +423,7 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
}
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Save a Copy", |d, _| {
let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?;
@@ -438,19 +442,21 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pattern.options";
let name = match str_param(p, "name") {
let name = match text_param(p, "name", C)? {
Some(n) => n.to_string(),
None => editing(s).map(|e| e.pattern.clone()).ok_or_else(|| bad(C, "give `name` (or edit a pattern)"))?,
};
let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?;
let tt = tile_type_param(p, C, cur.tile_type)?;
check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?;
check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
for k in ["width", "height"] {
if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) {
return Err(bad(C, format!("{k} must be > 0")));
}
}
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
let new_name = text_param(p, "newName", C)?.map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
if let Some(n) = &new_name {
let d = &s.doc()?.doc;
if d.pattern(n).is_some() {
@@ -461,11 +467,15 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let overlap = match p.get("overlap") {
Some(o) => Overlap {
right_in_front: str_param(o, "h").map_or(cur.overlap.right_in_front, |v| v == "right"),
bottom_in_front: str_param(o, "v").map_or(cur.overlap.bottom_in_front, |v| v == "bottom"),
None | Some(Value::Null) => cur.overlap,
Some(o) if o.is_object() => Overlap {
right_in_front: overlap_side(o, "h", ["left", "right"], cur.overlap.right_in_front, C)?,
bottom_in_front: overlap_side(o, "v", ["top", "bottom"], cur.overlap.bottom_in_front, C)?,
},
None => cur.overlap,
Some(got) => {
let (path, expected) = ("overlap", "an object");
return Err(bad(C, format!("`{path}` must be {expected}, not {got}")));
}
};
let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer);
let out_name = new_name.clone().unwrap_or(name.clone());
@@ -514,6 +524,19 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": out_name }))
}
/// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the
/// first, `current` when `o` leaves it out or gives null. Any other value is an error.
fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result<bool> {
match o.get(side) {
None | Some(Value::Null) => Ok(current),
Some(Value::String(v)) if names.contains(&v.as_str()) => Ok(v == names[1]),
Some(_) => {
let key = format!("overlap.{side}");
Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", "))))
}
}
}
/// Is `new` taken for pattern `old` by a swatch or a color group (they share one namespace,
/// [`Document::swatch_name_taken`])? It is free when the swatches named `new` are one or more
/// swatches of the pattern and none of the pattern's swatches is named `old` (those take the new
@@ -571,7 +594,7 @@ fn nodes_using(d: &Document, name: Option<&str>) -> Vec<NodeId> {
fn pattern_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pattern.delete";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?.to_string();
let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing name"))?.to_string();
if s.doc()?.doc.pattern(&name).is_none() {
return Err(bad(C, format!("no pattern `{name}`")));
}
@@ -694,18 +717,25 @@ fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn
}
fn repeat_radial(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.repeat.radial";
check_numbers(p, &["instances", "radius"], C)?;
let n = f64_or(p, "instances", 8.0);
if !(1.0..=1000.0).contains(&n) {
return Err(bad("object.repeat.radial", "instances must be 1..1000"));
return Err(bad(C, "instances must be 1..1000"));
}
let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0);
make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
let radius = p.get("radius").and_then(Value::as_f64);
if radius.is_some_and(|r| r < 0.0) {
let key = "radius";
return Err(bad(C, format!("`{key}` must be a number not below 0")));
}
make_repeat(s, p, C, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
}
fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["hSpacing", "vSpacing", "rows", "cols"], "object.repeat.grid")?;
let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64));
let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
let rows = p.get("rows").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500));
let cols = p.get("cols").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500));
make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| {
let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0));
@@ -718,6 +748,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
}
fn repeat_mirror(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle", "offset"], "object.repeat.mirror")?;
let angle = f64_or(p, "angle", 90.0);
let offset = f64_or(p, "offset", 10.0);
make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset))
@@ -803,16 +834,26 @@ pub(crate) fn expand_repeats(d: &mut Document, roots: &[NodeId]) -> Result<usize
fn repeat_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.repeat.options";
let reps = selected_repeats(s);
let numbers = ["instances", "radius", "startAngle", "endAngle", "hSpacing", "vSpacing", "rows", "cols", "brickOffset", "angle"];
check_numbers(p, &numbers, C)?;
check_switches(p, &["reverseOverlap", "flipRows", "flipCols"], C)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
if num("instances").is_some_and(|n| !(1.0..=1000.0).contains(&n)) {
return Err(bad(C, "instances must be 1..1000"));
}
let grid_type = match str_param(p, "gridType") {
let grid_type = match text_param(p, "gridType", C)? {
Some(t) => Some(TileType::parse(t, num("brickOffset")).ok_or_else(|| bad(C, format!("unknown gridType `{t}`")))?),
None => None,
};
let center = point_param(p, "center");
let center = match p.get("center") {
None | Some(Value::Null) => None,
Some(v) => {
let key = "center";
let [x, y] = finite_numbers::<2>(v).ok_or_else(|| bad(C, format!("{key} must be [x, y] with finite coordinates")))?;
Some(Point::new(x, y))
}
};
s.edit("Repeat Options", |d, _| {
for r in &reps {
let Some(NodeKind::Repeat(spec)) = d.node_mut(*r).map(|n| &mut n.kind) else { continue };
@@ -852,6 +893,8 @@ fn repeat_options(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(t) = grid_type {
*gt = t;
} else if let (Some(v), TileType::BrickByRow { offset } | TileType::BrickByColumn { offset }) = (num("brickOffset"), &mut *gt) {
*offset = v;
}
if let Some(v) = flag("flipRows") {
*flip_rows = v;
+4 -3
View File
@@ -5,7 +5,7 @@
use serde_json::{Map, Value, json};
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param};
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param, switch_or};
use crate::Prefs;
use crate::units::Measure;
use vectorcraft_doc::{GridPrefs, Unit};
@@ -763,7 +763,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Preference",
[],
None,
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)",
"{key, value} or {values: {key: value, …}, documentUnits?: bool=true} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step, unless documentUnits is false; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)",
always,
set
),
@@ -789,6 +789,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
if values.is_empty() {
return Err(bad("prefs.set", "give {key, value} or {values}"));
}
let document_units = switch_or(p, "documentUnits", true, "prefs.set")?;
let mut next = s.prefs.clone();
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() {
@@ -797,7 +798,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
s.prefs.check_folders(&values).map_err(EngineError::Other)?;
s.apply_prefs(next);
// Units ▸ General is also the open document's units (as Document Setup sets them).
if values.contains_key("unitsGeneral") && s.active().is_some() {
if document_units && values.contains_key("unitsGeneral") && s.active().is_some() {
s.set_document_units(s.default_units())?;
}
let all = s.prefs.to_json();
+5 -4
View File
@@ -12,7 +12,7 @@ use super::stroke::StrokeChange;
use super::typecmd::refresh_bounds;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param,
index, number_or, point_param, str_param, switch_or, targets,
index, number_or, point_param, str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -568,7 +568,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
let not_tags = || bad(cmd, "features must be a list of tags");
let tags: Vec<&str> = v.as_array().ok_or_else(not_tags)?.iter().map(|t| t.as_str().ok_or_else(not_tags)).collect::<Result<_>>()?;
if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) {
return Err(bad(
cmd,
@@ -708,8 +709,8 @@ impl CharChange {
stroke_opts.weight = Some(w.clamp(0.0, 1000.0));
}
let c = Self {
font: str_param(p, "font").map(str::to_string),
style: str_param(p, "style").map(str::to_string),
font: text_param(p, "font", cmd)?.map(str::to_string),
style: text_param(p, "style", cmd)?.map(str::to_string),
size: num("size"),
leading: auto_or_num(p, "leading", cmd)?,
tracking: num("tracking"),
+11 -11
View File
@@ -8,10 +8,10 @@ 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,
str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -249,16 +249,16 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
let ids = text_targets(s, p, C)?;
let range = TextRange::parse(p, C)?;
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);
let reading = text_param(p, "text", C)?.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`"));
}
let position = match str_param(p, "position").map(str::to_ascii_lowercase).as_deref() {
let position = match text_param(p, "position", C)?.map(str::to_ascii_lowercase).as_deref() {
None | Some("above") | Some("right") => RubyPosition::Above,
Some("below") | Some("left") => RubyPosition::Below,
Some(_) => return Err(bad(C, "`position` is above or below")),
};
let align = match str_param(p, "align").map(str::to_ascii_lowercase).as_deref() {
let align = match text_param(p, "align", C)?.map(str::to_ascii_lowercase).as_deref() {
None | Some("center") | Some("centre") => RubyAlign::Center,
Some("start") | Some("left") | Some("top") => RubyAlign::Start,
Some("end") | Some("right") | Some("bottom") => RubyAlign::End,
@@ -812,9 +812,9 @@ pub(crate) fn justify_param(v: &str) -> Option<Justify> {
fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setStyle";
let font = str_param(p, "font").map(str::to_string);
let style = str_param(p, "style").map(str::to_string);
let size = len_param(p, "size");
let font = text_param(p, "font", C)?.map(str::to_string);
let style = text_param(p, "style", C)?.map(str::to_string);
let size = length_given(p, "size", C)?;
if size.is_some_and(|v| v <= 0.0) {
return Err(bad(C, "size must be positive"));
}
@@ -823,8 +823,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
Some(_) => Some(Some(len_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))),
};
let tracking = num_param(p, "tracking");
let justify = match str_param(p, "justify") {
let tracking = number_given(p, "tracking", C)?;
let justify = match text_param(p, "justify", C)? {
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?),
None => None,
};
@@ -1282,7 +1282,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
let out = str_param(p, "which") != Some("in");
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let independent = switch_or(p, "independent", false, C)?;
let node = s.doc()?.doc.node(id);