Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb7d7a183 | ||
|
|
99671cabd8 | ||
|
|
522c1dabd6 | ||
|
|
b767e850ed |
No files matched your search
+1
-1
@@ -98,7 +98,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
|
||||
|
||||
@@ -269,17 +269,23 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
} else if !matches!(steps.first(), Some(Step::Cmd(id, _)) if id == "file.new") {
|
||||
h.ensure_document();
|
||||
}
|
||||
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
|
||||
let mut results = vec![];
|
||||
for step in steps {
|
||||
match step {
|
||||
let r = match step {
|
||||
Step::Cmd(id, params) => {
|
||||
let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("{id}: {e}"))?;
|
||||
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
|
||||
emit(json!({"step": "cmd", "command": id, "result": r}))?;
|
||||
r
|
||||
}
|
||||
Step::Export(path) => {
|
||||
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
r
|
||||
}
|
||||
}
|
||||
};
|
||||
results.push(r);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -52,6 +52,13 @@ impl RasterFx {
|
||||
pub fn is_below(&self) -> bool {
|
||||
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
|
||||
}
|
||||
/// The blend mode it paints with (shadows and glows).
|
||||
pub fn mode(&self) -> Option<BlendMode> {
|
||||
match self {
|
||||
RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } => Some(*mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches
|
||||
/// further around larger objects).
|
||||
pub fn outset(&self, bounds: Rect) -> f64 {
|
||||
|
||||
@@ -366,13 +366,23 @@ pub fn color_value(v: &Value) -> Option<Color> {
|
||||
|
||||
/// Objects a command targets: explicit `ids` param or the selection.
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
if let Some(ids) = ids_param(p, "ids") {
|
||||
return Ok(ids);
|
||||
// Objects given are used as given: a value that isn't an object id fails rather than the
|
||||
// command acting on the selection instead (#785).
|
||||
let object_id = |v: &Value| v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}")));
|
||||
match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) => return a.iter().map(object_id).collect(),
|
||||
Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))),
|
||||
None => {}
|
||||
}
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
return Ok(vec![id]);
|
||||
match p.get("id").filter(|v| !v.is_null()) {
|
||||
Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]),
|
||||
// A step reference a batch couldn't resolve (other strings are other ids, such as an
|
||||
// effect's).
|
||||
Some(Value::String(r)) if r.starts_with('$') => {
|
||||
Err(EngineError::Other(format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
|
||||
}
|
||||
_ => Ok(s.doc()?.selection.objects.clone()),
|
||||
}
|
||||
Ok(s.doc()?.selection.objects.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn ok() -> Result<Value> {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, ColorMode, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel, StrokeLayer,
|
||||
@@ -217,18 +217,55 @@ pub(crate) fn raster_fx(n: &Node) -> Vec<RasterFx> {
|
||||
/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of
|
||||
/// everything inside it.
|
||||
pub(crate) fn strip_raster(n: &mut Node, members: bool) {
|
||||
let keep = |e: &Effect| !effects::is_raster(&e.id);
|
||||
retain_effects(n, members, &|e| !effects::is_raster(&e.id));
|
||||
}
|
||||
|
||||
/// Keep the effects of `n` (its own and its fills' and strokes') that `keep` accepts and, with
|
||||
/// `members`, of everything inside it.
|
||||
fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) {
|
||||
n.appearance.effects.retain(keep);
|
||||
for it in &mut n.appearance.items {
|
||||
it.effects_mut().retain(keep);
|
||||
}
|
||||
if members {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
strip_raster(Arc::make_mut(c), true);
|
||||
retain_effects(Arc::make_mut(c), true, keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The blend mode raster effect `e` paints with (`None`: not a raster effect, or one without).
|
||||
fn effect_mode(e: &Effect) -> Option<BlendMode> {
|
||||
effects::raster_effects(std::slice::from_ref(e)).first().and_then(RasterFx::mode)
|
||||
}
|
||||
|
||||
/// The images of the shadows and outer glows of `n`, one per blend mode they use (in the order
|
||||
/// the modes first come), each composited with its mode: rendered alone, over nothing, they can't
|
||||
/// blend with the art below the object, so the image does.
|
||||
fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
|
||||
let mut modes: Vec<BlendMode> = vec![];
|
||||
for mode in raster_fx(n).iter().filter_map(RasterFx::mode) {
|
||||
if !modes.contains(&mode) {
|
||||
modes.push(mode);
|
||||
}
|
||||
}
|
||||
if modes.len() < 2 {
|
||||
let mut image = raster_image(d, n, true, false)?;
|
||||
image.blend = modes.first().copied().unwrap_or_default();
|
||||
return Some(vec![image]);
|
||||
}
|
||||
modes
|
||||
.into_iter()
|
||||
.map(|mode| {
|
||||
let mut only = n.clone();
|
||||
retain_effects(&mut only, false, &|e| effect_mode(e).is_none_or(|m| m == mode));
|
||||
let mut image = raster_image(d, &only, true, false)?;
|
||||
image.blend = mode;
|
||||
Some(image)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d`
|
||||
/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays
|
||||
/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds
|
||||
@@ -405,15 +442,18 @@ fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
}
|
||||
// An image that can't be made leaves the object as it is (the writer reports its effects).
|
||||
let image = raster_image(out, n, below, false)?;
|
||||
let mut v = n.clone();
|
||||
strip_raster(&mut v, false);
|
||||
let mut m = if below {
|
||||
let images = below_images(out, n)?;
|
||||
let mut m = walk(out, &v, which).unwrap_or(v);
|
||||
put_below(out, &mut m, image);
|
||||
// Each goes in first: the last one put is the lowest.
|
||||
for image in images.into_iter().rev() {
|
||||
put_below(out, &mut m, image);
|
||||
}
|
||||
m
|
||||
} else {
|
||||
replace_with_image(v, image)
|
||||
replace_with_image(v, raster_image(out, n, false, false)?)
|
||||
};
|
||||
walk_mask(out, &mut m, which);
|
||||
Some(m)
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -420,7 +420,14 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
|
||||
fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setRangeStyle";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
// The type object given, else the one selected (#785).
|
||||
let id = match p.get("id").filter(|v| !v.is_null()) {
|
||||
Some(v) => v.as_u64().map(NodeId).ok_or_else(|| bad(C, format!("`id` must be an object id, not {v}")))?,
|
||||
None => match super::typecmd::text_targets(s, &json!({}), C)?.as_slice() {
|
||||
[one] => *one,
|
||||
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
|
||||
},
|
||||
};
|
||||
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
pub mod cmd;
|
||||
pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod steps;
|
||||
mod tooling;
|
||||
pub mod units;
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! References between the steps of a batch (`vectorcraft-cli run`, MCP `command_batch`): a
|
||||
//! parameter string that is exactly `$N` or `$N.path` takes that value from the result of step `N`
|
||||
//! (0-based, in the order the steps ran), and `$last…` from the step before. A path goes into
|
||||
//! objects by key and into arrays by index: `$1.id`, `$2.ids.0`, `$last.ids[0]`. `$$` at the start
|
||||
//! of a string stands for one `$` and is never a reference (text such as `$$1.99`). A string that
|
||||
//! looks like a reference but names no value is an error, so a step never runs with a reference
|
||||
//! left in it.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Most nesting [`resolve`] walks into: deeper parameters are left as they are.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// `params` with every step reference replaced from `results` (the results of the steps run so
|
||||
/// far, in order); an error names a reference that resolves to nothing.
|
||||
pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> {
|
||||
resolve_at(params, results, 0)
|
||||
}
|
||||
|
||||
fn resolve_at(v: &Value, results: &[Value], depth: usize) -> Result<Value, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Ok(v.clone());
|
||||
}
|
||||
Ok(match v {
|
||||
Value::String(s) => match s.strip_prefix("$$") {
|
||||
Some(rest) => Value::String(format!("${rest}")),
|
||||
None => match parse(s) {
|
||||
Some((step, path)) => lookup(s, step, &path, results)?,
|
||||
None => v.clone(),
|
||||
},
|
||||
},
|
||||
Value::Array(a) => Value::Array(a.iter().map(|x| resolve_at(x, results, depth + 1)).collect::<Result<_, _>>()?),
|
||||
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), resolve_at(x, results, depth + 1)?))).collect::<Result<_, String>>()?),
|
||||
_ => v.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Which step a reference names.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Step {
|
||||
Index(usize),
|
||||
Last,
|
||||
}
|
||||
|
||||
/// A path segment: an object key or an array index (`.0` is either, as the value decides).
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Seg {
|
||||
Key(String),
|
||||
Index(usize),
|
||||
}
|
||||
|
||||
/// `$N…` or `$last…` → the step and the path after it; `None` for any other string.
|
||||
fn parse(s: &str) -> Option<(Step, Vec<Seg>)> {
|
||||
let rest = s.strip_prefix('$')?;
|
||||
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
|
||||
let (step, mut rest) = if digits > 0 {
|
||||
let (n, rest) = rest.split_at_checked(digits)?;
|
||||
(Step::Index(n.parse().ok()?), rest)
|
||||
} else {
|
||||
(Step::Last, rest.strip_prefix("last")?)
|
||||
};
|
||||
let mut path = vec![];
|
||||
while !rest.is_empty() {
|
||||
if let Some(r) = rest.strip_prefix('.') {
|
||||
let len = r.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_').count();
|
||||
let (key, r) = r.split_at_checked(len)?;
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
path.push(Seg::Key(key.to_string()));
|
||||
rest = r;
|
||||
} else {
|
||||
let (n, r) = rest.strip_prefix('[')?.split_once(']')?;
|
||||
path.push(Seg::Index(n.parse().ok()?));
|
||||
rest = r;
|
||||
}
|
||||
}
|
||||
Some((step, path))
|
||||
}
|
||||
|
||||
fn lookup(text: &str, step: Step, path: &[Seg], results: &[Value]) -> Result<Value, String> {
|
||||
let (i, found) = match step {
|
||||
Step::Index(i) => (i, results.get(i)),
|
||||
Step::Last => (results.len().saturating_sub(1), results.last()),
|
||||
};
|
||||
let mut cur = found.ok_or_else(|| format!("`{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $)"))?;
|
||||
for seg in path {
|
||||
let next = match (seg, cur) {
|
||||
(Seg::Key(k), Value::Object(o)) => o.get(k),
|
||||
(Seg::Key(k), Value::Array(a)) => k.parse::<usize>().ok().and_then(|n| a.get(n)),
|
||||
(Seg::Index(n), Value::Array(a)) => a.get(*n),
|
||||
_ => None,
|
||||
};
|
||||
cur = next.ok_or_else(|| format!("`{text}`: step {i}'s result has no such value (write $$ for a literal $)"))?;
|
||||
}
|
||||
Ok(cur.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn references_take_values_from_earlier_results() {
|
||||
let results = [json!({"index": 0}), json!({"id": 2}), json!({"ids": [5, 6]})];
|
||||
let p = json!({"id": "$1.id", "ids": ["$2.ids.1", "$last.ids[0]"], "nested": {"all": "$2"}, "n": 3, "flag": true});
|
||||
assert_eq!(resolve(&p, &results).unwrap(), json!({"id": 2, "ids": [6, 5], "nested": {"all": {"ids": [5, 6]}}, "n": 3, "flag": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_strings_and_escapes_are_left_alone() {
|
||||
let results = [json!({"id": 2})];
|
||||
let p = json!({"text": "costs $1", "price": "$$1.99", "name": "a$1.id", "dollar": "$", "word": "$lastname"});
|
||||
assert_eq!(
|
||||
resolve(&p, &results).unwrap(),
|
||||
json!({"text": "costs $1", "price": "$1.99", "name": "a$1.id", "dollar": "$", "word": "$lastname"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_to_nothing_is_an_error() {
|
||||
let results = [json!({"id": 2})];
|
||||
for bad in ["$1", "$1.id", "$0.ids", "$0.id.x", "$last.nope", "$7[0]"] {
|
||||
let e = resolve(&json!({"id": bad}), &results).unwrap_err();
|
||||
assert!(e.contains(bad), "{bad}: {e}");
|
||||
}
|
||||
assert!(resolve(&json!({"id": "$last"}), &[]).is_err(), "no step before the first");
|
||||
// Huge indexes and deep nesting don't panic.
|
||||
assert!(resolve(&json!("$99999999999999999999999.id"), &results).is_ok_and(|v| v == json!("$99999999999999999999999.id")));
|
||||
let mut deep = json!("$0.id");
|
||||
for _ in 0..200 {
|
||||
deep = json!([deep]);
|
||||
}
|
||||
assert!(resolve(&deep, &results).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -853,3 +853,30 @@ fn new_layers_and_sublayers_are_numbered_together() {
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap();
|
||||
assert_eq!(name(&s, &sub), "Layer 4", "the art doesn't count");
|
||||
}
|
||||
|
||||
/// #785: objects given are used as given (a value that isn't an object id fails, never the
|
||||
/// selection instead), and `text.setRangeStyle` works on the selected type object without `id`.
|
||||
#[test]
|
||||
fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() {
|
||||
let mut s = session();
|
||||
let first = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "first", "size": 30})).unwrap()["id"].clone();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 140, "text": "second", "size": 30})).unwrap();
|
||||
for bad in [
|
||||
json!({"id": "$1.id", "size": 60}),
|
||||
json!({"id": 2.5, "size": 60}),
|
||||
json!({"ids": [first.clone(), "$1.id"], "size": 60}),
|
||||
json!({"ids": 3, "size": 60}),
|
||||
] {
|
||||
assert!(s.execute("text.setStyle", &bad).is_err(), "{bad}");
|
||||
}
|
||||
// Another kind of id (an effect's) is still not an object id.
|
||||
let sel = s.doc().unwrap().selection.objects.clone();
|
||||
assert!(s.execute("effect.apply", &json!({"id": "stylize.dropShadow"})).is_ok());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, sel);
|
||||
// The selected type object (the second) is styled when no id is given.
|
||||
let r = s.execute("text.setRangeStyle", &json!({"start": 0, "end": 3, "style": "Bold"})).unwrap();
|
||||
assert_ne!(r["id"], first);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"start": 0, "end": 3, "size": 40})).is_err(), "two type objects: which one?");
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": "two", "size": 40})).is_err());
|
||||
}
|
||||
@@ -150,3 +150,35 @@ fn live_objects_and_single_fills_and_strokes_keep_their_raster_effects() {
|
||||
assert_eq!(pieces, 2, "item {item}: one piece per fill and stroke");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_and_glows_keep_their_blend_modes() {
|
||||
// #827: a red Screen glow over grey can't darken it, as Normal would.
|
||||
let glow = Effect {
|
||||
id: "stylize.outerGlow".into(),
|
||||
params: json!({"mode": "screen", "color": "#ff0000", "opacity": 100.0, "blur": 20.0}),
|
||||
visible: true,
|
||||
};
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let back = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), Color::rgb(0.5, 0.5, 0.5));
|
||||
add(&mut d, back);
|
||||
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
|
||||
add(&mut d, with(dot, glow.clone()));
|
||||
assert_written("screen glow", &d);
|
||||
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
|
||||
let back = render(&vectorcraft_pdf::import(&r.bytes).unwrap());
|
||||
for x in [133, 138, 145] {
|
||||
let i = (100 * back.width as usize + x) * 4;
|
||||
let (red, green, blue) = (back.pixels[i], back.pixels[i + 1], back.pixels[i + 2]);
|
||||
assert!(red > green && green >= 124 && blue >= 124, "x {x}: {:?}", (red, green, blue));
|
||||
}
|
||||
|
||||
// A Multiply shadow and the Screen glow: an image each, composited with its mode.
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
|
||||
add(&mut d, with(with(dot, shadow()), glow));
|
||||
let flat = crate::flatten_raster_effects(&d).unwrap();
|
||||
let modes: Vec<_> =
|
||||
flat.layers[0].children().unwrap()[0].children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.blend).collect();
|
||||
assert_eq!(modes, [vectorcraft_color::BlendMode::Multiply, vectorcraft_color::BlendMode::Screen]);
|
||||
}
|
||||
@@ -129,3 +129,21 @@ fn conventions_resources_and_versioned_results() {
|
||||
rpc(&mut s, "initialize", json!({"protocolVersion":"2025-06-18"}));
|
||||
assert!(rpc(&mut s, "tools/list", json!({}))["result"].get("resultType").is_none());
|
||||
}
|
||||
|
||||
/// #785: a batch step can use an earlier step's result (`"$N.path"`); a reference to nothing
|
||||
/// fails that step.
|
||||
#[test]
|
||||
fn batch_steps_refer_to_earlier_results() {
|
||||
let mut s = Server::new(Box::new(Headless::with_document()));
|
||||
let steps = json!([
|
||||
{"id":"text.create","params":{"x":10,"y":60,"text":"plain and bold words","size":30}},
|
||||
{"id":"shape.rectangle","params":{"x":0,"y":0,"width":20,"height":20}},
|
||||
{"id":"text.setRangeStyle","params":{"id":"$0.id","start":10,"end":14,"style":"Bold"}},
|
||||
{"id":"text.setRangeStyle","params":{"id":"$9.id","start":0,"end":1,"style":"Bold"}}
|
||||
]);
|
||||
let r = call(&mut s, "command_batch", json!({"steps":steps,"stop_on_error":false}));
|
||||
let p = payload(&r);
|
||||
assert_eq!((p["completed"].clone(), p["failed"].clone()), (json!(3), json!(1)), "{p}");
|
||||
assert_eq!(p["results"][2]["result"]["id"], p["results"][0]["result"]["id"], "the text, not the selected rectangle");
|
||||
assert!(p["results"][3]["error"].as_str().unwrap().contains("$9.id"), "{p}");
|
||||
}
|
||||
@@ -96,7 +96,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"command_batch",
|
||||
"Run several commands",
|
||||
"Run steps in order; each edit is its own undo step. Returns completed, failed and results. Stops on the first error by default.",
|
||||
"Run steps in order; each edit is its own undo step. A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed and results. Stops on the first error by default.",
|
||||
obj(
|
||||
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}}),
|
||||
&["steps"],
|
||||
@@ -899,6 +899,8 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
|
||||
};
|
||||
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
|
||||
// Each step's result (null for a failed one), for later steps' `"$N.path"` references.
|
||||
let mut values: Vec<Value> = vec![];
|
||||
for step in steps {
|
||||
let result = vectorcraft_engine::guard::catch_panic(|| {
|
||||
let args = step.as_object().ok_or("each step must be an object")?;
|
||||
@@ -908,7 +910,7 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let id = req_str(args, "id")?;
|
||||
let params = match args.get("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
Some(v @ Value::Object(_)) => vectorcraft_engine::steps::resolve(v, &values)?,
|
||||
Some(_) => return Err("`params` must be an object".into()),
|
||||
};
|
||||
b.call("engine.execute", json!({"command":id,"params":params}))
|
||||
@@ -917,10 +919,12 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
match result {
|
||||
Ok(v) => {
|
||||
completed += 1;
|
||||
values.push(v.clone());
|
||||
results.push(json!({"ok":true,"result":v}));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
values.push(Value::Null);
|
||||
results.push(json!({"ok":false,"error":e}));
|
||||
if stop {
|
||||
break;
|
||||
|
||||
@@ -365,11 +365,16 @@ impl Targets {
|
||||
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
|
||||
/// unlocked, as the selection tools pick them) pull the pointer into line where they run.
|
||||
fn for_snap_to_point(self, cx: &ToolContext) -> Self {
|
||||
let mut t = self.anchors_only();
|
||||
self.anchors_only().with_rulers(cx)
|
||||
}
|
||||
|
||||
/// These targets and the ruler guides, when they are shown (and so unlocked to the selection
|
||||
/// tools): they pull a point into line where they run.
|
||||
fn with_rulers(mut self, cx: &ToolContext) -> Self {
|
||||
if cx.guides {
|
||||
t.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
|
||||
self.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
|
||||
}
|
||||
t
|
||||
self
|
||||
}
|
||||
|
||||
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
|
||||
@@ -486,8 +491,10 @@ impl Targets {
|
||||
if d.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
// The ruler guides that way count as lines too (#812).
|
||||
let rulers: Vec<(f64, Point, Kind)> = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(q, reach)).collect();
|
||||
// A line that far across the way crosses it at least that far along.
|
||||
for (t, from, _) in lines_near(lines, v, reach) {
|
||||
for (t, from, _) in lines_near(lines, v, reach).iter().chain(&rulers) {
|
||||
let s = (t - a) / d;
|
||||
// Exactly on the line, whatever the rounding along the way.
|
||||
let hit = if vertical { Point::new(*t, at.y + dir.y * s) } else { Point::new(at.x + dir.x * s, *t) };
|
||||
@@ -827,7 +834,9 @@ impl PointSnap {
|
||||
let targets = if cx.snap_to_pixel || cx.snap_to_grid {
|
||||
None
|
||||
} else if cx.smart_guides {
|
||||
Some((targets().styled(cx), cx.snapping_tolerance))
|
||||
// Snap to Point keeps pulling points onto the ruler guides with Smart Guides on (#812).
|
||||
let t = targets().styled(cx);
|
||||
Some((if cx.snap_to_point { t.with_rulers(cx) } else { t }, cx.snapping_tolerance))
|
||||
} else if cx.snap_to_point {
|
||||
Some((targets().for_snap_to_point(cx), cx.snap_tolerance))
|
||||
} else {
|
||||
@@ -1397,6 +1406,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #812: with Smart Guides on, ruler guides pull a drawn point too (Snap to Point), horizontal
|
||||
/// and vertical alike, also while it slides along a construction guide or Shift's angle.
|
||||
#[test]
|
||||
fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 313.0));
|
||||
d.guides.push(vectorcraft_doc::Guide::new(true, 331.0));
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut snap = DrawSnap::default();
|
||||
assert_eq!(snap.press(&c, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 313.0), "the horizontal guide");
|
||||
assert_eq!(snap.press(&c, Point::new(329.0, 260.0), &[], None), Point::new(331.0, 260.0), "the vertical guide");
|
||||
// On the 45° construction guide from (210, 210), where the horizontal guide crosses it.
|
||||
let from = Leave::segment(&c, Point::new(210.0, 210.0), false);
|
||||
let q = snap.press(&c, Point::new(312.0, 311.0), &[], Some(&from));
|
||||
assert!((q - Point::new(313.0, 313.0)).hypot() < 1e-9, "{q:?}");
|
||||
// Snap to Point off: Smart Guides alone don't use the ruler guides.
|
||||
let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) };
|
||||
assert_eq!(snap.press(&off, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 315.0));
|
||||
}
|
||||
|
||||
/// A drawing tool's snapper reuses its targets while hovering over the same document state, and
|
||||
/// gathers them again when it changes, and on every press.
|
||||
#[test]
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct SelectionTool {
|
||||
targets: Option<Targets>,
|
||||
moving: Option<MoveSnap>,
|
||||
guide: GuideEdit,
|
||||
/// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]).
|
||||
hover: Option<(Point, &'static str)>,
|
||||
}
|
||||
|
||||
/// Snapping for the selection moved as a whole, its targets gathered when the move begins: View →
|
||||
@@ -163,10 +165,36 @@ fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option<BoxHit> {
|
||||
in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate)
|
||||
}
|
||||
|
||||
/// How near (screen points) the pointer must come to an object's centre or anchor for Smart Guides
|
||||
/// to label it while hovering.
|
||||
const HOVER_REACH: f64 = 6.0;
|
||||
|
||||
impl SelectionTool {
|
||||
fn drag_threshold(cx: &ToolContext) -> f64 {
|
||||
cx.tol(3.0)
|
||||
}
|
||||
|
||||
/// With Smart Guides on, the anchor or the centre of an object near `p` (the one under the
|
||||
/// pointer, else a selected one) and its label, "anchor" or "center", as Illustrator shows
|
||||
/// them while hovering (#812). A press there grabs the object by that point.
|
||||
fn hover_point(cx: &ToolContext, p: Point) -> Option<(Point, &'static str)> {
|
||||
if !cx.smart_guides {
|
||||
return None;
|
||||
}
|
||||
let reach = cx.tol(HOVER_REACH);
|
||||
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation));
|
||||
let nearest = |points: &mut dyn Iterator<Item = Point>| {
|
||||
points.filter(|q| q.distance(p) <= reach).min_by(|a, b| a.distance(p).total_cmp(&b.distance(p)))
|
||||
};
|
||||
under.into_iter().chain(cx.selection.objects.iter().copied()).find_map(|id| {
|
||||
let n = cx.doc.node(id)?;
|
||||
let anchor = n.path_data().and_then(|pd| nearest(&mut pd.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))));
|
||||
anchor.map(|q| (q, "anchor")).or_else(|| {
|
||||
let c = cx.doc.bounds_of(&[id], cx.preview_bounds)?.center();
|
||||
(c.distance(p) <= reach).then_some((c, "center"))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for SelectionTool {
|
||||
@@ -188,6 +216,11 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
let p = ev.pos;
|
||||
let m = ev.mods;
|
||||
// A press near the hovered centre or anchor grabs the object by it.
|
||||
let grab = self.hover.take().map(|(q, _)| q).filter(|q| q.distance(p) <= cx.tol(HOVER_REACH)).unwrap_or(p);
|
||||
if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) {
|
||||
self.hover = Self::hover_point(cx, p);
|
||||
}
|
||||
match (ev.kind, self.state.clone()) {
|
||||
(PointerKind::DoubleClick, _) => {
|
||||
self.state = State::Idle;
|
||||
@@ -278,7 +311,7 @@ impl Tool for SelectionTool {
|
||||
} else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt {
|
||||
key = Some(top);
|
||||
}
|
||||
self.state = State::Moving { start: p, began: false, deselect, key };
|
||||
self.state = State::Moving { start: grab, began: false, deselect, key };
|
||||
out
|
||||
}
|
||||
None => {
|
||||
@@ -397,6 +430,12 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
o.extend(self.guides.iter().cloned());
|
||||
o.extend(self.guide.overlays(cx));
|
||||
if let (Some((q, label)), State::Idle) = (self.hover, &self.state) {
|
||||
let style = crate::guides::GuideStyle::of(cx);
|
||||
if style.labels {
|
||||
o.push(Overlay::Label { p: q, text: label.into(), color: style.color });
|
||||
}
|
||||
}
|
||||
if let Some((p, t)) = &self.measure {
|
||||
o.push(Overlay::Measure { p: *p, text: t.clone() });
|
||||
}
|
||||
@@ -483,6 +522,39 @@ mod tests {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
/// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a
|
||||
/// press there grabs the object by that point.
|
||||
#[test]
|
||||
fn hovering_labels_the_centre_and_anchors_and_a_press_grabs_by_them() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let label = |t: &SelectionTool| {
|
||||
t.overlays(&cx).iter().find_map(|o| match o {
|
||||
Overlay::Label { p, text, .. } => Some((*p, text.clone())),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0));
|
||||
assert_eq!(label(&t), Some((Point::new(150.0, 150.0), "center".to_string())));
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 102.0, 101.0));
|
||||
assert_eq!(label(&t), Some((Point::new(100.0, 100.0), "anchor".to_string())));
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 130.0, 170.0));
|
||||
assert_eq!(label(&t), None, "away from both");
|
||||
// Grabbed by its centre, the object's centre follows the pointer.
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 152.0, 151.0));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 250.0));
|
||||
assert!(a.iter().any(|a| matches!(a, Action::Preview(_, v) if v["matrix"][4] == 100.0 && v["matrix"][5] == 100.0)), "{a:?}");
|
||||
// With Smart Guides off, nothing is labelled.
|
||||
let off = ToolContext { smart_guides: false, ..cx };
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&off, &ev(PointerKind::Move, 152.0, 151.0));
|
||||
assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Label { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_selects_object() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -3782,3 +3782,8 @@ axis Both Ambos
|
||||
Constrain Restringir
|
||||
Free Transform Transformación libre
|
||||
Perspective Distort Distorsión de perspectiva
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` es la referencia a un paso {r}, que solo `run` y `command_batch` resuelven
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` debe ser una lista de identificadores de objeto, no {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal)
|
||||
@msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3793,3 +3793,8 @@ axis Both Les deux
|
||||
Insert Inline Symbol Insérer un symbole dans le texte
|
||||
ICF Top/Right ICF haut/droite
|
||||
ICF Bottom/Left ICF bas/gauche
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` est la référence d’étape {r}, que seuls `run` et `command_batch` résolvent
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` doit être une liste d’identifiants d’objet, pas {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral)
|
||||
@msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3782,3 +3782,8 @@ axis Both Entrambi
|
||||
Constrain Vincola
|
||||
Free Transform Trasformazione libera
|
||||
Perspective Distort Distorsione prospettica
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` è il riferimento al passaggio {r}, che solo `run` e `command_batch` risolvono
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` deve essere un elenco di ID oggetto, non {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale)
|
||||
@msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3774,3 +3774,8 @@ theme Appearance 外観
|
||||
@msg larger than 256 MB 256 MB を超えています
|
||||
@msg the files come to more than 1 GB: add fewer at a time ファイルの合計が 1 GB を超えています: 一度に追加するファイルを減らしてください
|
||||
@msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません)
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください)
|
||||
@msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3780,3 +3780,8 @@ axis Both Оба
|
||||
Perspective Distort Искажение перспективы
|
||||
Artboard Монтажная область
|
||||
Exit Выйти
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — это ссылка на шаг {r}, которую разрешают только `run` и `command_batch`
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` должен быть массивом идентификаторов объектов, а не {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$)
|
||||
@msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3765,3 +3765,8 @@ axis Both Обидві
|
||||
Perspective Distort Спотворення перспективи
|
||||
Artboard Монтажна область
|
||||
Exit Вийти
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — це посилання на крок {r}, яке розв’язують лише `run` і `command_batch`
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` має бути масивом ідентифікаторів об’єктів, а не {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$)
|
||||
@msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v}
|
||||
|
Can't render this file because it contains an unexpected character in line 632 and column 40.
|
+11
-1
@@ -167,7 +167,7 @@ The older names below remain listed because existing workflows use them.
|
||||
|---|---|
|
||||
| `command_list` | Optional `filter`, `enabled_only`; returns the command array |
|
||||
| `command_run` | `id`, optional `params`; runs the command through the existing backend |
|
||||
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step; failures set `isError` |
|
||||
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below) |
|
||||
| `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` |
|
||||
| `render_preview` | Optional `max_side` (1–4096, default 1024); inline PNG of the first artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
|
||||
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |
|
||||
@@ -573,6 +573,16 @@ applies to the `--cmd` just before it. `run` also accepts the host commands `fil
|
||||
`file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
|
||||
(`vectorcraft-cli --help` lists them).
|
||||
|
||||
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:
|
||||
`"$1.id"`, `"$2.ids.0"`, `"$last.ids[0]"`. A string starting with `$$` stands for one `$` (`"$$1.99"` is `$1.99`). A
|
||||
reference that names no value fails the step, so a command never runs with one left in it:
|
||||
|
||||
```sh
|
||||
vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd text.create --params '{"x":10,"y":60,"text":"plain and bold words","size":30}' --cmd text.setRangeStyle --params '{"id":"$1.id","start":10,"end":14,"style":"Bold"}'
|
||||
```
|
||||
|
||||
## Transparency and opacity masks
|
||||
|
||||
Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage
|
||||
|
||||
Reference in new issue
Block a user