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);
+78 -1
View File
@@ -1058,11 +1058,88 @@ fn scissors_splits_open_path_in_two() {
assert!(near(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 100.0)));
assert!(near(ps[1].1.subpaths[0].anchors[0].p, Point::new(150.0, 100.0)));
assert_eq!(ps[1].1.subpaths[0].anchors[1].p, Point::new(300.0, 100.0));
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
// One of the two new end points is selected: a Direct Selection drag from the cut pulls the
// second piece's end away and leaves the first piece as it was.
assert_eq!(s.doc().unwrap().selection.objects, [ps[1].0]);
assert_eq!(selected_anchors(&s, ps[1].0), [(0, 0)]);
drag(&mut s, "directSelection", &[(150.0, 100.0), (150.0, 115.0), (150.0, 130.0)], Mods::default());
assert_eq!(path(&s, id), ps[0].1);
let moved = anchor_points(&s, ps[1].0);
assert!(near(moved[0], Point::new(150.0, 130.0)) && moved[1] == Point::new(300.0, 100.0), "{moved:?}");
// Clicking an end point is refused without panicking.
assert!(s.execute("path.split", &json!({"id": id.0, "subpath": 0, "anchor": 0})).is_err());
}
/// An ellipse cut once with the Scissors tool, at an anchor or along a segment, opens with its two
/// ends on top of each other and one of them selected. A short Direct Selection drag from there,
/// plain or with Shift, moves that end alone with its handles, in one undo step, so the open path
/// becomes one turn that spirals out; every other anchor stays where it was.
#[test]
fn scissors_cut_ellipse_end_drags_alone() {
let mods = [Mods::default(), Mods { shift: true, ..Mods::default() }];
for at_anchor in [true, false] {
for m in mods {
let mut s = session();
drag(&mut s, "ellipse", &[(100.0, 100.0), (200.0, 200.0), (300.0, 200.0)], Mods::default());
let id = paths(&s)[0].0;
let ellipse = path(&s, id);
let cut = if at_anchor {
ellipse.subpaths[0].anchors[0].p
} else {
use kurbo::ParamCurve;
ellipse.subpaths[0].segment(1).eval(0.5)
};
click(&mut s, "scissors", cut.x, cut.y);
let opened = path(&s, id);
let sp = &opened.subpaths[0];
assert!(!sp.closed && near(sp.anchors[0].p, cut) && near(sp.anchors[sp.anchors.len() - 1].p, cut), "{sp:?}");
assert_eq!(s.doc().unwrap().selection.objects, [id]);
assert_eq!(selected_anchors(&s, id), [(0, 0)]);
// Shift is pressed once the drag is under way: held at the press it toggles the anchor.
let v = view();
s.select_tool("directSelection", v).unwrap();
let undo = undo_steps(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, cut.x, cut.y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, cut.x + 4.0, cut.y + 4.0).with_mods(m), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap();
assert_eq!(undo_steps(&s), undo + 1);
let after = path(&s, id);
let (old, new) = (&sp.anchors, &after.subpaths[0].anchors);
assert_eq!(new.len(), old.len());
assert_eq!(new[1..], old[1..], "only the dragged end moves (at anchor: {at_anchor}, {m:?})");
let d = Point::new(cut.x + 8.0, cut.y + 8.0) - cut;
assert!(near(new[0].p, old[0].p + d) && near(new[0].h_out, old[0].h_out + d) && near(new[0].h_in, new[0].p), "{:?}", new[0]);
}
}
}
/// The Scissors tool on a path of several subpaths: the cut end stays selected in the subpath it
/// opened, and a second cut of that subpath selects the end of the new piece.
#[test]
fn scissors_cut_in_a_compound_path_selects_the_cut_end() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let square =
|x: f64| json!({"closed": true, "anchors": [{"x": x, "y": 100}, {"x": x + 100.0, "y": 100}, {"x": x + 100.0, "y": 200}, {"x": x, "y": 200}]});
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [square(100.0), square(300.0)]})).unwrap();
click(&mut s, "scissors", 400.0, 100.0);
let pd = path(&s, id);
assert!(pd.subpaths[0].closed && !pd.subpaths[1].closed && pd.subpaths[1].anchors[0].p == Point::new(400.0, 100.0), "{pd:?}");
assert_eq!(selected_anchors(&s, id), [(1, 0)]);
// Cut again at the opened subpath's corner (400, 200): its second piece follows it.
click(&mut s, "scissors", 400.0, 200.0);
let pd = path(&s, id);
assert_eq!(pd.subpaths.len(), 3);
assert_eq!(pd.subpaths[2].anchors[0].p, Point::new(400.0, 200.0));
assert_eq!(selected_anchors(&s, id), [(2, 0)]);
drag(&mut s, "directSelection", &[(400.0, 200.0), (410.0, 210.0), (420.0, 220.0)], Mods::default());
let after = path(&s, id);
assert_eq!(after.subpaths[2].anchors[0].p, Point::new(420.0, 220.0));
assert_eq!(after.subpaths[1], pd.subpaths[1]);
assert_eq!(after.subpaths[0], pd.subpaths[0]);
}
#[test]
fn knife_cuts_rect_into_two_pieces() {
let mut s = session();
+35
View File
@@ -349,6 +349,41 @@ fn perceptual_spot_gradients_with_rgb_stops_in_a_cmyk_document_export_as_device_
assert!(r.warnings.iter().any(|w| w.contains("spot")), "RGB destination: {:?}", r.warnings);
}
#[test]
fn classic_spot_gradients_with_rgb_stops_in_a_cmyk_document_export_as_device_n_shadings() {
let (mut s, _) = setup();
run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]}));
let (pdf, warnings) = pdf_of(&s);
assert!(!pdf.contains("/DeviceN") && warnings.iter().any(|w| w.contains("spot")), "an RGB document drops the ink: {warnings:?}");
// A CMYK document, or a conversion to a CMYK destination, writes the RGB stop in CMYK: a
// DeviceN shading that keeps the ink.
let mut cmyk = doc(&s).clone();
cmyk.color_mode = vectorcraft_doc::ColorMode::Cmyk;
let plain = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() };
let mut converted = plain.clone();
converted.settings.output = vectorcraft_pdf::OutputSettings {
conversion: vectorcraft_pdf::ColorConversion::Destination,
destination: vectorcraft_pdf::DOCUMENT_CMYK.into(),
..Default::default()
};
for (what, d, opts) in [("CMYK document", &cmyk, &plain), ("CMYK destination", doc(&s), &converted)] {
let r = vectorcraft_pdf::export_with_report(d, opts).unwrap();
let pdf = String::from_utf8_lossy(&r.bytes);
assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Ink]/DeviceCMYK"), "{what}: a DeviceN shading with Ink");
assert!(!r.warnings.iter().any(|w| w.contains("spot")), "{what}: {:?}", r.warnings);
}
// A conversion to an RGB destination keeps the RGB stop in RGB, in a CMYK document too.
let mut to_rgb = plain.clone();
to_rgb.settings.output = vectorcraft_pdf::OutputSettings {
conversion: vectorcraft_pdf::ColorConversion::Destination,
destination: vectorcraft_color::cms::SRGB.into(),
..Default::default()
};
let r = vectorcraft_pdf::export_with_report(&cmyk, &to_rgb).unwrap();
assert!(!String::from_utf8_lossy(&r.bytes).contains("/DeviceN"), "RGB destination: no DeviceN shading");
assert!(r.warnings.iter().any(|w| w.contains("spot")), "RGB destination: {:?}", r.warnings);
}
#[test]
fn spot_gradients_drawn_as_images_warn() {
let (mut s, _) = setup();
+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]")),
+181
View File
@@ -1112,3 +1112,184 @@ fn mirror_axis_hidden_controls_and_other_repeat_kinds_do_not_capture_drags() {
assert_eq!(node(&s, id), before);
}
}
/// The document, its revision, the undo and redo stacks and the selection: what a refused call
/// must leave as it was.
fn untouched(s: &Session) -> (std::sync::Arc<vectorcraft_doc::Document>, u64, (usize, usize, bool), Vec<NodeId>) {
let st = s.doc().unwrap();
(st.doc.clone(), st.revision, crate::tests::history(s), st.selection.objects.to_vec())
}
/// Each call in `calls` is refused with the parameter error `want` and changes nothing.
fn refused(s: &mut Session, calls: Vec<(&str, Value, String)>) {
let before = untouched(s);
for (cmd, p, want) in calls {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}, want {want:?}");
assert!(untouched(s) == before, "{cmd} {p}: nothing changes");
}
}
fn number(k: &str, v: Value) -> String {
format!("`{k}` must be a number, not {v}")
}
fn switch(k: &str) -> String {
format!("`{k}` must be true or false")
}
fn string(k: &str) -> String {
format!("`{k}` must be a string")
}
/// Radial, Grid, Mirror and Repeat Options refuse a param of the wrong type with an error naming
/// it, and change nothing; a distance given as text with a unit is refused like any other string.
/// Null counts as a param left out, which keeps its default.
#[test]
fn repeat_commands_refuse_a_param_of_the_wrong_type() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 60.0, 80.0);
sel(&mut s, &[r]);
refused(
&mut s,
vec![
("object.repeat.radial", json!({"instances": "8"}), number("instances", json!("8"))),
("object.repeat.radial", json!({"radius": true}), number("radius", json!(true))),
("object.repeat.radial", json!({"radius": "5 mm"}), number("radius", json!("5 mm"))),
("object.repeat.radial", json!({"radius": -5}), "`radius` must be a number not below 0".into()),
("object.repeat.grid", json!({"hSpacing": "5 mm"}), number("hSpacing", json!("5 mm"))),
("object.repeat.grid", json!({"vSpacing": [1]}), number("vSpacing", json!([1]))),
("object.repeat.grid", json!({"rows": "3"}), number("rows", json!("3"))),
("object.repeat.grid", json!({"cols": false}), number("cols", json!(false))),
("object.repeat.mirror", json!({"angle": "45"}), number("angle", json!("45"))),
("object.repeat.mirror", json!({"offset": {}}), number("offset", json!({}))),
],
);
// Null params keep the defaults: each repeat matches the one made with `{}`.
for (cmd, nulls) in [
("object.repeat.radial", json!({"instances": null, "radius": null})),
("object.repeat.grid", json!({"hSpacing": null, "vSpacing": null, "rows": null, "cols": null})),
("object.repeat.mirror", json!({"angle": null, "offset": null})),
] {
let made = |p: &Value| {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 60.0, 80.0);
sel(&mut s, &[r]);
let id = id_of(&s.execute(cmd, p).unwrap());
node(&s, id)
};
assert_eq!(made(&nulls), made(&json!({})), "{cmd}");
}
// A grid's rows and cols take any number, as Repeat Options does: 2.5 rows are 2.
let id = id_of(&s.execute("object.repeat.grid", &json!({"rows": 2.5, "cols": 4})).unwrap());
assert!(matches!(node(&s, id).kind, NodeKind::Repeat(ref r) if matches!(r.kind, RepeatKind::Grid { rows: 2, cols: 4, .. })));
// `brickOffset` alone sets the offset of a brick grid, and leaves a plain grid as it is.
let grid_type = |s: &Session| match node(s, id).kind {
NodeKind::Repeat(r) => match r.kind {
RepeatKind::Grid { grid_type, .. } => grid_type,
other => panic!("not a grid: {other:?}"),
},
other => panic!("not a repeat: {other:?}"),
};
s.execute("object.repeat.options", &json!({"brickOffset": 0.25})).unwrap();
assert_eq!(grid_type(&s), TileType::Grid);
s.execute("object.repeat.options", &json!({"gridType": "brickByRow"})).unwrap();
s.execute("object.repeat.options", &json!({"brickOffset": 0.25})).unwrap();
assert_eq!(grid_type(&s), TileType::BrickByRow { offset: 0.25 });
// Repeat Options checks every param, whichever kind the selected repeat is.
s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap();
let kind = |s: &Session| match node(s, id).kind {
NodeKind::Repeat(r) => r.kind,
other => panic!("not a repeat: {other:?}"),
};
let radial = kind(&s);
refused(
&mut s,
vec![
("object.repeat.options", json!({"instances": "4"}), number("instances", json!("4"))),
("object.repeat.options", json!({"radius": "50 pt"}), number("radius", json!("50 pt"))),
("object.repeat.options", json!({"startAngle": true}), number("startAngle", json!(true))),
("object.repeat.options", json!({"endAngle": "x"}), number("endAngle", json!("x"))),
("object.repeat.options", json!({"hSpacing": "1"}), number("hSpacing", json!("1"))),
("object.repeat.options", json!({"vSpacing": [1]}), number("vSpacing", json!([1]))),
("object.repeat.options", json!({"rows": "2"}), number("rows", json!("2"))),
("object.repeat.options", json!({"cols": [2]}), number("cols", json!([2]))),
("object.repeat.options", json!({"brickOffset": "0.5"}), number("brickOffset", json!("0.5"))),
("object.repeat.options", json!({"angle": "10"}), number("angle", json!("10"))),
("object.repeat.options", json!({"reverseOverlap": "true"}), switch("reverseOverlap")),
("object.repeat.options", json!({"flipRows": 1}), switch("flipRows")),
("object.repeat.options", json!({"flipCols": "no"}), switch("flipCols")),
("object.repeat.options", json!({"gridType": 3}), string("gridType")),
("object.repeat.options", json!({"center": [1]}), "center must be [x, y] with finite coordinates".into()),
("object.repeat.options", json!({"center": "0, 0"}), "center must be [x, y] with finite coordinates".into()),
],
);
let all_null = json!({"instances": null, "radius": null, "startAngle": null, "endAngle": null, "reverseOverlap": null, "center": null});
s.execute("object.repeat.options", &all_null).unwrap();
assert_eq!(kind(&s), radial, "null params keep the options");
s.execute("object.repeat.options", &json!({"instances": 6, "reverseOverlap": true, "center": [10, 20]})).unwrap();
assert!(matches!(kind(&s), RepeatKind::Radial { instances: 6, reverse_overlap: true, .. }));
}
/// Make, Edit Pattern, Save a Copy, Pattern Options and Delete Pattern refuse a param of the wrong
/// type (and Pattern Options an `overlap` side it doesn't know) with an error naming it, and change
/// nothing. Null counts as a param left out, which keeps its default.
#[test]
fn pattern_commands_refuse_a_param_of_the_wrong_type() {
let mut s = session();
let r = make_dots(&mut s);
sel(&mut s, &[r]);
let options = |p: Value| {
let mut p = p;
p["name"] = json!("Dots");
p
};
refused(
&mut s,
vec![
("object.pattern.make", json!({"name": 5}), string("name")),
("object.pattern.make", json!({"tileType": 2}), string("tileType")),
("object.pattern.make", json!({"brickOffset": "0.5"}), number("brickOffset", json!("0.5"))),
("object.pattern.make", json!({"width": "20"}), number("width", json!("20"))),
("object.pattern.make", json!({"height": true}), number("height", json!(true))),
("object.pattern.make", json!({"edit": "false"}), switch("edit")),
("object.pattern.edit", json!({"name": 5}), string("name")),
("pattern.options", json!({"name": ["Dots"]}), string("name")),
("pattern.options", options(json!({"newName": 5})), string("newName")),
("pattern.options", options(json!({"tileType": []})), string("tileType")),
("pattern.options", options(json!({"width": "20"})), number("width", json!("20"))),
("pattern.options", options(json!({"hSpacing": "1"})), number("hSpacing", json!("1"))),
("pattern.options", options(json!({"vSpacing": true})), number("vSpacing", json!(true))),
("pattern.options", options(json!({"copies": "5"})), number("copies", json!("5"))),
("pattern.options", options(json!({"dimCopies": "50"})), number("dimCopies", json!("50"))),
("pattern.options", options(json!({"sizeTileToArt": "true"})), switch("sizeTileToArt")),
("pattern.options", options(json!({"showTileEdge": 1})), switch("showTileEdge")),
("pattern.options", options(json!({"showSwatchBounds": "no"})), switch("showSwatchBounds")),
("pattern.options", options(json!({"overlap": "right"})), "`overlap` must be an object, not \"right\"".into()),
("pattern.options", options(json!({"overlap": {"h": "center"}})), "`overlap.h` must be one of left, right".into()),
("pattern.options", options(json!({"overlap": {"v": 1}})), "`overlap.v` must be one of top, bottom".into()),
("pattern.delete", json!({"name": 5}), string("name")),
],
);
// Null params keep the defaults: a new pattern named "New Pattern 1", a grid tile the size of
// the art, edited.
let made = s
.execute("object.pattern.make", &json!({"name": null, "tileType": null, "brickOffset": null, "width": null, "height": null, "edit": null}))
.unwrap();
assert_eq!(made["name"], "New Pattern 1");
let def = doc(&s).pattern("New Pattern 1").unwrap().clone();
assert_eq!(def.tile_type, TileType::Grid);
assert!((def.tile.width() - 10.0).abs() < 1e-6, "{:?}", def.tile);
assert_eq!(doc(&s).pattern_edit.as_ref().map(|e| e.pattern.as_str()), Some("New Pattern 1"));
// In editing mode: Save a Copy refuses a name that isn't a string, and Pattern Options with
// null params keeps the edited pattern's options.
refused(&mut s, vec![("object.pattern.saveCopy", json!({"name": 5}), string("name"))]);
s.execute("pattern.options", &json!({"name": null, "overlap": {"h": null, "v": null}, "copies": null, "sizeTileToArt": null})).unwrap();
assert!(doc(&s).pattern("New Pattern 1").unwrap() == &def);
s.execute("pattern.options", &json!({"overlap": {"h": "right"}})).unwrap();
assert!(doc(&s).pattern("New Pattern 1").unwrap().overlap.right_in_front);
assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": null})).unwrap()["name"], "New Pattern 1 copy");
s.execute("object.pattern.done", &json!({})).unwrap();
assert!(s.execute("pattern.delete", &json!({"name": null})).is_err(), "no name given");
}
+39
View File
@@ -262,6 +262,45 @@ fn snap_moves_objects_at_different_depths_by_one_plane_offset() {
assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}");
}
/// The anchors of `flat` and `far` after a snapped move of both on the left plane, where `flat`
/// starts flat (attached by the move, or first with `attach_first`) and `far` sits 50 pt deeper.
fn move_with_a_flat_first_object(attach_first: bool, constrain: bool) -> Vec<Point> {
let (mut s, _) = persp_session();
let rect = |s: &mut Session, x: f64| {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap())
};
s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap();
let far = rect(&mut s, 150.0);
s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap();
s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap();
let flat = rect(&mut s, 240.0);
if attach_first {
s.execute("perspective.attach", &json!({"ids": [flat.0], "plane": "left"})).unwrap();
}
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
let h = grid(&s).homography_at(Plane::Left, 0.0).unwrap();
let (from, to) = (h.apply(Point::new(100.0, 100.0)).unwrap(), h.apply(Point::new(137.3, 121.7)).unwrap());
s.execute(
"perspective.move",
&json!({"ids": [flat.0, far.0], "plane": "left", "from": [from.x, from.y], "to": [to.x, to.y], "constrain": constrain}),
)
.unwrap();
assert_eq!(attached(&s, flat).map(|a| a.0), Some(Plane::Left));
let doc = &s.doc().unwrap().doc;
[flat, far].iter().flat_map(|id| doc.node(*id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect::<Vec<_>>()).collect()
}
#[test]
fn snap_reads_a_flat_first_object_as_attached_by_the_move() {
for constrain in [false, true] {
let (moved, attached_first) = (move_with_a_flat_first_object(false, constrain), move_with_a_flat_first_object(true, constrain));
assert_eq!(moved.len(), attached_first.len());
for (a, b) in moved.iter().zip(&attached_first) {
assert!(a.distance(*b) < 1e-6, "constrain {constrain}: {moved:?} vs {attached_first:?}");
}
}
}
#[test]
fn constrained_move_uses_each_objects_plane() {
let (mut s, right) = persp_session();
+78
View File
@@ -467,6 +467,84 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() {
assert!(!t.ruby.is_empty() && t.ruby.iter().all(|r| !r.overhang && r.size_percent == 50.0), "{:?}", t.ruby);
}
/// The Character commands, Ruby and Move Type Path Handle refuse a param of the wrong type with an
/// error naming the param, also when another param would change something, and leave the
/// document, its revision, the selection and the undo history as they were. Null counts as a param
/// left out, which keeps its default, and `size` and `leading` take a length with a unit.
#[test]
fn character_ruby_and_path_handle_refuse_a_param_of_the_wrong_type() {
use crate::tests::{history, not_a_number, select_with_a_step_to_redo};
let mut s = session();
let id = text(&mut s, "abc");
let path = s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]}));
let on_path = s.execute("text.createInPath", &json!({"path": path.unwrap()["id"], "mode": "onPath", "text": "Along"})).unwrap()["id"].clone();
select_with_a_step_to_redo(&mut s, &[id]);
let string = |k: &str| format!("`{k}` must be a string");
let tags = "features must be a list of tags".to_string();
let which = "`which` must be one of in, out".to_string();
let handle = |which: Value| json!({"id": on_path, "anchor": 0, "which": which, "x": 100, "y": 250});
let mut calls = vec![
("text.setStyle", json!({"font": 5}), string("font")),
("text.setStyle", json!({"style": true}), string("style")),
("text.setStyle", json!({"size": 30, "style": ["Bold"]}), string("style")),
("text.setStyle", json!({"justify": 1}), string("justify")),
("text.setStyle", json!({"size": 30, "justify": {"value": "center"}}), string("justify")),
("text.setStyle", json!({"features": ["dlig", 5]}), tags.clone()),
("text.setStyle", json!({"leading": true}), "leading must be a number or \"auto\"".to_string()),
("text.setRangeStyle", json!({"id": id.0, "font": 5}), string("font")),
("text.setRangeStyle", json!({"id": id.0, "size": 30, "style": false}), string("style")),
("text.setRangeStyle", json!({"id": id.0, "features": [null]}), tags),
("type.ruby", json!({"text": 5}), string("text")),
("type.ruby", json!({"remove": true, "text": ["a"]}), string("text")),
("type.ruby", json!({"text": "a", "position": 1}), string("position")),
("type.ruby", json!({"text": "a", "align": true}), string("align")),
("text.setPathHandle", handle(json!(1)), which.clone()),
("text.setPathHandle", handle(json!("up")), which),
];
calls.extend(
[
("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"),
]
.map(|(cmd, p, key)| {
let (_, p, msg) = not_a_number(cmd, p, key);
(cmd, p, msg)
}),
);
let state = |s: &Session| {
let st = s.doc().unwrap();
(st.doc.clone(), st.revision, st.selection.objects.clone(), history(s))
};
let before = state(&s);
let wrong: Vec<String> = calls
.iter()
.filter_map(|(cmd, p, want)| {
let r = s.execute(cmd, p);
let refused = matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want);
let unchanged = state(&s) == before;
(!refused || !unchanged).then(|| format!("{cmd} {p}: {r:?}, unchanged: {unchanged}"))
})
.collect();
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
// Null keeps each default; a size and a leading with a unit are lengths.
let r = json!({"font": null, "style": null, "justify": null, "tracking": null, "features": null, "leading": null, "size": "5 mm"});
s.execute("text.setStyle", &r).unwrap();
let st = obj(&s, id).runs[0].style.clone();
assert!((st.size - 5.0 * 72.0 / 25.4).abs() < 1e-6 && st.tracking == 0.0 && st.leading.is_none(), "{st:?}");
s.execute("text.setStyle", &json!({"size": "12 pt", "leading": "18 pt", "tracking": 50})).unwrap();
let st = obj(&s, id).runs[0].style.clone();
assert_eq!((st.size, st.leading, st.tracking), (12.0, Some(18.0), 50.0));
s.execute("type.ruby", &json!({"text": "a", "position": null, "align": null})).unwrap();
let ruby = obj(&s, id).ruby;
assert!(ruby.iter().all(|r| r.position == vectorcraft_doc::RubyPosition::Above && r.align == vectorcraft_doc::RubyAlign::Center), "{ruby:?}");
s.execute("text.setPathHandle", &handle(Value::Null)).unwrap();
}
/// 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);
+13
View File
@@ -36,6 +36,19 @@ fn preferences_general_sets_the_open_documents_units_as_one_undo_step() {
assert_eq!(s.prefs.units_general, "millimeters", "undo is the document's; the preference stays");
}
#[test]
fn preferences_general_with_document_units_false_sets_only_the_preference() {
let mut s = session();
s.execute("document.setUnits", &json!({"units": "Millimeters"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.execute("prefs.set", &json!({"values": {"unitsGeneral": "inches"}, "documentUnits": false})).unwrap();
assert_eq!((units(&s), s.prefs.units_general.as_str()), (Unit::Millimeters, "inches"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let e = s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "points", "documentUnits": "no"})).unwrap_err();
assert!(e.to_string().contains("`documentUnits` must be true or false"), "{e}");
assert_eq!((units(&s), s.prefs.units_general.as_str()), (Unit::Millimeters, "inches"), "nothing changes on error");
}
#[test]
fn new_documents_start_in_the_preference_and_document_setup_only_changes_the_document() {
let mut s = Session::new();