Panels to Illustrator parity (19 panels), ~70 menu commands, generic param dialogs, view rotation, property/model-based tests + MCP e2e; fix arrange overflow, compound grouping, double journaling, float round-trip, coordinate sanity, simplify growth, batch rollback of session state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -842,6 +842,7 @@ name = "drawcraft-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"drawcraft-mcp",
|
||||
"drawcraft-testkit",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -892,10 +893,12 @@ dependencies = [
|
||||
"drawcraft-pdf",
|
||||
"drawcraft-render",
|
||||
"drawcraft-svg",
|
||||
"drawcraft-testkit",
|
||||
"drawcraft-text",
|
||||
"drawcraft-tools",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -907,6 +910,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"drawcraft-doc",
|
||||
"drawcraft-geom",
|
||||
"drawcraft-testkit",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -916,6 +921,7 @@ dependencies = [
|
||||
name = "drawcraft-geom"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"drawcraft-testkit",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -933,8 +939,10 @@ dependencies = [
|
||||
"drawcraft-geom",
|
||||
"drawcraft-render",
|
||||
"drawcraft-svg",
|
||||
"drawcraft-testkit",
|
||||
"drawcraft-tools",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -943,6 +951,7 @@ name = "drawcraft-pathops"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"drawcraft-geom",
|
||||
"drawcraft-testkit",
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
"proptest",
|
||||
@@ -975,9 +984,11 @@ dependencies = [
|
||||
"drawcraft-doc",
|
||||
"drawcraft-effects",
|
||||
"drawcraft-geom",
|
||||
"drawcraft-testkit",
|
||||
"drawcraft-text",
|
||||
"image",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"vello_common 0.2.0",
|
||||
"vello_cpu 0.2.0",
|
||||
@@ -990,8 +1001,10 @@ dependencies = [
|
||||
"drawcraft-color",
|
||||
"drawcraft-doc",
|
||||
"drawcraft-geom",
|
||||
"drawcraft-testkit",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"svgtypes 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
"usvg 0.48.1",
|
||||
@@ -1001,8 +1014,17 @@ dependencies = [
|
||||
name = "drawcraft-testkit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"drawcraft-color",
|
||||
"drawcraft-doc",
|
||||
"drawcraft-engine",
|
||||
"drawcraft-format",
|
||||
"drawcraft-geom",
|
||||
"drawcraft-render",
|
||||
"drawcraft-svg",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5026,6 +5048,10 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"drawcraft-color",
|
||||
"drawcraft-doc",
|
||||
"drawcraft-geom",
|
||||
"drawcraft-render",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ kurbo = { version = "0.13", features = ["serde"] }
|
||||
peniko = "0.6"
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
proptest = "1"
|
||||
|
||||
|
||||
@@ -18,4 +18,5 @@ drawcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
drawcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,260 @@
|
||||
//! End-to-end MCP over stdio against the built `drawcraft-cli mcp --headless` binary.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
|
||||
use drawcraft_testkit::raster::Image;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_drawcraft-cli");
|
||||
|
||||
struct Mcp {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
next: u64,
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn spawn() -> Self {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["mcp", "--headless"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn drawcraft-cli");
|
||||
let stdin = child.stdin.take();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
Self { child, stdin, stdout, next: 1 }
|
||||
}
|
||||
fn send(&mut self, v: &Value) {
|
||||
let w = self.stdin.as_mut().unwrap();
|
||||
writeln!(w, "{v}").unwrap();
|
||||
w.flush().unwrap();
|
||||
}
|
||||
fn read(&mut self) -> Value {
|
||||
let mut line = String::new();
|
||||
let n = self.stdout.read_line(&mut line).unwrap();
|
||||
assert!(n > 0, "server closed stdout");
|
||||
serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad JSON line {line}: {e}"))
|
||||
}
|
||||
fn request(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
||||
let v = self.read();
|
||||
assert_eq!(v["jsonrpc"], "2.0");
|
||||
assert_eq!(v["id"], id, "reply to the wrong request: {v}");
|
||||
v
|
||||
}
|
||||
fn notify(&mut self, method: &str) {
|
||||
self.send(&json!({"jsonrpc": "2.0", "method": method}));
|
||||
}
|
||||
/// Call a tool; panics on protocol errors or tool errors.
|
||||
fn tool(&mut self, name: &str, args: Value) -> Value {
|
||||
let v = self.request("tools/call", json!({"name": name, "arguments": args}));
|
||||
assert!(v.get("error").is_none(), "{name}: {v}");
|
||||
let r = v["result"].clone();
|
||||
assert_ne!(r["isError"], json!(true), "{name} {args}: {r}");
|
||||
r
|
||||
}
|
||||
fn tool_json(&mut self, name: &str, args: Value) -> Value {
|
||||
let r = self.tool(name, args);
|
||||
let text: String =
|
||||
r["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect();
|
||||
serde_json::from_str(&text).unwrap_or(Value::String(text))
|
||||
}
|
||||
fn run(&mut self, command: &str, params: Value) -> Value {
|
||||
self.tool_json("run_command", json!({"command": command, "params": params}))
|
||||
}
|
||||
fn document(&mut self) -> Value {
|
||||
let v = self.request("resources/read", json!({"uri": "drawcraft://document/json"}));
|
||||
serde_json::from_str(v["result"]["contents"][0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
fn art_count(&mut self) -> usize {
|
||||
let d: drawcraft_testkit::doc::Document = serde_json::from_value(self.document()).unwrap();
|
||||
d.node_count() - d.layers.len()
|
||||
}
|
||||
fn initialize(&mut self) {
|
||||
let v =
|
||||
self.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "e2e", "version": "0"}}));
|
||||
let r = &v["result"];
|
||||
assert_eq!(r["serverInfo"]["name"], "drawcraft");
|
||||
assert!(r["protocolVersion"].is_string());
|
||||
assert!(r["capabilities"]["tools"].is_object());
|
||||
self.notify("notifications/initialized");
|
||||
}
|
||||
fn close(mut self) {
|
||||
drop(self.stdin.take());
|
||||
let status = self.child.wait().unwrap();
|
||||
assert!(status.success(), "server exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
drawcraft_testkit::temp_dir("cli-mcp-e2e").join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_and_list_tools_with_valid_schemas() {
|
||||
let mut m = Mcp::spawn();
|
||||
m.initialize();
|
||||
let v = m.request("tools/list", json!({}));
|
||||
let tools = v["result"]["tools"].as_array().unwrap().clone();
|
||||
assert!(tools.len() >= 15);
|
||||
for t in &tools {
|
||||
let name = t["name"].as_str().unwrap();
|
||||
assert!(!name.is_empty() && t["description"].as_str().is_some_and(|d| !d.is_empty()), "{t}");
|
||||
let s = &t["inputSchema"];
|
||||
assert_eq!(s["type"], "object", "{name}");
|
||||
let props = s["properties"].as_object().unwrap_or_else(|| panic!("{name}: no properties"));
|
||||
for r in s.get("required").and_then(Value::as_array).into_iter().flatten() {
|
||||
assert!(props.contains_key(r.as_str().unwrap()), "{name}: required {r} missing");
|
||||
}
|
||||
}
|
||||
for want in ["run_command", "draw_shape", "draw_path", "pointer_gesture", "screenshot", "export", "open_file", "undo", "redo", "inspect_document"]
|
||||
{
|
||||
assert!(tools.iter().any(|t| t["name"] == want), "missing tool {want}");
|
||||
}
|
||||
// ping and an unknown method.
|
||||
assert_eq!(m.request("ping", json!({}))["result"], json!({}));
|
||||
assert_eq!(m.request("no/such", json!({}))["error"]["code"], -32601);
|
||||
m.close();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_inspect_batch_undo_redo() {
|
||||
let mut m = Mcp::spawn();
|
||||
m.initialize();
|
||||
let base = m.art_count();
|
||||
let r =
|
||||
m.tool_json("draw_shape", json!({"shape": "rectangle", "x": 20, "y": 20, "width": 100, "height": 80, "fill": "#ff0000", "stroke": "none"}));
|
||||
assert!(r["id"].is_u64(), "{r}");
|
||||
m.tool_json("draw_path", json!({"points": [[200, 50], [300, 50], [250, 150]], "closed": true, "fill": [0, 0, 1]}));
|
||||
m.tool_json("draw_path", json!({"d": "M 50 300 C 100 250 150 350 200 300", "stroke": "#00aa00", "strokeWidth": 3}));
|
||||
m.tool("pointer_gesture", json!({"tool": "ellipse", "events": [
|
||||
{"kind": "down", "x": 300, "y": 300}, {"kind": "drag", "x": 350, "y": 340}, {"kind": "drag", "x": 400, "y": 380}, {"kind": "up", "x": 400, "y": 380}
|
||||
]}));
|
||||
assert_eq!(m.art_count(), base + 4);
|
||||
let inspect = m.tool_json("inspect_document", json!({}));
|
||||
let text = inspect.to_string();
|
||||
assert!(text.contains("Rectangle") || text.contains("rectangle"), "{text}");
|
||||
|
||||
// A batch is one undo step.
|
||||
let before = m.document();
|
||||
m.run(
|
||||
"command.batch",
|
||||
json!({"commands": [
|
||||
{"command": "shape.star", "params": {"cx": 100, "cy": 500, "radius1": 40, "radius2": 20}},
|
||||
{"command": "shape.polygon", "params": {"cx": 200, "cy": 500, "radius": 30}},
|
||||
{"command": "select.all", "params": {}},
|
||||
{"command": "object.group", "params": {}},
|
||||
]}),
|
||||
);
|
||||
let after = m.document();
|
||||
assert_ne!(before, after);
|
||||
m.tool("undo", json!({}));
|
||||
assert!(drawcraft_testkit::invariants::json_approx_eq(&m.document(), &before, 1e-12));
|
||||
m.tool("redo", json!({}));
|
||||
assert!(drawcraft_testkit::invariants::json_approx_eq(&m.document(), &after, 1e-12));
|
||||
|
||||
// Tool errors are reported in-band, and the server keeps working.
|
||||
let v = m.request("tools/call", json!({"name": "run_command", "arguments": {"command": "no.such.command"}}));
|
||||
assert_eq!(v["result"]["isError"], json!(true), "{v}");
|
||||
let v = m.request("tools/call", json!({"name": "draw_shape", "arguments": {"shape": "hexagram"}}));
|
||||
assert_eq!(v["result"]["isError"], json!(true));
|
||||
m.tool("inspect_document", json!({}));
|
||||
m.close();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_is_a_decodable_png() {
|
||||
let mut m = Mcp::spawn();
|
||||
m.initialize();
|
||||
m.tool("draw_shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100, "fill": "#ff0000", "stroke": "none"}));
|
||||
let path = tmp("shot.png");
|
||||
let r = m.tool("screenshot", json!({"scale": 0.5, "path": path}));
|
||||
let img = r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").expect("image content").clone();
|
||||
assert_eq!(img["mimeType"], "image/png");
|
||||
let png = drawcraft_testkit::format::base64_decode(img["data"].as_str().unwrap()).expect("base64");
|
||||
let decoded = Image::from_png(&png).expect("decodable PNG");
|
||||
let doc: drawcraft_testkit::doc::Document = serde_json::from_value(m.document()).unwrap();
|
||||
let ab = doc.artboards[0].rect;
|
||||
assert_eq!(decoded.width, (ab.width() * 0.5).round() as u32);
|
||||
assert_eq!(decoded.height, (ab.height() * 0.5).round() as u32);
|
||||
// The red square covers the top-left 50×50 px.
|
||||
let p = decoded.over_white(10, 10);
|
||||
assert!(p[0] > 240 && p[1] < 20 && p[2] < 20, "{p:?}");
|
||||
let q = decoded.over_white(decoded.width - 5, decoded.height - 5);
|
||||
assert_eq!(q, [255, 255, 255]);
|
||||
// The saved file is the same PNG.
|
||||
assert_eq!(Image::from_png(&std::fs::read(&path).unwrap()).unwrap(), decoded);
|
||||
m.close();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_formats_and_reopen() {
|
||||
let mut m = Mcp::spawn();
|
||||
m.initialize();
|
||||
m.tool("draw_shape", json!({"shape": "ellipse", "x": 50, "y": 50, "width": 200, "height": 120, "fill": "#3366ff"}));
|
||||
m.tool("draw_shape", json!({"shape": "star", "cx": 300, "cy": 300, "radius1": 80, "radius2": 40, "fill": "#ffcc00"}));
|
||||
let original = m.document();
|
||||
let n = m.art_count();
|
||||
|
||||
let dc = tmp("e2e.drawcraft");
|
||||
let svg = tmp("e2e.svg");
|
||||
let pdf = tmp("e2e.pdf");
|
||||
let png = tmp("e2e.png");
|
||||
m.tool("export", json!({"format": "drawcraft", "path": dc}));
|
||||
m.tool("export", json!({"path": svg}));
|
||||
m.tool("export", json!({"path": png, "scale": 0.25}));
|
||||
m.run("document.export", json!({"format": "pdf", "path": pdf}));
|
||||
assert!(std::fs::read(&pdf).unwrap().starts_with(b"%PDF"));
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
Image::from_png(&std::fs::read(&png).unwrap()).expect("exported PNG decodes");
|
||||
|
||||
// Re-open each: .drawcraft reproduces the document; SVG and PDF bring the art back.
|
||||
m.tool("open_file", json!({"path": dc}));
|
||||
assert!(drawcraft_testkit::invariants::json_approx_eq(&m.document(), &original, 1e-12));
|
||||
m.tool("open_file", json!({"path": svg}));
|
||||
assert!(m.art_count() >= n, "svg re-open lost art");
|
||||
m.run("document.open", json!({"path": pdf}));
|
||||
assert!(m.art_count() >= n, "pdf re-open lost art");
|
||||
let docs = m.tool_json("inspect_document", json!({}));
|
||||
assert!(docs.is_object());
|
||||
|
||||
// save_file writes the native format to a new path and it loads.
|
||||
let saved = tmp("saved.drawcraft");
|
||||
m.tool("save_file", json!({"path": saved}));
|
||||
let d = drawcraft_testkit::format::load(&std::fs::read(&saved).unwrap()).unwrap();
|
||||
drawcraft_testkit::invariants::check_document(&d).unwrap();
|
||||
m.close();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_requests_in_one_write_are_answered_in_order() {
|
||||
let mut m = Mcp::spawn();
|
||||
m.initialize();
|
||||
let mut batch = String::new();
|
||||
for i in 0..50u64 {
|
||||
batch.push_str(&json!({"jsonrpc": "2.0", "id": 1000 + i, "method": "tools/call", "params": {"name": "draw_shape", "arguments": {"shape": "rectangle", "x": i, "y": i, "width": 5, "height": 5}}}).to_string());
|
||||
batch.push('\n');
|
||||
}
|
||||
let w = m.stdin.as_mut().unwrap();
|
||||
w.write_all(batch.as_bytes()).unwrap();
|
||||
w.flush().unwrap();
|
||||
for i in 0..50u64 {
|
||||
let v = m.read();
|
||||
assert_eq!(v["id"], 1000 + i);
|
||||
assert_ne!(v["result"]["isError"], json!(true));
|
||||
}
|
||||
assert!(m.art_count() >= 50);
|
||||
m.close();
|
||||
}
|
||||
@@ -18,7 +18,7 @@ enum Handle {
|
||||
|
||||
pub struct NativeMenu {
|
||||
_menu: Menu,
|
||||
items: HashMap<String, (String, Value, Handle)>,
|
||||
items: HashMap<String, (String, Value, Handle, String)>,
|
||||
last_refresh: f64,
|
||||
}
|
||||
|
||||
@@ -43,15 +43,18 @@ impl NativeMenu {
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
app.native_menu = true;
|
||||
app.native_shortcuts =
|
||||
items.values().filter(|(c, p, _)| p.is_null() && menus::shortcut_of(c).and_then(accel).is_some()).map(|(c, _, _)| c.clone()).collect();
|
||||
app.native_shortcuts = items
|
||||
.values()
|
||||
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
|
||||
.map(|(_, _, _, c)| c.clone())
|
||||
.collect();
|
||||
Self { _menu: menu, items, last_refresh: 0.0 }
|
||||
}
|
||||
|
||||
/// Dispatch clicked items and refresh state.
|
||||
pub fn poll(&mut self, app: &mut DrawcraftApp) {
|
||||
while let Ok(ev) = MenuEvent::receiver().try_recv() {
|
||||
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
|
||||
if let Some((cmd, params, _, _)) = self.items.get(ev.id.as_ref()) {
|
||||
let p = if params.is_null() { serde_json::json!({}) } else { params.clone() };
|
||||
menus::invoke(app, cmd, p);
|
||||
}
|
||||
@@ -61,7 +64,9 @@ impl NativeMenu {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
for (cmd, params, h) in self.items.values() {
|
||||
for (run, rp, h, cmd) in self.items.values() {
|
||||
let null = Value::Null;
|
||||
let params = if run == cmd { rp } else { &null };
|
||||
let en = menus::enabled(app, cmd);
|
||||
match h {
|
||||
Handle::Plain(i) => {
|
||||
@@ -91,7 +96,7 @@ impl NativeMenu {
|
||||
}
|
||||
}
|
||||
|
||||
fn build(app: &DrawcraftApp, parent: &Submenu, entries: &[Item], items: &mut HashMap<String, (String, Value, Handle)>, counter: &mut usize) {
|
||||
fn build(app: &DrawcraftApp, parent: &Submenu, entries: &[Item], items: &mut HashMap<String, (String, Value, Handle, String)>, counter: &mut usize) {
|
||||
for e in entries {
|
||||
match e {
|
||||
Item::Sep => {
|
||||
@@ -121,7 +126,9 @@ fn build(app: &DrawcraftApp, parent: &Submenu, entries: &[Item], items: &mut Has
|
||||
let _ = parent.append(&i);
|
||||
Handle::Plain(i)
|
||||
};
|
||||
items.insert(id, (cmd.to_string(), params.clone(), handle));
|
||||
let (run, run_params) = menus::click_target(label, cmd, params);
|
||||
let _ = &run_params;
|
||||
items.insert(id, (run, run_params, handle, cmd.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 19.25H21" stroke-width="1.5" /><rect x="6" y="5" width="4.5" height="12" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="13.5" y="10" width="4.5" height="7" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 3V21" stroke-width="1.5" /><rect x="5" y="6" width="14" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="8" y="13.5" width="8" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 423 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4.75 3V21" stroke-width="1.5" /><rect x="7" y="6" width="12" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="7" y="13.5" width="7" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M19.25 3V21" stroke-width="1.5" /><rect x="5" y="6" width="12" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="10" y="13.5" width="7" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 4.75H21" stroke-width="1.5" /><rect x="6" y="7" width="4.5" height="12" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="13.5" y="7" width="4.5" height="7" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 12H21" stroke-width="1.5" /><rect x="6" y="5" width="4.5" height="14" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="13.5" y="8" width="4.5" height="8" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 423 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M7 5H14L18 9V19H7Z" fill="currentColor" fill-opacity="0.85" stroke="none"/><path d="M4 2V6M2 4H6" stroke-width="1.25" /></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="5" y="5" width="14" height="14" stroke-width="1.25" stroke-dasharray="2 2"/><rect x="9" y="9" width="8" height="8" stroke-width="2.5" /></svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="5" y="5" width="14" height="14" stroke-width="1.25" stroke-dasharray="2 2"/><rect x="3.5" y="3.5" width="3" height="3" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="17.5" y="3.5" width="3" height="3" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="3.5" y="17.5" width="3" height="3" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="17.5" y="17.5" width="3" height="3" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 666 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 5V19M6.5 13.5L12 19L17.5 13.5" stroke-width="1.6" /></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 19V5M6.5 10.5L12 5L17.5 10.5" stroke-width="1.6" /></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M6 4H14L18 8V20H6Z M14 4V8H18" stroke-width="1.25" /></svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3" y="8" width="13" height="8" fill="currentColor" fill-opacity="1.0" stroke="none"/><path d="M16 4V20" stroke-width="1" /></svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 8H12A4 4 0 0 1 12 16H3Z" fill="currentColor" fill-opacity="1.0" stroke="none"/><path d="M12 4V20" stroke-width="1" /></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3" y="8" width="17" height="8" fill="currentColor" fill-opacity="1.0" stroke="none"/><path d="M16 4V20" stroke-width="1" /></svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><circle cx="12" cy="12" r="7.5" stroke-width="2"/><path d="M6.7 6.7L17.3 17.3" stroke-width="2" /></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 3L20 7.5V16.5L12 21L4 16.5V7.5Z M4 7.5L12 12L20 7.5 M12 12V21" stroke-width="1.25" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 12V4H12M12 20H4V12M12 4H20V12M20 12V20H12" stroke-width="2.5" stroke-dasharray="4 8"/></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 20V4H20V20H4" stroke-width="2.5" stroke-dasharray="5 3"/></svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 9.25H21" stroke-width="1.5" /><path d="M3 19.25H21" stroke-width="1.5" /><rect x="7" y="4.5" width="10" height="4" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="5" y="13.5" width="14" height="5" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M7 3V21" stroke-width="1.5" /><path d="M16.5 3V21" stroke-width="1.5" /><rect x="5" y="7" width="4" height="10" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="14" y="5" width="5" height="14" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3" y="6" width="5" height="14" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="16" y="6" width="5" height="14" fill="currentColor" fill-opacity="1.0" stroke="none"/><path d="M9.5 3H14.5" stroke-width="1.25" /><path d="M9.5 2V4M14.5 2V4" stroke-width="1.25" /></svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4.75 3V21" stroke-width="1.5" /><path d="M13.75 3V21" stroke-width="1.5" /><rect x="5.5" y="7" width="4" height="10" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="14.5" y="5" width="5" height="14" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M9.25 3V21" stroke-width="1.5" /><path d="M19.25 3V21" stroke-width="1.5" /><rect x="4.5" y="7" width="4" height="10" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="13.5" y="5" width="5" height="14" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 4.75H21" stroke-width="1.5" /><path d="M3 13.75H21" stroke-width="1.5" /><rect x="7" y="5.5" width="10" height="4" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="5" y="14.5" width="14" height="5" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 7H21" stroke-width="1.5" /><path d="M3 16.5H21" stroke-width="1.5" /><rect x="7" y="5" width="10" height="4" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="5" y="14" width="14" height="5" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="6" y="3" width="14" height="5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="6" y="16" width="14" height="5" fill="currentColor" fill-opacity="1.0" stroke="none"/><path d="M3 9.5V14.5" stroke-width="1.25" /><path d="M2 9.5H4M2 14.5H4" stroke-width="1.25" /></svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 6.5A1 1 0 0 1 4 5.5H9.5L11.5 7.5H20A1 1 0 0 1 21 8.5V18.5A1 1 0 0 1 20 19.5H4A1 1 0 0 1 3 18.5Z" fill="currentColor" fill-opacity="0.9" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 352 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M13 4.5C11 3.5 9.6 4.4 9.2 6.6L7 19.5" stroke-width="1.6" /><path d="M5.5 9.5H11.5" stroke-width="1.5" /><path d="M12.5 11.5L17.5 18.5M17.5 11.5L12.5 18.5" stroke-width="1.6" /><path d="M18.5 20H22L20.25 22Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 3.5L21 19.5H3Z" stroke-width="1.5" stroke-linejoin="round"/><path d="M12 9V14" stroke-width="1.75" /><rect x="11.1" y="15.8" width="1.8" height="1.8" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 406 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3.5" y="3.5" width="17" height="17" stroke-width="1" /><circle cx="8" cy="8" r="2.2" fill="currentColor" stroke="none"/><circle cx="16" cy="10" r="2.2" fill="currentColor" stroke="none"/><circle cx="10" cy="16.5" r="2.2" fill="currentColor" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="white" stop-opacity="0.1"/><stop offset="1" stop-color="white"/></linearGradient></defs><rect x="3.5" y="3.5" width="17" height="17" fill="url(#g)" stroke="currentColor" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 449 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><defs><radialGradient id="g"><stop offset="0" stop-color="white" stop-opacity="0.1"/><stop offset="1" stop-color="white"/></radialGradient></defs><rect x="3.5" y="3.5" width="17" height="17" fill="url(#g)" stroke="currentColor" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4" y="4" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="4" y="10" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="4" y="16" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="10" y="4" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="10" y="10" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="10" y="16" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="16" y="4" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="16" y="10" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="16" y="16" width="4.5" height="4.5" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 21V9L9 3H21V10H10V21Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 278 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 21V3H21V10H10V21Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 21V10A7 7 0 0 1 10 3H21V10H10V21Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 290 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 6H20M4 10H20M4 14H20M4 18H20" stroke-width="1.75" /></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><circle cx="12" cy="12" r="7" stroke-width="2"/><path d="M7 7L17 17" stroke-width="2" /></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4.5" y="4.5" width="15" height="15" stroke-width="1.25" /><rect x="8" y="8" width="8" height="8" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4.5" y="4.5" width="15" height="15" stroke-width="1.25" /><path d="M12 8V16M8 12H16" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 304 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="5" y="5" width="14" height="14" stroke-width="3" /></svg>
|
||||
|
After Width: | Height: | Size: 249 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4" y="4" width="16" height="16" stroke-width="1.25" /><path d="M8 9H16M8 12H16M8 15H13" stroke-width="1.25" /></svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 5H21" stroke-width="1.5" /><path d="M6 9.5H18" stroke-width="1.5" /><path d="M3 14H21" stroke-width="1.5" /><path d="M7.5 18.5H16.5" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 5H21" stroke-width="1.5" /><path d="M3 9.5H21" stroke-width="1.5" /><path d="M3 14H21" stroke-width="1.5" /><path d="M3 18.5H21" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 5H21" stroke-width="1.5" /><path d="M3 9.5H21" stroke-width="1.5" /><path d="M3 14H21" stroke-width="1.5" /><path d="M7.5 18.5H16.5" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 5H21" stroke-width="1.5" /><path d="M3 9.5H21" stroke-width="1.5" /><path d="M3 14H21" stroke-width="1.5" /><path d="M3 18.5H12" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 5H21" stroke-width="1.5" /><path d="M3 9.5H21" stroke-width="1.5" /><path d="M3 14H21" stroke-width="1.5" /><path d="M12 18.5H21" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 352 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 5H21" stroke-width="1.5" /><path d="M3 9.5H15" stroke-width="1.5" /><path d="M3 14H21" stroke-width="1.5" /><path d="M3 18.5H12" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 5H21" stroke-width="1.5" /><path d="M9 9.5H21" stroke-width="1.5" /><path d="M3 14H21" stroke-width="1.5" /><path d="M12 18.5H21" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 352 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 3H14V10H10V14H3Z" fill="currentColor" fill-opacity="0.2" stroke="none"/><rect x="10.6" y="10.6" width="9.8" height="9.8" stroke-width="1.25" /><rect x="10" y="10" width="4" height="4" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 3H14V9.25H9.25V14H3Z" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="10.75" y="10.75" width="3.25" height="3.25" fill="currentColor" fill-opacity="1.0" stroke="none"/><path d="M14.75 10H21V21H10V14.75H14.75Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 480 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 3H14V10H21V21H10V14H3Z M10 10H14V14H10Z" fill="currentColor" fill-rule="evenodd" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3.6" y="3.6" width="9.8" height="9.8" stroke-width="1" /><rect x="10.6" y="10.6" width="9.8" height="9.8" stroke-width="1" /><rect x="10" y="10" width="4" height="4" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 418 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 3H14V9.25H9.25V14H3Z" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="10" y="10" width="11" height="11" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 374 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3.6" y="3.6" width="9.8" height="9.8" stroke-width="1.25" /><path d="M14 10H21V21H10V14H14Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 345 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 3H14V10H10V14H3Z" fill="currentColor" fill-opacity="1.0" stroke="none"/><rect x="10.6" y="10.6" width="9.8" height="9.8" stroke-width="1.25" /></svg>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3.5" y="3.5" width="10" height="10" stroke-width="1" /><rect x="10.5" y="10.5" width="10" height="10" stroke-width="1" /></svg>
|
||||
|
After Width: | Height: | Size: 319 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 3H14V9.25H9.25V14H3Z" fill="currentColor" fill-opacity="0.45" stroke="none"/><rect x="10" y="10" width="11" height="11" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 3H14V10H21V21H10V14H3Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 279 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4" y="10" width="10" height="10" stroke-width="1.25" /><path d="M9 3V13M5 9L9 13L13 9" stroke-width="1.4" /><circle cx="17.5" cy="6.5" r="3" stroke-width="1.25"/></svg>
|
||||
|
After Width: | Height: | Size: 360 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3.5" y="3.5" width="7" height="7" stroke-width="1.25" /><rect x="3.5" y="12.5" width="7" height="7" stroke-width="1.25" /><rect x="12.5" y="3.5" width="7" height="7" stroke-width="1.25" /><rect x="12.5" y="12.5" width="7" height="7" stroke-width="1.25" /></svg>
|
||||
|
After Width: | Height: | Size: 453 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3 16C7 8 11 20 16 10" stroke-width="2" /><path d="M15 15L21 21M21 15L15 21" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 8H19M15 4.5L19 8L15 11.5" stroke-width="1.5" /><path d="M20 16H5M9 12.5L5 16L9 19.5" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="7" y="7" width="10" height="10" stroke-width="4.5" stroke-opacity="0.55"/><rect x="7" y="7" width="10" height="10" stroke-width="1" /></svg>
|
||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="9.25" y="9.25" width="5.5" height="5.5" stroke-width="4.5" stroke-opacity="0.55"/><rect x="7" y="7" width="10" height="10" stroke-width="1" /></svg>
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4.75" y="4.75" width="14.5" height="14.5" stroke-width="4.5" stroke-opacity="0.55"/><rect x="7" y="7" width="10" height="10" stroke-width="1" /></svg>
|
||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="3" y="3" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.9" stroke="none"/><rect x="3" y="9" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.45" stroke="none"/><rect x="3" y="15" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.9" stroke="none"/><rect x="9" y="3" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.45" stroke="none"/><rect x="9" y="9" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.9" stroke="none"/><rect x="9" y="15" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.45" stroke="none"/><rect x="15" y="3" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.9" stroke="none"/><rect x="15" y="9" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.45" stroke="none"/><rect x="15" y="15" width="4.5" height="4.5" fill="currentColor" fill-opacity="0.9" stroke="none"/><path d="M18 20H22L20 22.5Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M2 12C8 5 16 5 22 12C16 19 8 19 2 12Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M2 20L9 9L13.5 15.5L16 12.5L22 20Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 18L9 11L12 15L14 13L18 18Z" fill="currentColor" fill-opacity="1.0" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 283 B |
@@ -24,3 +24,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
drawcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Edit → Edit Colors: invert, convert to CMYK/Grayscale/RGB, saturate, adjust colour balance and the
|
||||
//! three Blend commands. They recolour fills and strokes (solid colours, gradient stops and text
|
||||
//! runs) of the selected objects and everything inside them. Colours linked to a global swatch are
|
||||
//! unlinked when they change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_color::{Color, Paint};
|
||||
use drawcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"edit.colors.invert",
|
||||
"Invert Colors",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{fill?: true, stroke?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) → {changed}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "Invert Colors", &|c| keep_model(c, c.invert()))
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.toCMYK",
|
||||
"Convert to CMYK",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{fill?, stroke?, ids?} → {changed}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "Convert to CMYK", &to_cmyk)
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.toGrayscale",
|
||||
"Convert to Grayscale",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{fill?, stroke?, ids?} → {changed}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "Convert to Grayscale", &to_gray)
|
||||
),
|
||||
cmd!("edit.colors.toRGB", "Convert to RGB", ["Edit", "Edit Colors"], None, "{fill?, stroke?, ids?} → {changed}", has_selection, |s, p| {
|
||||
recolor(s, p, "Convert to RGB", &|c| {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
Color::rgb(r, g, b)
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"edit.colors.saturate",
|
||||
"Saturate…",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{intensity: -100..100 (%), fill?, stroke?, ids?} scale saturation by (1 + intensity/100) → {changed}",
|
||||
has_selection,
|
||||
saturate
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.adjustBalance",
|
||||
"Adjust Color Balance…",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel), convert?: false (keep the result in the adjusted model), fill?, stroke?, ids?} → {changed}",
|
||||
has_selection,
|
||||
adjust_balance
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.blendFrontToBack",
|
||||
"Blend Front to Back",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects: intermediate fills graded between the frontmost and backmost fill → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Stack)
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.blendHorizontally",
|
||||
"Blend Horizontally",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects: fills graded between the leftmost and rightmost → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Horizontal)
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.blendVertically",
|
||||
"Blend Vertically",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{} ≥3 filled objects: fills graded between the topmost and bottommost → {changed}",
|
||||
has_selection,
|
||||
|s, _| blend(s, BlendOrder::Vertical)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Express `new` in the colour model of `orig`.
|
||||
fn keep_model(orig: Color, new: Color) -> Color {
|
||||
match orig {
|
||||
Color::Rgb { .. } => {
|
||||
let [r, g, b] = new.to_rgb();
|
||||
Color::rgb(r, g, b)
|
||||
}
|
||||
Color::Cmyk { .. } => to_cmyk(new),
|
||||
Color::Gray { .. } => to_gray(new),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_cmyk(c: Color) -> Color {
|
||||
let [c, m, y, k] = c.to_cmyk();
|
||||
Color::cmyk(c, m, y, k)
|
||||
}
|
||||
|
||||
pub(crate) fn to_gray(c: Color) -> Color {
|
||||
if let Color::Gray { .. } = c {
|
||||
return c;
|
||||
}
|
||||
let [r, g, b] = c.to_rgb();
|
||||
Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Apply `f` to a paint; returns whether anything changed.
|
||||
fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
|
||||
match p {
|
||||
Paint::Solid { color, swatch } => {
|
||||
let n = f(*color);
|
||||
if n != *color {
|
||||
*color = n;
|
||||
*swatch = None;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
Paint::Gradient(g) => {
|
||||
let mut ch = false;
|
||||
for st in &mut g.gradient.stops {
|
||||
let n = f(st.color);
|
||||
if n != st.color {
|
||||
st.color = n;
|
||||
ch = true;
|
||||
}
|
||||
}
|
||||
if ch {
|
||||
g.swatch = None;
|
||||
}
|
||||
ch
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recolour a node and its descendants. Returns the number of paints changed.
|
||||
pub(crate) fn map_node_colors(n: &mut Node, f: &dyn Fn(Color) -> Color, fill: bool, stroke: bool) -> usize {
|
||||
let mut count = 0;
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) if fill => count += map_paint(&mut l.paint, f) as usize,
|
||||
AppearanceItem::Stroke(l) if stroke => count += map_paint(&mut l.paint, f) as usize,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
if fill {
|
||||
count += map_paint(&mut r.style.fill, f) as usize;
|
||||
}
|
||||
if stroke {
|
||||
count += map_paint(&mut r.style.stroke, f) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
count += map_node_colors(Arc::make_mut(c), f, fill, stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn recolor_ids(s: &mut Session, label: &str, ids: Vec<NodeId>, fill: bool, stroke: bool, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
let n = s.edit(label, |d, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
if let Some(node) = d.node_mut(*id) {
|
||||
n += map_node_colors(node, f, fill, stroke);
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
}
|
||||
|
||||
fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
recolor_ids(s, label, ids, bool_or(p, "fill", true), bool_or(p, "stroke", true), f)
|
||||
}
|
||||
|
||||
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = (f64_or(p, "intensity", 0.0).clamp(-100.0, 100.0) / 100.0) as f32;
|
||||
recolor(s, p, "Saturate", &move |c| {
|
||||
let [h, sat, v] = c.to_hsb();
|
||||
if sat <= 0.0 {
|
||||
return c;
|
||||
}
|
||||
keep_model(c, Color::from_hsb(h, (sat * (1.0 + i)).clamp(0.0, 1.0), v))
|
||||
})
|
||||
}
|
||||
|
||||
fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = |k: &str| (f64_or(p, k, 0.0).clamp(-100.0, 100.0) / 100.0) as f32;
|
||||
let convert = bool_or(p, "convert", false);
|
||||
let cmyk = ["c", "m", "y", "k"].iter().any(|k| p.get(*k).is_some());
|
||||
let gray = p.get("gray").is_some();
|
||||
let rgb = ["r", "g", "b"].iter().any(|k| p.get(*k).is_some());
|
||||
if !(cmyk || gray || rgb) {
|
||||
return Err(bad("edit.colors.adjustBalance", "give r/g/b, c/m/y/k or gray adjustments"));
|
||||
}
|
||||
let (dr, dg, db) = (g("r"), g("g"), g("b"));
|
||||
let (dc, dm, dy, dk) = (g("c"), g("m"), g("y"), g("k"));
|
||||
let dgray = g("gray");
|
||||
let cl = |v: f32| v.clamp(0.0, 1.0);
|
||||
recolor(s, p, "Adjust Colors", &move |c| {
|
||||
let out = if cmyk {
|
||||
let [cc, m, y, k] = c.to_cmyk();
|
||||
Color::cmyk(cl(cc + dc), cl(m + dm), cl(y + dy), cl(k + dk))
|
||||
} else if gray {
|
||||
let Color::Gray { k } = to_gray(c) else { unreachable!() };
|
||||
Color::gray(cl(k + dgray))
|
||||
} else {
|
||||
let [r, gg, b] = c.to_rgb();
|
||||
Color::rgb(cl(r + dr), cl(gg + dg), cl(b + db))
|
||||
};
|
||||
if convert { out } else { keep_model(c, out) }
|
||||
})
|
||||
}
|
||||
|
||||
enum BlendOrder {
|
||||
Stack,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Leaf objects with a solid fill among the selection (paint order).
|
||||
fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
fn visit(n: &Node, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children {
|
||||
visit(c, out);
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
if t.first_style().fill.color().is_some() {
|
||||
out.push(n.id);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if n.appearance.fill_paint().color().is_some() {
|
||||
out.push(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(*r) {
|
||||
visit(n, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fill_color(n: &Node) -> Option<Color> {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => t.first_style().fill.color(),
|
||||
_ => n.appearance.fill_paint().color(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpolate within the endpoints' model when they share one.
|
||||
pub(crate) fn lerp_model(a: Color, b: Color, t: f32) -> Color {
|
||||
let l = |x: f32, y: f32| x + (y - x) * t;
|
||||
match (a, b) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
|
||||
_ => a.lerp(&b, t),
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut ids = filled_leaves(d, &roots);
|
||||
if ids.len() < 3 {
|
||||
return Err(EngineError::Other("Blend colors: select at least three objects with filled colours".into()));
|
||||
}
|
||||
let center = |id: &NodeId| d.node(*id).and_then(|n| n.geometric_bounds()).map(|b| b.center()).unwrap_or_default();
|
||||
match order {
|
||||
BlendOrder::Stack => {
|
||||
// Front to back: frontmost first.
|
||||
ids.reverse();
|
||||
}
|
||||
BlendOrder::Horizontal => ids.sort_by(|a, b| center(a).x.total_cmp(¢er(b).x)),
|
||||
BlendOrder::Vertical => ids.sort_by(|a, b| center(a).y.total_cmp(¢er(b).y)),
|
||||
}
|
||||
let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default();
|
||||
let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default();
|
||||
let n = ids.len();
|
||||
let label = match order {
|
||||
BlendOrder::Stack => "Blend Front to Back",
|
||||
BlendOrder::Horizontal => "Blend Horizontally",
|
||||
BlendOrder::Vertical => "Blend Vertically",
|
||||
};
|
||||
let changed = s.edit(label, |d, _| {
|
||||
let mut changed = 0;
|
||||
for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) {
|
||||
let c = lerp_model(first, last, i as f32 / (n - 1) as f32);
|
||||
if let Some(node) = d.node_mut(*id) {
|
||||
changed += map_node_colors(node, &|_| c, true, false);
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
@@ -26,7 +26,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
path_create
|
||||
),
|
||||
cmd!("view.drawMode", "Drawing Mode", [], Some("Shift+D"), "{mode?: normal|behind|inside} (no param cycles; inside needs one selected path)", has_doc, draw_mode),
|
||||
cmd!(
|
||||
"view.drawMode",
|
||||
"Drawing Mode",
|
||||
[],
|
||||
Some("Shift+D"),
|
||||
"{mode?: normal|behind|inside} (no param cycles; inside needs one selected path)",
|
||||
has_doc,
|
||||
draw_mode
|
||||
),
|
||||
cmd!(
|
||||
"text.create",
|
||||
"Create Text",
|
||||
@@ -108,7 +116,10 @@ fn draw_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
if mode == crate::DrawMode::Inside {
|
||||
let st = s.doc()?;
|
||||
let target = st.selection.objects.first().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_))));
|
||||
let target =
|
||||
st.selection.objects.first().copied().filter(|id| {
|
||||
matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)))
|
||||
});
|
||||
match target {
|
||||
Some(t) if st.selection.len() == 1 => s.draw_inside = Some(t),
|
||||
_ => return Err(bad("view.drawMode", "Draw Inside needs exactly one selected path, compound path or text")),
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
//! Select menu long tail (Same → text/symbol attributes, Object → Direction Handles / text kinds /
|
||||
//! brush strokes, Save/recall selections), View → Guides, and File → Close All / Document Color
|
||||
//! Mode / File Info.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::colorcmds::{map_node_colors, to_cmyk};
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("select.same.symbolInstance", "Symbol Instance", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same(
|
||||
s,
|
||||
"select.same.symbolInstance",
|
||||
|a, b| match (&a.kind, &b.kind) {
|
||||
(NodeKind::SymbolInstance { symbol: x, .. }, NodeKind::SymbolInstance { symbol: y, .. }) => x == y,
|
||||
_ => false,
|
||||
}
|
||||
)),
|
||||
cmd!("select.same.fontFamily", "Font Family", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
|
||||
s,
|
||||
"select.same.fontFamily",
|
||||
|a, b| a.font_family == b.font_family
|
||||
)),
|
||||
cmd!("select.same.fontFamilyStyle", "Font Family & Style", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
|
||||
s,
|
||||
"select.same.fontFamilyStyle",
|
||||
|a, b| a.font_family == b.font_family && a.font_style == b.font_style
|
||||
)),
|
||||
cmd!("select.same.fontFamilyStyleSize", "Font Family, Style & Size", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| {
|
||||
same_text(s, "select.same.fontFamilyStyleSize", |a, b| {
|
||||
a.font_family == b.font_family && a.font_style == b.font_style && (a.size - b.size).abs() < 1e-6
|
||||
})
|
||||
}),
|
||||
cmd!("select.same.fontSize", "Font Size", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
|
||||
s,
|
||||
"select.same.fontSize",
|
||||
|a, b| (a.size - b.size).abs() < 1e-6
|
||||
)),
|
||||
cmd!("select.same.textFillColor", "Text Fill Color", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
|
||||
s,
|
||||
"select.same.textFillColor",
|
||||
|a, b| a.fill == b.fill
|
||||
)),
|
||||
cmd!("select.same.textStrokeColor", "Text Stroke Color", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
|
||||
s,
|
||||
"select.same.textStrokeColor",
|
||||
|a, b| a.stroke == b.stroke
|
||||
)),
|
||||
cmd!(
|
||||
"select.object.directionHandles",
|
||||
"Direction Handles",
|
||||
["Select", "Object"],
|
||||
None,
|
||||
"{} direct-select every anchor (showing all handles) of the selected paths → {anchors}",
|
||||
has_selection,
|
||||
direction_handles
|
||||
),
|
||||
cmd!("select.object.pointText", "Point Text Objects", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(
|
||||
s,
|
||||
|n| matches!(
|
||||
&n.kind,
|
||||
NodeKind::Text(t) if matches!(t.kind, TextKind::Point)
|
||||
)
|
||||
)),
|
||||
cmd!("select.object.areaText", "Area Text Objects", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(s, |n| matches!(
|
||||
&n.kind,
|
||||
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. })
|
||||
))),
|
||||
cmd!("select.object.brushStrokes", "Brush Strokes", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(s, |n| n
|
||||
.appearance
|
||||
.stroke()
|
||||
.is_some_and(|st| st.brush.is_some()))),
|
||||
cmd!(
|
||||
"select.object.bristleBrushStrokes",
|
||||
"Bristle Brush Strokes",
|
||||
["Select", "Object"],
|
||||
None,
|
||||
"{} objects whose brush name contains \"bristle\" → {count}",
|
||||
has_doc,
|
||||
|s, _| by_kind(s, |n| n
|
||||
.appearance
|
||||
.stroke()
|
||||
.and_then(|st| st.brush.as_deref())
|
||||
.is_some_and(|b| b.to_ascii_lowercase().contains("bristle")))
|
||||
),
|
||||
cmd!(
|
||||
"select.save",
|
||||
"Save Selection…",
|
||||
["Select"],
|
||||
None,
|
||||
"{name?} remember the current selection for this session (default name \"Selection N\") → {name}",
|
||||
has_selection,
|
||||
save_selection
|
||||
),
|
||||
cmd!("select.recall", "Recall Selection", ["Select"], None, "{name} → {count}", has_doc, recall_selection),
|
||||
cmd!("select.editSaved", "Edit Selection…", ["Select"], None, "{name, newName?: rename, delete?: bool}", has_doc, edit_saved),
|
||||
cmd!(query "select.savedList", "Saved Selections", [], None, "{} → [name…] for the active document", has_doc, |s, _| {
|
||||
let t = s.doc()?.title();
|
||||
Ok(json!(s.menu.saved_selections.iter().filter(|x| x.0 == t).map(|x| x.1.clone()).collect::<Vec<_>>()))
|
||||
}),
|
||||
cmd!(
|
||||
"view.guides.make",
|
||||
"Make Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+5"),
|
||||
"{} turn the selected paths into guides → {count}",
|
||||
has_selection,
|
||||
make_guides
|
||||
),
|
||||
cmd!(
|
||||
"view.guides.release",
|
||||
"Release Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+5"),
|
||||
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
|
||||
has_doc,
|
||||
release_guides
|
||||
),
|
||||
cmd!(
|
||||
query "view.guides.lock",
|
||||
"Lock Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+;"),
|
||||
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
|
||||
has_doc,
|
||||
lock_guides
|
||||
),
|
||||
cmd!(
|
||||
"view.guides.clear",
|
||||
"Clear Guides",
|
||||
["View", "Guides"],
|
||||
None,
|
||||
"{} delete all ruler guides and guide paths → {count}",
|
||||
has_doc,
|
||||
clear_guides
|
||||
),
|
||||
cmd!("guide.add", "Add Guide", [], None, "{vertical: bool, pos: pt (x for vertical, y for horizontal)} → {index}", has_doc, guide_add),
|
||||
cmd!("guide.remove", "Remove Guide", [], None, "{index}", guides_unlocked, guide_remove),
|
||||
cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", guides_unlocked, guide_move),
|
||||
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
|
||||
cmd!(
|
||||
"file.documentColorMode",
|
||||
"Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour and swatch to the mode)}",
|
||||
has_doc,
|
||||
color_mode
|
||||
),
|
||||
cmd!(
|
||||
"file.info",
|
||||
"File Info…",
|
||||
["File"],
|
||||
Some("Cmd+Alt+Shift+I"),
|
||||
"{title?} set the document title; always → {title, colorMode, units, artboards, objects}",
|
||||
has_doc,
|
||||
file_info
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// View → Guides → Lock Guides state (canvas guide dragging should honour it).
|
||||
pub fn guides_locked(&self) -> bool {
|
||||
self.menu.guides_locked
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Select ----------
|
||||
|
||||
fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let mut ids = vec![];
|
||||
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
|
||||
l.walk(&mut |n| {
|
||||
if !n.is_container() && n.visible && !n.locked && f(n) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
|
||||
let n = ids.len();
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({})));
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let r = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let ids = candidates(s, |n| eq(n, &r))?;
|
||||
finish_same(s, cmd, ids)
|
||||
}
|
||||
|
||||
fn first_text_style(s: &Session) -> Option<CharStyle> {
|
||||
let st = s.active()?;
|
||||
let mut out = None;
|
||||
for id in &st.selection.objects {
|
||||
st.doc.node(*id)?.walk(&mut |n| {
|
||||
if out.is_none()
|
||||
&& let NodeKind::Text(t) = &n.kind
|
||||
{
|
||||
out = Some(t.first_style());
|
||||
}
|
||||
});
|
||||
if out.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool) -> Result<Value> {
|
||||
let r = first_text_style(s).ok_or_else(|| bad(cmd, "select a text object"))?;
|
||||
let ids = candidates(s, |n| match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|run| eq(&run.style, &r)),
|
||||
_ => false,
|
||||
})?;
|
||||
finish_same(s, cmd, ids)
|
||||
}
|
||||
|
||||
fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
|
||||
let ids = candidates(s, f)?;
|
||||
let n = ids.len();
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = vec![];
|
||||
for r in &roots {
|
||||
if let Some(n) = d.node(*r) {
|
||||
n.walk(&mut |c| {
|
||||
if let Some(p) = c.path_data() {
|
||||
items.push((c.id, p.anchors().map(|(si, ai, _)| (si, ai)).collect()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let total: usize = items.iter().map(|x| x.1.len()).sum();
|
||||
s.select(|_, sel| {
|
||||
sel.clear();
|
||||
for (id, refs) in items {
|
||||
sel.add(id);
|
||||
sel.anchors.insert(id, refs);
|
||||
}
|
||||
})?;
|
||||
Ok(json!({ "anchors": total }))
|
||||
}
|
||||
|
||||
fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let title = st.title();
|
||||
let ids = st.selection.objects.clone();
|
||||
let name = match str_param(p, "name").filter(|n| !n.trim().is_empty()) {
|
||||
Some(n) => n.trim().to_string(),
|
||||
None => {
|
||||
let mut i = 1;
|
||||
while s.menu.saved_selections.iter().any(|x| x.0 == title && x.1 == format!("Selection {i}")) {
|
||||
i += 1;
|
||||
}
|
||||
format!("Selection {i}")
|
||||
}
|
||||
};
|
||||
s.menu.saved_selections.retain(|x| !(x.0 == title && x.1 == name));
|
||||
s.menu.saved_selections.push((title, name.clone(), ids));
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn saved_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?;
|
||||
let t = s.doc()?.title();
|
||||
s.menu.saved_selections.iter().position(|x| x.0 == t && x.1 == name).ok_or_else(|| bad(cmd, format!("no saved selection `{name}`")))
|
||||
}
|
||||
|
||||
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = saved_index(s, p, "select.recall")?;
|
||||
let ids = s.menu.saved_selections[i].2.clone();
|
||||
s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?;
|
||||
Ok(json!({ "count": s.doc()?.selection.len() }))
|
||||
}
|
||||
|
||||
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = saved_index(s, p, "select.editSaved")?;
|
||||
if bool_or(p, "delete", false) {
|
||||
s.menu.saved_selections.remove(i);
|
||||
} else if let Some(n) = str_param(p, "newName").filter(|n| !n.trim().is_empty()) {
|
||||
s.menu.saved_selections[i].1 = n.trim().to_string();
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
// ---------- Guides ----------
|
||||
|
||||
fn guides_unlocked(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.menu.guides_locked { Err("guides are locked".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn set_guide_flag(n: &mut Node, on: bool, count: &mut usize) {
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { guide, clipping: false, .. } => {
|
||||
if *guide != on {
|
||||
*guide = on;
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
set_guide_flag(Arc::make_mut(c), on, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let n = s.edit("Make Guides", |d, sel| {
|
||||
let mut n = 0;
|
||||
for r in &roots {
|
||||
if let Some(node) = d.node_mut(*r) {
|
||||
set_guide_flag(node, true, &mut n);
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(EngineError::Other("Make Guides: select paths".into()));
|
||||
}
|
||||
sel.clear();
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn guide_paths(d: &drawcraft_doc::Document) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. }) {
|
||||
v.push(n.id);
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
|
||||
if targets.is_empty() {
|
||||
return Err(EngineError::Other("Release Guides: no guides".into()));
|
||||
}
|
||||
let n = targets.len();
|
||||
s.edit("Release Guides", |d, sel| {
|
||||
for id in &targets {
|
||||
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
*guide = false;
|
||||
}
|
||||
}
|
||||
sel.set(targets.iter().copied());
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
|
||||
s.menu.guides_locked = v;
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let paths = guide_paths(&st.doc);
|
||||
let n = paths.len() + st.doc.guides.len();
|
||||
if n == 0 {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Clear Guides", |d, _| {
|
||||
d.guides.clear();
|
||||
for id in &paths {
|
||||
let _ = d.remove(*id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pos = f64_req(p, "pos", "guide.add")?;
|
||||
let vertical = bool_or(p, "vertical", false);
|
||||
let i = s.edit("New Guide", |d, _| {
|
||||
d.guides.push(Guide { vertical, pos });
|
||||
Ok(d.guides.len() - 1)
|
||||
})?;
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize;
|
||||
if i >= s.doc()?.doc.guides.len() {
|
||||
return Err(bad(cmd, "no such guide"));
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = guide_index(s, p, "guide.remove")?;
|
||||
s.edit("Delete Guide", |d, _| {
|
||||
d.guides.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = guide_index(s, p, "guide.move")?;
|
||||
let pos = f64_req(p, "pos", "guide.move")?;
|
||||
s.edit("Move Guide", |d, _| {
|
||||
d.guides[i].pos = pos;
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
// ---------- File ----------
|
||||
|
||||
fn close_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let n = s.documents().len();
|
||||
while !s.documents().is_empty() {
|
||||
s.close_document(s.documents().len() - 1);
|
||||
}
|
||||
Ok(json!({ "closed": n }))
|
||||
}
|
||||
|
||||
fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.documentColorMode";
|
||||
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cmyk") => ColorMode::Cmyk,
|
||||
Some("rgb") => ColorMode::Rgb,
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let convert = bool_or(p, "convert", true);
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return ok();
|
||||
}
|
||||
let f = move |c: drawcraft_color::Color| match mode {
|
||||
ColorMode::Cmyk => to_cmyk(c),
|
||||
ColorMode::Rgb => {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
drawcraft_color::Color::rgb(r, g, b)
|
||||
}
|
||||
};
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
d.color_mode = mode;
|
||||
if convert {
|
||||
for l in &mut d.layers {
|
||||
map_node_colors(Arc::make_mut(l), &f, true, true);
|
||||
}
|
||||
for sw in &mut d.swatches {
|
||||
if let drawcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
|
||||
*color = f(*color);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(t) = str_param(p, "title") {
|
||||
let t = t.to_string();
|
||||
s.edit("File Info", |d, _| {
|
||||
d.title = t;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(json!({
|
||||
"title": d.title,
|
||||
"colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" },
|
||||
"units": d.units.label(),
|
||||
"artboards": d.artboards.len(),
|
||||
"objects": d.node_count(),
|
||||
}))
|
||||
}
|
||||
@@ -146,7 +146,17 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
|
||||
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let sel = st.selection.in_paint_order(&st.doc);
|
||||
// A selected compound-path member acts as its compound (Illustrator treats compounds as one object).
|
||||
let mut sel: Vec<NodeId> = st
|
||||
.selection
|
||||
.in_paint_order(&st.doc)
|
||||
.into_iter()
|
||||
.map(|id| match st.doc.parent_of(id).and_then(|p| st.doc.node(p).map(|n| (p, n))) {
|
||||
Some((p, n)) if matches!(n.kind, drawcraft_doc::NodeKind::Compound { .. }) => p,
|
||||
_ => id,
|
||||
})
|
||||
.collect();
|
||||
sel.dedup();
|
||||
Ok(sel
|
||||
.iter()
|
||||
.copied()
|
||||
|
||||
@@ -112,6 +112,8 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
s.begin_interaction(&label)?;
|
||||
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
|
||||
let mut results = vec![];
|
||||
for c in &cmds {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
@@ -124,6 +126,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(v) => results.push(v),
|
||||
Err(e) => {
|
||||
s.cancel_interaction()?;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
|
||||
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! The command registry. Ids follow Illustrator's menu structure.
|
||||
|
||||
mod colorcmds;
|
||||
mod create;
|
||||
mod docmenu;
|
||||
mod draw2;
|
||||
mod edit;
|
||||
mod effectcmd;
|
||||
mod file;
|
||||
mod layer;
|
||||
pub(crate) mod menucmds;
|
||||
mod object;
|
||||
mod paint;
|
||||
mod panelcmds;
|
||||
mod path;
|
||||
mod pathops;
|
||||
mod select;
|
||||
mod typecmd;
|
||||
mod typemenu;
|
||||
mod xform;
|
||||
|
||||
use drawcraft_color::Color;
|
||||
@@ -123,6 +128,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(effectcmd::specs());
|
||||
v.extend(pathops::specs());
|
||||
v.extend(typecmd::specs());
|
||||
v.extend(menucmds::specs());
|
||||
v.extend(colorcmds::specs());
|
||||
v.extend(typemenu::specs());
|
||||
v.extend(docmenu::specs());
|
||||
v.extend(panelcmds::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -269,17 +269,31 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
Arrange::Back => "Send to Back",
|
||||
};
|
||||
s.edit(label, |d, _| {
|
||||
let n = ids.len();
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
let Some((par, idx, len)) = d.position(*id) else { continue };
|
||||
let to = match how {
|
||||
Arrange::Front => len - 1 - k,
|
||||
Arrange::Back => k,
|
||||
Arrange::Forward => (idx + 1).min(len - 1 - k.min(len - 1)),
|
||||
Arrange::Backward => idx.saturating_sub(1).max(k.min(n)),
|
||||
};
|
||||
if to != idx {
|
||||
d.move_node(*id, par, to)?;
|
||||
// Arrange within each parent independently (selections may span layers/groups).
|
||||
let mut by_parent: Vec<(Option<drawcraft_doc::NodeId>, Vec<drawcraft_doc::NodeId>)> = vec![];
|
||||
for id in &ids {
|
||||
let par = d.parent_of(*id);
|
||||
match by_parent.iter_mut().find(|(p, _)| *p == par) {
|
||||
Some((_, v)) => v.push(*id),
|
||||
None => by_parent.push((par, vec![*id])),
|
||||
}
|
||||
}
|
||||
for (par, group) in by_parent {
|
||||
for (k, id) in group.iter().enumerate() {
|
||||
let Some((_, idx, len)) = d.position(*id) else { continue };
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
let last = len - 1;
|
||||
let to = match how {
|
||||
Arrange::Front => last.saturating_sub(k),
|
||||
Arrange::Back => k.min(last),
|
||||
Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx),
|
||||
Arrange::Backward => idx.saturating_sub(1).max(k.min(idx)),
|
||||
};
|
||||
if to != idx {
|
||||
d.move_node(*id, par, to)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
//! Commands the panels need beyond the core set: in-place gradient editing (Gradient panel),
|
||||
//! appearance item duplicate/reorder, artboard reorder/duplicate, swatch groups, graphic style
|
||||
//! management and the advanced Character/Paragraph attributes.
|
||||
|
||||
use drawcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint, SwatchGroup};
|
||||
use drawcraft_doc::{Document, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"paint.editGradient",
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
cmd!(
|
||||
"stroke.setAdvanced",
|
||||
"Stroke Options",
|
||||
[],
|
||||
None,
|
||||
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras",
|
||||
has_doc,
|
||||
stroke_advanced
|
||||
),
|
||||
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item),
|
||||
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item),
|
||||
cmd!("artboard.reorder", "Move Artboard Up/Down", [], None, "{index, to} change an artboard's number", has_doc, artboard_reorder),
|
||||
cmd!(
|
||||
"artboard.duplicate",
|
||||
"Duplicate Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index} copy placed right of the last artboard",
|
||||
has_doc,
|
||||
artboard_duplicate
|
||||
),
|
||||
cmd!(
|
||||
"swatch.newGroup",
|
||||
"New Color Group",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{name?, swatches?: [names] (moved into the group), colors?: [colour] (added as new swatches)}",
|
||||
has_doc,
|
||||
swatch_new_group
|
||||
),
|
||||
cmd!("swatch.duplicate", "Duplicate Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_duplicate),
|
||||
cmd!("swatch.sortByName", "Sort by Name", ["Window", "Swatches"], None, "{}", has_doc, swatch_sort),
|
||||
cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete),
|
||||
cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate),
|
||||
cmd!(
|
||||
"text.setFormat",
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- gradient ----------
|
||||
|
||||
/// Leaf paint targets (groups apply to their children), like the Swatches/Color panels.
|
||||
fn leaf_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let Some(n) = d.node(id) else { continue };
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| {
|
||||
if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) {
|
||||
out.push(c.id)
|
||||
}
|
||||
}),
|
||||
_ => out.push(id),
|
||||
}
|
||||
}
|
||||
let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect();
|
||||
out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c)));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Apply the gradient edits in `p` to `paint` (pure; unit-tested).
|
||||
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<drawcraft_geom::Rect>) -> std::result::Result<Paint, String> {
|
||||
let mut gp = match paint {
|
||||
Paint::Gradient(g) => (**g).clone(),
|
||||
_ => GradientPaint::new(Gradient::default()),
|
||||
};
|
||||
if let Some(k) = str_param(p, "kind") {
|
||||
let kind = match k.to_ascii_lowercase().as_str() {
|
||||
"linear" => GradientKind::Linear,
|
||||
"radial" => GradientKind::Radial,
|
||||
"freeform" => GradientKind::Freeform,
|
||||
_ => return Err(format!("unknown gradient kind `{k}`")),
|
||||
};
|
||||
if kind != gp.gradient.kind {
|
||||
gp.gradient.kind = kind;
|
||||
gp.geom = None;
|
||||
}
|
||||
}
|
||||
if let Some(stops) = p.get("stops") {
|
||||
let arr = stops.as_array().ok_or("`stops` must be an array")?;
|
||||
let mut out = Vec::with_capacity(arr.len());
|
||||
for st in arr {
|
||||
let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?;
|
||||
let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?;
|
||||
let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0);
|
||||
let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5);
|
||||
out.push(GradientStop {
|
||||
offset: offset.clamp(0.0, 1.0) as f32,
|
||||
color,
|
||||
opacity: opacity.clamp(0.0, 1.0) as f32,
|
||||
midpoint: midpoint.clamp(0.13, 0.87) as f32,
|
||||
});
|
||||
}
|
||||
if out.len() < 2 {
|
||||
return Err("a gradient needs at least two stops".into());
|
||||
}
|
||||
gp.gradient.stops = out;
|
||||
gp.gradient.sort();
|
||||
gp.swatch = None;
|
||||
}
|
||||
if bool_or(p, "reverse", false) {
|
||||
gp.gradient.reverse();
|
||||
// Midpoints belong to the segment to the right; mirror them.
|
||||
let n = gp.gradient.stops.len();
|
||||
let mids: Vec<f32> = gp.gradient.stops.iter().map(|s| s.midpoint).collect();
|
||||
for i in 0..n {
|
||||
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
|
||||
}
|
||||
}
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
let a = ((a + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
gp.angle = a;
|
||||
if let Some(g) = &mut gp.geom {
|
||||
let r = a.to_radians();
|
||||
let dir = drawcraft_geom::Vec2::new(r.cos(), -r.sin());
|
||||
if gp.gradient.kind == GradientKind::Radial {
|
||||
g.end = g.start + dir * g.length();
|
||||
} else {
|
||||
let c = g.start.midpoint(g.end);
|
||||
let half = g.length() / 2.0;
|
||||
g.start = c - dir * half;
|
||||
g.end = c + dir * half;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(asp) = p.get("aspect").and_then(Value::as_f64) {
|
||||
let asp = (asp / 100.0).clamp(0.005, 327.67);
|
||||
if gp.geom.is_none()
|
||||
&& let Some(b) = bounds
|
||||
{
|
||||
gp.geom = Some(gp.resolve(b));
|
||||
}
|
||||
if let Some(g) = &mut gp.geom {
|
||||
g.aspect = asp;
|
||||
}
|
||||
}
|
||||
Ok(Paint::Gradient(Box::new(gp)))
|
||||
}
|
||||
|
||||
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.editGradient";
|
||||
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
|
||||
let ids = leaf_targets(s, p)?;
|
||||
// Validate against the defaults first so bad params fail without touching the document.
|
||||
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
|
||||
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
|
||||
if ids.is_empty() {
|
||||
if stroke {
|
||||
s.paint.stroke = new_default;
|
||||
} else {
|
||||
s.paint.fill = new_default;
|
||||
}
|
||||
return Ok(json!({"ids": []}));
|
||||
}
|
||||
let mut err = None;
|
||||
s.edit("Gradient", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill };
|
||||
match apply_gradient_edit(cur, p, None) {
|
||||
Ok(np) => *cur = np,
|
||||
Err(e) => err = Some(e),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let b = n.geometric_bounds();
|
||||
let ap = &mut n.appearance;
|
||||
let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() };
|
||||
match apply_gradient_edit(&cur, p, b) {
|
||||
Ok(np) => {
|
||||
if stroke {
|
||||
ap.set_stroke(np)
|
||||
} else {
|
||||
ap.set_fill(np)
|
||||
}
|
||||
}
|
||||
Err(e) => err = Some(e),
|
||||
}
|
||||
}
|
||||
match err.take() {
|
||||
Some(e) => Err(bad(C, e)),
|
||||
None => Ok(()),
|
||||
}
|
||||
})?;
|
||||
if stroke {
|
||||
s.paint.stroke = new_default;
|
||||
} else {
|
||||
s.paint.fill = new_default;
|
||||
}
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
// ---------- appearance ----------
|
||||
|
||||
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.duplicateItem", "missing index"))? as usize;
|
||||
let ids = leaf_targets(s, p)?;
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if let Some(item) = n.appearance.items.get(idx).cloned() {
|
||||
n.appearance.items.insert(idx + 1, item);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) }
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.moveItem";
|
||||
let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing from"))? as usize;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
|
||||
let ids = leaf_targets(s, p)?;
|
||||
s.edit("Reorder Appearance", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let items = &mut n.appearance.items;
|
||||
if from >= items.len() {
|
||||
return Err(bad(C, format!("no item at index {from}")));
|
||||
}
|
||||
let it = items.remove(from);
|
||||
let to = to.min(items.len());
|
||||
items.insert(to, it);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
// ---------- artboards ----------
|
||||
|
||||
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.reorder";
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
|
||||
s.edit("Reorder Artboards", |d, _| {
|
||||
if i >= d.artboards.len() {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
}
|
||||
let a = d.artboards.remove(i);
|
||||
let to = to.min(d.artboards.len());
|
||||
d.artboards.insert(to, a);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = s.edit("Duplicate Artboard", |d, _| {
|
||||
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
|
||||
let mut a = src.clone();
|
||||
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1;
|
||||
a.name = format!("{} copy", src.name);
|
||||
let dx = right + 20.0 - src.rect.x0;
|
||||
a.rect = drawcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1);
|
||||
d.artboards.push(a);
|
||||
Ok(d.artboards.len() - 1)
|
||||
})?;
|
||||
Ok(json!({"index": index}))
|
||||
}
|
||||
|
||||
// ---------- swatches ----------
|
||||
|
||||
fn unique_name(d: &Document, base: &str) -> String {
|
||||
let exists = |n: &str| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n);
|
||||
if !exists(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !exists(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let names: Vec<String> =
|
||||
p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
let colors: Vec<drawcraft_color::Color> =
|
||||
p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default();
|
||||
let requested = str_param(p, "name").map(str::to_string);
|
||||
let name = s.edit("New Color Group", |d, _| {
|
||||
let name = unique_name(d, requested.as_deref().unwrap_or("Color Group"));
|
||||
let mut group = SwatchGroup { name: name.clone(), swatches: vec![] };
|
||||
for n in &names {
|
||||
if let Some(pos) = d.swatches.iter().position(|sw| &sw.name == n) {
|
||||
group.swatches.push(d.swatches.remove(pos));
|
||||
}
|
||||
}
|
||||
for c in &colors {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let nm = format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round());
|
||||
group.swatches.push(drawcraft_color::Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false });
|
||||
}
|
||||
d.swatch_groups.push(group);
|
||||
Ok(name)
|
||||
})?;
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string();
|
||||
let new = s.edit("Duplicate Swatch", |d, _| {
|
||||
let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
|
||||
let nm = unique_name(d, &format!("{name} copy"));
|
||||
let copy = drawcraft_color::Swatch { name: nm.clone(), ..src };
|
||||
if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) {
|
||||
d.swatches.insert(pos + 1, copy);
|
||||
} else if let Some(g) = d.swatch_groups.iter_mut().find(|g| g.swatches.iter().any(|sw| sw.name == name)) {
|
||||
let pos = g.swatches.iter().position(|sw| sw.name == name).unwrap_or(0);
|
||||
g.swatches.insert(pos + 1, copy);
|
||||
}
|
||||
Ok(nm)
|
||||
})?;
|
||||
Ok(json!({"name": new}))
|
||||
}
|
||||
|
||||
fn swatch_sort(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.edit("Sort Swatches", |d, _| {
|
||||
// [None] and other bracketed specials stay first, like Illustrator.
|
||||
let key = |sw: &drawcraft_color::Swatch| (!sw.name.starts_with('['), sw.name.to_lowercase());
|
||||
d.swatches.sort_by_key(key);
|
||||
for g in &mut d.swatch_groups {
|
||||
g.swatches.sort_by_key(key);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
// ---------- graphic styles ----------
|
||||
|
||||
fn style_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string();
|
||||
s.edit("Delete Graphic Style", |d, _| {
|
||||
let before = d.graphic_styles.len();
|
||||
d.graphic_styles.retain(|g| g.name != name);
|
||||
if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) }
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn style_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string();
|
||||
let new = s.edit("Duplicate Graphic Style", |d, _| {
|
||||
let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
|
||||
let mut g = d.graphic_styles[pos].clone();
|
||||
let base = format!("{name} copy");
|
||||
let mut nm = base.clone();
|
||||
let mut i = 2;
|
||||
while d.graphic_styles.iter().any(|x| x.name == nm) {
|
||||
nm = format!("{base} {i}");
|
||||
i += 1;
|
||||
}
|
||||
g.name = nm.clone();
|
||||
d.graphic_styles.insert(pos + 1, g);
|
||||
Ok(nm)
|
||||
})?;
|
||||
Ok(json!({"name": new}))
|
||||
}
|
||||
|
||||
// ---------- text ----------
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setFormat";
|
||||
let ids: Vec<NodeId> = {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no text objects selected"));
|
||||
}
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let kerning = match p.get("kerning") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
|
||||
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(C, "kerning must be a number or \"auto\""))?.clamp(-1000.0, 10000.0))),
|
||||
};
|
||||
let keys = [
|
||||
"kerning",
|
||||
"baselineShift",
|
||||
"hScale",
|
||||
"vScale",
|
||||
"rotation",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"allCaps",
|
||||
"leftIndent",
|
||||
"rightIndent",
|
||||
"firstLineIndent",
|
||||
"spaceBefore",
|
||||
"spaceAfter",
|
||||
"hyphenate",
|
||||
];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
if let Some(k) = kerning {
|
||||
st.kerning = k;
|
||||
}
|
||||
if let Some(v) = num("baselineShift") {
|
||||
st.baseline_shift = v.clamp(-1296.0, 1296.0);
|
||||
}
|
||||
if let Some(v) = num("hScale") {
|
||||
st.h_scale = v.clamp(1.0, 10000.0);
|
||||
}
|
||||
if let Some(v) = num("vScale") {
|
||||
st.v_scale = v.clamp(1.0, 10000.0);
|
||||
}
|
||||
if let Some(v) = num("rotation") {
|
||||
st.rotation = ((v + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
}
|
||||
if let Some(v) = flag("underline") {
|
||||
st.underline = v;
|
||||
}
|
||||
if let Some(v) = flag("strikethrough") {
|
||||
st.strikethrough = v;
|
||||
}
|
||||
if let Some(v) = flag("allCaps") {
|
||||
st.all_caps = v;
|
||||
}
|
||||
}
|
||||
let para = &mut t.para;
|
||||
if let Some(v) = num("leftIndent") {
|
||||
para.left_indent = v;
|
||||
}
|
||||
if let Some(v) = num("rightIndent") {
|
||||
para.right_indent = v;
|
||||
}
|
||||
if let Some(v) = num("firstLineIndent") {
|
||||
para.first_line_indent = v;
|
||||
}
|
||||
if let Some(v) = num("spaceBefore") {
|
||||
para.space_before = v;
|
||||
}
|
||||
if let Some(v) = num("spaceAfter") {
|
||||
para.space_after = v;
|
||||
}
|
||||
if let Some(v) = flag("hyphenate") {
|
||||
para.hyphenate = v;
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- stroke extras ----------
|
||||
|
||||
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
|
||||
pub(crate) fn flip_profile(p: &drawcraft_doc::WidthProfile, along: bool) -> drawcraft_doc::WidthProfile {
|
||||
let mut pts: Vec<(f64, f64, f64)> =
|
||||
if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() };
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
drawcraft_doc::WidthProfile { points: pts }
|
||||
}
|
||||
|
||||
fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.setAdvanced";
|
||||
let scale = match p.get("arrowScale") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(C, "arrowScale must be [start, end]"))?;
|
||||
let f = |i: usize| a[i].as_f64().map(|x| x.clamp(1.0, 1000.0));
|
||||
Some((f(0).ok_or_else(|| bad(C, "bad arrowScale"))?, f(1).ok_or_else(|| bad(C, "bad arrowScale"))?))
|
||||
}
|
||||
};
|
||||
let flip = match str_param(p, "flipProfile") {
|
||||
None => None,
|
||||
Some("along") => Some(true),
|
||||
Some("across") => Some(false),
|
||||
Some(o) => return Err(bad(C, format!("flipProfile must be along|across, got {o}"))),
|
||||
};
|
||||
let swap = bool_or(p, "swapArrows", false);
|
||||
let brush = match p.get("brush") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(b)) => Some(Some(b.clone())),
|
||||
Some(_) => return Err(bad(C, "brush must be a name or null")),
|
||||
};
|
||||
if scale.is_none() && flip.is_none() && !swap && brush.is_none() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let ids = leaf_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Stroke", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let Some(st) = n.appearance.stroke_mut() else { continue };
|
||||
if let Some(sc) = scale {
|
||||
st.arrow_scale = sc;
|
||||
}
|
||||
if swap {
|
||||
std::mem::swap(&mut st.start_arrow, &mut st.end_arrow);
|
||||
st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0);
|
||||
}
|
||||
if let (Some(along), Some(pr)) = (flip, &st.profile) {
|
||||
st.profile = Some(flip_profile(pr, along));
|
||||
}
|
||||
if let Some(b) = &brush {
|
||||
st.brush = b.clone();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Type menu long tail (Change Case, Smart Punctuation, Convert To Area/Point Type, Type on a Path
|
||||
//! Options, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) and the text
|
||||
//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
|
||||
|
||||
use drawcraft_doc::{CharStyle, Document, NodeId, NodeKind, TextKind, TextRun};
|
||||
use drawcraft_geom::{Affine, Rect, shapes};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"type.changeCase",
|
||||
"Change Case",
|
||||
["Type", "Change Case"],
|
||||
None,
|
||||
"{case: \"upper\"|\"lower\"|\"title\"|\"sentence\", ids?} → {ids}",
|
||||
has_selection,
|
||||
change_case
|
||||
),
|
||||
cmd!(
|
||||
"type.smartPunctuation",
|
||||
"Smart Punctuation…",
|
||||
["Type"],
|
||||
None,
|
||||
"{quotes?: true (\"\" '' → curly), dashes?: true (-- → en dash, --- → em dash), ellipsis?: true (... → …), scope?: \"selection\"|\"document\"} → {changed}",
|
||||
has_doc,
|
||||
smart_punctuation
|
||||
),
|
||||
cmd!(
|
||||
"type.convertToAreaType",
|
||||
"Convert To Area Type",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?} point type → area type framed by its current layout bounds → {ids}",
|
||||
has_selection,
|
||||
to_area
|
||||
),
|
||||
cmd!(
|
||||
"type.convertToPointType",
|
||||
"Convert To Point Type",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?} area type → point type; soft line wraps become line breaks → {ids}",
|
||||
has_selection,
|
||||
to_point
|
||||
),
|
||||
cmd!(
|
||||
"type.pathOptions",
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), ids?}",
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
cmd!(
|
||||
"type.fillPlaceholder",
|
||||
"Fill with Placeholder Text",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?} replace selected text with placeholder text (area type is filled to its frame) → {ids}",
|
||||
has_selection,
|
||||
fill_placeholder
|
||||
),
|
||||
cmd!(
|
||||
"type.insert",
|
||||
"Insert Character",
|
||||
["Type"],
|
||||
None,
|
||||
"{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string} append to the selected text objects",
|
||||
has_selection,
|
||||
insert_char
|
||||
),
|
||||
cmd!(
|
||||
"edit.findReplace",
|
||||
"Find and Replace…",
|
||||
["Edit"],
|
||||
None,
|
||||
"{find, replace?: \"\", matchCase?: false, wholeWord?: false, ids?} replace in every text object (or ids) → {count}. Matches never span style runs.",
|
||||
has_doc,
|
||||
find_replace
|
||||
),
|
||||
cmd!(
|
||||
"edit.findNext",
|
||||
"Find Next",
|
||||
["Edit"],
|
||||
None,
|
||||
"{find, matchCase?, wholeWord?} select the next text object (after the selected one, wrapping) containing `find` → {id|null}",
|
||||
has_doc,
|
||||
find_next
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteWithoutFormatting",
|
||||
"Paste without Formatting",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+V"),
|
||||
"{dx?, dy?} paste; pasted text takes the default character style (one run)",
|
||||
has_clipboard,
|
||||
paste_plain
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
pub(crate) fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
if let Some(n) = d.node(*id) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn texts(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = targets(s, p)?;
|
||||
let t = text_ids(&s.doc()?.doc, &ids);
|
||||
if t.is_empty() {
|
||||
return Err(bad(cmd, "no text objects selected"));
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// Rewrite every run of the given text objects with `f(run text, state)`.
|
||||
fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl Fn(&str, &mut S) -> String) -> Result<usize> {
|
||||
s.edit(label, |d, _| {
|
||||
let mut changed = 0;
|
||||
for id in ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let mut st = S::default();
|
||||
let mut any = false;
|
||||
for r in &mut t.runs {
|
||||
let n = f(&r.text, &mut st);
|
||||
if n != r.text {
|
||||
r.text = n;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
changed += 1;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Change Case ----------
|
||||
|
||||
#[derive(Default)]
|
||||
struct CaseState {
|
||||
/// Previous character (across runs).
|
||||
prev: Option<char>,
|
||||
/// Sentence start pending (sentence case).
|
||||
sentence: bool,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.changeCase";
|
||||
let case = str_param(p, "case").ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase();
|
||||
if !["upper", "uppercase", "lower", "lowercase", "title", "sentence"].contains(&case.as_str()) {
|
||||
return Err(bad(C, "case must be upper|lower|title|sentence"));
|
||||
}
|
||||
let ids = texts(s, p, C)?;
|
||||
edit_runs::<CaseState>(s, "Change Case", &ids, |text, st| {
|
||||
if !st.started {
|
||||
st.started = true;
|
||||
st.sentence = true;
|
||||
}
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match case.as_str() {
|
||||
"upper" | "uppercase" => out.extend(c.to_uppercase()),
|
||||
"lower" | "lowercase" => out.extend(c.to_lowercase()),
|
||||
"title" => {
|
||||
if st.prev.is_none_or(|p| p.is_whitespace() || matches!(p, '-' | '(' | '"' | '“' | '/')) {
|
||||
out.extend(c.to_uppercase())
|
||||
} else {
|
||||
out.extend(c.to_lowercase())
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if c.is_alphabetic() && st.sentence {
|
||||
out.extend(c.to_uppercase());
|
||||
st.sentence = false;
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
if matches!(c, '.' | '!' | '?' | '\n') {
|
||||
st.sentence = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
st.prev = Some(c);
|
||||
}
|
||||
out
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
// ---------- Smart Punctuation ----------
|
||||
|
||||
#[derive(Default)]
|
||||
struct PunctState {
|
||||
prev: Option<char>,
|
||||
}
|
||||
|
||||
fn opens(prev: Option<char>) -> bool {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–' | '“' | '‘'))
|
||||
}
|
||||
|
||||
pub(crate) fn smarten(text: &str, prev: &mut Option<char>, quotes: bool, dashes: bool, ellipsis: bool) -> String {
|
||||
let mut s = text.to_string();
|
||||
if dashes {
|
||||
s = s.replace("---", "—").replace("--", "–");
|
||||
}
|
||||
if ellipsis {
|
||||
s = s.replace("...", "…");
|
||||
}
|
||||
if !quotes {
|
||||
*prev = s.chars().last().or(*prev);
|
||||
return s;
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
let r = match c {
|
||||
'"' => {
|
||||
if opens(*prev) {
|
||||
'“'
|
||||
} else {
|
||||
'”'
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
if opens(*prev) {
|
||||
'‘'
|
||||
} else {
|
||||
'’'
|
||||
}
|
||||
}
|
||||
c => c,
|
||||
};
|
||||
out.push(r);
|
||||
*prev = Some(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (q, d, e) = (bool_or(p, "quotes", true), bool_or(p, "dashes", true), bool_or(p, "ellipsis", true));
|
||||
let st = s.doc()?;
|
||||
let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() {
|
||||
let mut v = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
v.push(n.id)
|
||||
}
|
||||
});
|
||||
v
|
||||
} else {
|
||||
text_ids(&st.doc, &st.selection.objects)
|
||||
};
|
||||
let n = edit_runs::<PunctState>(s, "Smart Punctuation", &ids, |text, st| smarten(text, &mut st.prev, q, d, e))?;
|
||||
Ok(json!({ "changed": n }))
|
||||
}
|
||||
|
||||
// ---------- Area / Point conversion ----------
|
||||
|
||||
fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = texts(s, p, "type.convertToAreaType")?;
|
||||
s.edit("Convert To Area Type", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
if !matches!(t.kind, TextKind::Point) {
|
||||
continue;
|
||||
}
|
||||
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
|
||||
let b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() };
|
||||
let size = t.first_style().size;
|
||||
// A little slack on the right so the text doesn't rewrap.
|
||||
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
|
||||
let frame = Rect::new(b.x0, top, b.x1 + size * 0.5, b.y1.max(top + 1.0));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(frame) };
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// (run, byte within the run) for a byte offset into the plain text.
|
||||
fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> {
|
||||
let mut off = 0;
|
||||
for (i, r) in runs.iter().enumerate() {
|
||||
if byte < off + r.text.len() {
|
||||
return Some((i, byte - off));
|
||||
}
|
||||
off += r.text.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = texts(s, p, "type.convertToPointType")?;
|
||||
s.edit("Convert To Point Type", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let TextKind::Area { frame } = &t.kind else { continue };
|
||||
let fb = frame.bounds().unwrap_or_default();
|
||||
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
|
||||
let plain = t.plain_text();
|
||||
let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8);
|
||||
// Soft wraps → explicit breaks (replace the space before the wrap when there is one).
|
||||
let mut breaks: Vec<usize> =
|
||||
lay.lines.iter().skip(1).map(|l| l.start).filter(|&b| b > 0 && plain.as_bytes().get(b - 1) != Some(&b'\n')).collect();
|
||||
breaks.sort_unstable();
|
||||
breaks.dedup();
|
||||
for b in breaks.into_iter().rev() {
|
||||
if plain.as_bytes().get(b - 1) == Some(&b' ') {
|
||||
if let Some((ri, bi)) = locate(&t.runs, b - 1) {
|
||||
t.runs[ri].text.replace_range(bi..bi + 1, "\n");
|
||||
}
|
||||
} else if let Some((ri, bi)) = locate(&t.runs, b) {
|
||||
t.runs[ri].text.insert(bi, '\n');
|
||||
}
|
||||
}
|
||||
t.kind = TextKind::Point;
|
||||
t.xf *= Affine::translate((fb.x0, baseline));
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.pathOptions";
|
||||
let ids = texts(s, p, C)?;
|
||||
let start = p.get("start").and_then(Value::as_f64);
|
||||
let flip = bool_or(p, "flip", false);
|
||||
let n = s.edit("Type on a Path Options", |d, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let TextKind::OnPath { path, start: st } = &mut t.kind else { continue };
|
||||
if let Some(v) = start {
|
||||
*st = v.clamp(0.0, 1.0);
|
||||
}
|
||||
if flip {
|
||||
path.reverse();
|
||||
}
|
||||
n += 1;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(bad(C, "select type on a path"));
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
}
|
||||
|
||||
// ---------- placeholder / insert ----------
|
||||
|
||||
/// DrawCraft's own placeholder copy (not the classical Latin text).
|
||||
pub(crate) const PLACEHOLDER: &str = "Sample copy flows here while the layout takes shape. Swap these words for real text once the \
|
||||
design is settled. Every line is only a stand-in that shows size, rhythm and colour. Headlines, captions and body text all \
|
||||
start as rough drafts like this one. Keep going until the frame is full and the page feels balanced.";
|
||||
|
||||
fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = texts(s, p, "type.fillPlaceholder")?;
|
||||
s.edit("Fill with Placeholder Text", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let style = t.first_style();
|
||||
let text = match &t.kind {
|
||||
TextKind::Area { frame } => {
|
||||
let b = frame.bounds().unwrap_or_default();
|
||||
let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0);
|
||||
let lines = (b.height() / style.effective_leading()).max(1.0);
|
||||
let want = (chars_per_line * lines) as usize;
|
||||
let mut out = String::new();
|
||||
let words: Vec<&str> = PLACEHOLDER.split(' ').collect();
|
||||
let mut i = 0;
|
||||
while out.len() < want && i < 10_000 {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(words[i % words.len()]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
|
||||
};
|
||||
t.runs = vec![TextRun { text, style }];
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
pub(crate) fn special_char(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"bullet" => "•",
|
||||
"copyright" => "©",
|
||||
"ellipsis" => "…",
|
||||
"paragraph" => "¶",
|
||||
"registered" => "®",
|
||||
"section" => "§",
|
||||
"trademark" => "™",
|
||||
"emDash" => "—",
|
||||
"enDash" => "–",
|
||||
"discretionaryHyphen" => "\u{00AD}",
|
||||
"nonBreakingHyphen" => "\u{2011}",
|
||||
"doubleLeftQuote" => "“",
|
||||
"doubleRightQuote" => "”",
|
||||
"singleLeftQuote" => "‘",
|
||||
"singleRightQuote" => "’",
|
||||
"emSpace" => "\u{2003}",
|
||||
"enSpace" => "\u{2002}",
|
||||
"hairSpace" => "\u{200A}",
|
||||
"sixthSpace" => "\u{2006}",
|
||||
"thinSpace" => "\u{2009}",
|
||||
"nonBreakingSpace" => "\u{00A0}",
|
||||
"figureSpace" => "\u{2007}",
|
||||
"punctuationSpace" => "\u{2008}",
|
||||
"thirdSpace" => "\u{2004}",
|
||||
"quarterSpace" => "\u{2005}",
|
||||
"tab" => "\t",
|
||||
// Our text model breaks lines at paragraph returns only.
|
||||
"forcedLineBreak" | "paragraphReturn" => "\n",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.insert";
|
||||
let text = match (str_param(p, "char"), str_param(p, "text")) {
|
||||
(Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(),
|
||||
(None, Some(t)) => t.to_string(),
|
||||
_ => return Err(bad(C, "give `char` or `text`")),
|
||||
};
|
||||
let ids = texts(s, p, C)?;
|
||||
s.edit("Typing", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
match t.runs.last_mut() {
|
||||
Some(r) => r.text.push_str(&text),
|
||||
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default() }),
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
// ---------- Find and Replace ----------
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Byte ranges of `needle` in `hay`.
|
||||
pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> {
|
||||
let nc: Vec<char> = needle.chars().collect();
|
||||
if nc.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let hc: Vec<(usize, char)> = hay.char_indices().collect();
|
||||
let eq = |a: char, b: char| if match_case { a == b } else { a == b || a.to_lowercase().eq(b.to_lowercase()) };
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i + nc.len() <= hc.len() {
|
||||
if (0..nc.len()).all(|k| eq(hc[i + k].1, nc[k])) {
|
||||
let before = i.checked_sub(1).map(|j| hc[j].1);
|
||||
let after = hc.get(i + nc.len()).map(|x| x.1);
|
||||
if !whole_word || (!before.is_some_and(is_word) && !after.is_some_and(is_word)) {
|
||||
let start = hc[i].0;
|
||||
let end = hc.get(i + nc.len()).map(|x| x.0).unwrap_or(hay.len());
|
||||
out.push((start, end));
|
||||
i += nc.len();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn all_texts(d: &Document) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
for l in d.layers.iter().filter(|l| l.visible && !l.locked) {
|
||||
l.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) && n.visible && !n.locked {
|
||||
v.push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.findReplace";
|
||||
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string();
|
||||
let rep = str_param(p, "replace").unwrap_or("").to_string();
|
||||
let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false));
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => text_ids(&s.doc()?.doc, &v),
|
||||
None => all_texts(&s.doc()?.doc),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let total: usize = ids
|
||||
.iter()
|
||||
.filter_map(|id| match &st.doc.node(*id)?.kind {
|
||||
NodeKind::Text(t) => Some(t.runs.iter().map(|r| find_all(&r.text, &find, mc, ww).len()).sum::<usize>()),
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
if total == 0 {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
edit_runs::<()>(s, "Replace", &ids, |text, _| {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut last = 0;
|
||||
for (a, b) in find_all(text, &find, mc, ww) {
|
||||
out.push_str(&text[last..a]);
|
||||
out.push_str(&rep);
|
||||
last = b;
|
||||
}
|
||||
out.push_str(&text[last..]);
|
||||
out
|
||||
})?;
|
||||
Ok(json!({ "count": total }))
|
||||
}
|
||||
|
||||
fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "edit.findNext";
|
||||
let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string();
|
||||
let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false));
|
||||
let st = s.doc()?;
|
||||
let all = all_texts(&st.doc);
|
||||
let cur = st.selection.objects.first().and_then(|id| all.iter().position(|x| x == id));
|
||||
let n = all.len();
|
||||
let start = cur.map(|c| c + 1).unwrap_or(0);
|
||||
let hit = (0..n).map(|k| all[(start + k) % n.max(1)]).find(|id| match st.doc.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => !find_all(&t.plain_text(), &find, mc, ww).is_empty(),
|
||||
_ => false,
|
||||
});
|
||||
match hit {
|
||||
Some(id) => {
|
||||
s.select(|_, sel| sel.set([id]))?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
None => Ok(json!({ "id": null })),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Paste without Formatting ----------
|
||||
|
||||
fn strip_formatting(n: &mut drawcraft_doc::Node) {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let text = t.plain_text();
|
||||
t.runs = vec![TextRun { text, style: CharStyle::default() }];
|
||||
t.para = Default::default();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
strip_formatting(std::sync::Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_plain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let saved = s.clipboard.clone();
|
||||
for n in &mut s.clipboard {
|
||||
strip_formatting(n);
|
||||
}
|
||||
let paste = find_command("edit.paste").map(|c| c.run).ok_or_else(|| EngineError::Other("paste unavailable".into()))?;
|
||||
let r = paste(s, p);
|
||||
s.clipboard = saved;
|
||||
if r.is_ok()
|
||||
&& let Some(e) = s.active_mut().and_then(|d| d.history.undo.last_mut())
|
||||
{
|
||||
e.label = "Paste without Formatting".into();
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -70,6 +70,9 @@ pub struct Interaction {
|
||||
pub doc: Arc<Document>,
|
||||
pub selection: Selection,
|
||||
pub preview: Option<(String, Value)>,
|
||||
/// Per-document state restored on cancel (current layer, isolation).
|
||||
pub active_layer: Option<NodeId>,
|
||||
pub isolation: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// Per-document editing state.
|
||||
@@ -131,6 +134,16 @@ impl DocState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
|
||||
pub const MAX_COORD: f64 = 4.0e6;
|
||||
|
||||
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
|
||||
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
|
||||
fn doc_sane(d: &Document, sel: &Selection) -> bool {
|
||||
let ok = |r: drawcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
|
||||
d.artboards.iter().all(|a| ok(a.rect)) && sel.objects.iter().all(|id| d.node(*id).and_then(|n| n.geometric_bounds()).is_none_or(ok))
|
||||
}
|
||||
|
||||
/// Where new art goes (Illustrator's drawing modes, Shift+D cycles).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -173,6 +186,7 @@ pub struct Session {
|
||||
pub journal: Vec<(String, Value)>,
|
||||
pub(crate) tool: Box<dyn Tool>,
|
||||
pub(crate) last_view: ViewInfo,
|
||||
depth: u32,
|
||||
/// Draw Normal / Behind / Inside (toolbar drawing modes).
|
||||
pub draw_mode: DrawMode,
|
||||
/// The path new art is drawn inside (Draw Inside).
|
||||
@@ -200,6 +214,7 @@ impl Session {
|
||||
journal: vec![],
|
||||
tool: drawcraft_tools::create("selection"),
|
||||
last_view: ViewInfo::default(),
|
||||
depth: 0,
|
||||
draw_mode: DrawMode::Normal,
|
||||
draw_inside: None,
|
||||
untitled_counter: 0,
|
||||
@@ -259,8 +274,13 @@ impl Session {
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
let r = (spec.run)(self, params)?;
|
||||
if spec.journal && self.active().is_none_or(|d| d.interaction.is_none()) {
|
||||
// Only top-level commands are journaled (commands that call other commands would otherwise
|
||||
// be recorded twice and replay differently).
|
||||
self.depth += 1;
|
||||
let r = (spec.run)(self, params);
|
||||
self.depth -= 1;
|
||||
let r = r?;
|
||||
if spec.journal && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
}
|
||||
Ok(r)
|
||||
@@ -273,7 +293,14 @@ impl Session {
|
||||
let before = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
match f(doc, &mut st.selection) {
|
||||
let result = f(doc, &mut st.selection).and_then(|v| {
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
|
||||
}
|
||||
});
|
||||
match result {
|
||||
Ok(v) => {
|
||||
st.selection.prune(&st.doc);
|
||||
st.revision += 1;
|
||||
@@ -310,7 +337,14 @@ impl Session {
|
||||
if st.interaction.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
st.interaction = Some(Interaction { label: label.to_string(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None });
|
||||
st.interaction = Some(Interaction {
|
||||
label: label.to_string(),
|
||||
doc: st.doc.clone(),
|
||||
selection: st.selection.clone(),
|
||||
preview: None,
|
||||
active_layer: st.active_layer,
|
||||
isolation: st.isolation,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -355,6 +389,8 @@ impl Session {
|
||||
if let Some(it) = st.interaction.take() {
|
||||
st.doc = it.doc;
|
||||
st.selection = it.selection;
|
||||
st.active_layer = it.active_layer;
|
||||
st.isolation = it.isolation;
|
||||
st.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
@@ -379,8 +415,8 @@ mod tests_file;
|
||||
#[cfg(test)]
|
||||
mod tests_menucmds;
|
||||
#[cfg(test)]
|
||||
mod tests_pathops;
|
||||
#[cfg(test)]
|
||||
mod tests_panelcmds;
|
||||
#[cfg(test)]
|
||||
mod tests_pathops;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
@@ -0,0 +1,706 @@
|
||||
//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`.
|
||||
|
||||
use drawcraft_color::{Color, Paint};
|
||||
use drawcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||||
use drawcraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, t: &str) -> NodeId {
|
||||
id_of(&s.execute("text.create", &json!({"x": 50, "y": 50, "text": t})).unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
node(s, id).geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn fill(s: &mut Session, id: NodeId, hex: &str) {
|
||||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": hex})).unwrap();
|
||||
}
|
||||
|
||||
fn fill_of(s: &Session, id: NodeId) -> Color {
|
||||
node(s, id).appearance.fill_paint().color().unwrap()
|
||||
}
|
||||
|
||||
fn plain(s: &Session, id: NodeId) -> String {
|
||||
match &node(s, id).kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
// ---------- Object → Lock / Hide ----------
|
||||
|
||||
#[test]
|
||||
fn lock_all_artwork_above_only_overlapping_objects_above() {
|
||||
let mut s = session();
|
||||
let below = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
|
||||
let over = rect(&mut s, 30.0, 30.0, 50.0, 50.0);
|
||||
let far = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||||
sel(&mut s, &[a]);
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("object.lock.above", &json!({})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
assert!(node(&s, over).locked);
|
||||
assert!(!node(&s, below).locked && !node(&s, far).locked && !node(&s, a).locked);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_all_artwork_above() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
|
||||
let over = rect(&mut s, 30.0, 30.0, 50.0, 50.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.hide.above", &json!({})).unwrap();
|
||||
assert!(!node(&s, over).visible);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(node(&s, over).visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_hide_other_layers() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let l2 = id_of(&s.execute("layer.new", &json!({})).unwrap_or(json!({"id": 0})));
|
||||
let l1 = s.doc().unwrap().doc.layers[0].id;
|
||||
assert_ne!(l1, l2);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.lock.otherLayers", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(l2).unwrap().locked);
|
||||
assert!(!d.node(l1).unwrap().locked);
|
||||
s.execute("object.hide.otherLayers", &json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.node(l2).unwrap().visible);
|
||||
}
|
||||
|
||||
// ---------- Transform Each ----------
|
||||
|
||||
#[test]
|
||||
fn transform_each_scales_each_about_its_own_center() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
let n = undo_len(&s);
|
||||
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50})).unwrap();
|
||||
assert_eq!(bounds(&s, a), Rect::new(25.0, 25.0, 75.0, 75.0));
|
||||
assert_eq!(bounds(&s, b), Rect::new(225.0, 25.0, 275.0, 75.0));
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_each_move_reference_and_copy() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
// Top-left reference: scaling keeps the top-left corner.
|
||||
let r = s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": 5, "reference": 0, "copy": true})).unwrap();
|
||||
let copy = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
assert_ne!(copy, a);
|
||||
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert_eq!(bounds(&s, copy), Rect::new(10.0, 5.0, 60.0, 55.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_each_random_is_deterministic_with_seed() {
|
||||
let run = || {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.transformEach", &json!({"moveH": 50, "random": true, "seed": 7})).unwrap();
|
||||
bounds(&s, a)
|
||||
};
|
||||
let (x, y) = (run(), run());
|
||||
assert_eq!(x, y);
|
||||
assert!(x.x0.abs() <= 50.0 && x.width() == 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_is_a_noop() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0);
|
||||
assert_eq!(undo_len(&s), n);
|
||||
}
|
||||
|
||||
// ---------- Expand / Rasterize / Crop / Trim marks ----------
|
||||
|
||||
#[test]
|
||||
fn expand_live_shape_and_text_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let t = text(&mut s, "Hi");
|
||||
sel(&mut s, &[a, t]);
|
||||
let n = undo_len(&s);
|
||||
s.execute("object.expand", &json!({})).unwrap();
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: None, .. }));
|
||||
assert!(s.doc().unwrap().doc.node(t).is_none(), "text became outlines");
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(_), .. }));
|
||||
assert!(s.doc().unwrap().doc.node(t).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_nothing_errors() {
|
||||
let mut s = session();
|
||||
let p = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]})).unwrap());
|
||||
sel(&mut s, &[p]);
|
||||
// A plain path: nothing to expand for object/fill.
|
||||
let r = s.execute("object.expand", &json!({"stroke": false}));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_replaces_selection_with_image() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
fill(&mut s, a, "#ff0000");
|
||||
sel(&mut s, &[a]);
|
||||
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
|
||||
let r = s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap();
|
||||
let img = id_of(&r);
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||||
let n = node(&s, img);
|
||||
let NodeKind::Image(im) = &n.kind else { panic!() };
|
||||
// Visual bounds (stroke included) at 2 px/pt.
|
||||
assert_eq!((im.width as f64, im.height as f64), ((vb.width() * 2.0).ceil(), (vb.height() * 2.0).ceil()));
|
||||
assert!(s.doc().unwrap().doc.images.contains_key(&im.key));
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!(close(b.x0, vb.x0) && close(b.y0, vb.y0) && b.width() >= vb.width());
|
||||
assert_eq!(selected(&s), vec![img]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_to_rect() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
||||
let r = s.execute("object.cropImage", &json!({"rect": [10, 10, 50, 40]})).unwrap();
|
||||
assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (50, 40));
|
||||
let b = bounds(&s, img);
|
||||
assert!(close(b.x0, 10.0) && close(b.y0, 10.0) && close(b.width(), 50.0) && close(b.height(), 40.0), "{b:?}");
|
||||
// Image fully inside the artboard: default crop has nothing to do.
|
||||
assert!(s.execute("object.cropImage", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_disabled_without_image() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
assert!(matches!(s.execute("object.cropImage", &json!({})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_marks_around_selection() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
fill(&mut s, a, "#00ff00");
|
||||
s.execute("stroke.set", &json!({"ids": [a.0], "weight": 0})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
let g = id_of(&s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20})).unwrap());
|
||||
let n = node(&s, g);
|
||||
assert_eq!(n.children().unwrap().len(), 8);
|
||||
assert_eq!(n.name.as_deref(), Some("Trim Marks"));
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
let vb = s.doc().unwrap().doc.bounds_of(&[a], true).unwrap();
|
||||
assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}");
|
||||
}
|
||||
|
||||
// ---------- Convert to Shape ----------
|
||||
|
||||
#[test]
|
||||
fn convert_rectangle_path_to_live_shape() {
|
||||
let mut s = session();
|
||||
let p = id_of(
|
||||
&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 40, "y": 0}, {"x": 40, "y": 20}, {"x": 0, "y": 20}], "closed": true}))
|
||||
.unwrap(),
|
||||
);
|
||||
sel(&mut s, &[p]);
|
||||
assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 1);
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, .. }), path, .. } = &node(&s, p).kind else { panic!() };
|
||||
assert!(close(*w, 40.0) && close(*h, 20.0));
|
||||
assert_eq!(path.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 20.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rotated_rectangle_and_ellipse() {
|
||||
let mut s = session();
|
||||
let p = id_of(
|
||||
&s.execute(
|
||||
"path.create",
|
||||
&json!({"anchors": [{"x": 50, "y": 0}, {"x": 100, "y": 50}, {"x": 50, "y": 100}, {"x": 0, "y": 50}], "closed": true}),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.expandShape", &json!({})).unwrap();
|
||||
sel(&mut s, &[p, e]);
|
||||
assert_eq!(s.execute("object.shape.convertToShape", &json!({})).unwrap()["converted"], 2);
|
||||
let b = bounds(&s, p);
|
||||
assert!(close(b.x0, 0.0) && close(b.x1, 100.0) && close(b.y1, 100.0), "{b:?}");
|
||||
assert!(matches!(node(&s, e).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_shape_rejects_other_paths() {
|
||||
let mut s = session();
|
||||
let p = id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 40, "y": 0}, {"x": 10, "y": 20}], "closed": true})).unwrap());
|
||||
sel(&mut s, &[p]);
|
||||
assert!(s.execute("object.shape.convertToShape", &json!({})).is_err());
|
||||
}
|
||||
|
||||
// ---------- Blend ----------
|
||||
|
||||
#[test]
|
||||
fn blend_make_release_options() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 0.0, 30.0, 30.0);
|
||||
fill(&mut s, a, "#000000");
|
||||
fill(&mut s, b, "#ffffff");
|
||||
sel(&mut s, &[a, b]);
|
||||
let n = undo_len(&s);
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
let gn = node(&s, g);
|
||||
assert_eq!(gn.name.as_deref(), Some("Blend"));
|
||||
let ch = gn.children().unwrap().clone();
|
||||
assert_eq!(ch.len(), 5);
|
||||
// Middle step: halfway in position, size and colour.
|
||||
let mid = &ch[2];
|
||||
let mb = mid.geometric_bounds().unwrap();
|
||||
assert!(close(mb.x0, 50.0) && close(mb.width(), 20.0), "{mb:?}");
|
||||
let c = mid.appearance.fill_paint().color().unwrap().to_rgb();
|
||||
assert!((c[0] - 0.5).abs() < 1e-3);
|
||||
// Options: 1 step.
|
||||
s.execute("object.blend.options", &json!({"steps": 1})).unwrap();
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 3);
|
||||
// Release: keys only, back in the layer.
|
||||
let r = s.execute("object.blend.release", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
assert!(s.doc().unwrap().doc.node(a).is_some() && s.doc().unwrap().doc.node(b).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_expand_and_reverse() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
|
||||
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, g).children().unwrap().last().unwrap().id, a);
|
||||
s.execute("object.blend.reverseSpine", &json!({})).unwrap();
|
||||
assert!(close(bounds(&s, a).x0, 100.0));
|
||||
s.execute("object.blend.expand", &json!({})).unwrap();
|
||||
assert!(node(&s, g).name.is_none());
|
||||
assert!(s.execute("object.blend.release", &json!({})).is_err(), "no longer a blend");
|
||||
}
|
||||
|
||||
// ---------- Artboards ----------
|
||||
|
||||
#[test]
|
||||
fn convert_to_artboards_and_rearrange() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 900.0, 0.0, 200.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("artboard.convertToArtboards", &json!({})).unwrap()["artboards"], 1);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards.len(), 2);
|
||||
assert_eq!(d.artboards[1].rect, Rect::new(900.0, 0.0, 1100.0, 100.0));
|
||||
assert!(d.node(a).is_none());
|
||||
// Art on artboard 2 moves with it.
|
||||
let b = rect(&mut s, 950.0, 25.0, 10.0, 10.0);
|
||||
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards[1].rect, Rect::new(0.0, 650.0, 200.0, 750.0));
|
||||
assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0));
|
||||
}
|
||||
|
||||
// ---------- Edit Colors ----------
|
||||
|
||||
#[test]
|
||||
fn invert_and_convert_colors() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
fill(&mut s, a, "#ff0000");
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("edit.colors.invert", &json!({"stroke": false})).unwrap();
|
||||
assert_eq!(fill_of(&s, a).to_hex(), "#00ffff");
|
||||
s.execute("edit.colors.toCMYK", &json!({})).unwrap();
|
||||
assert!(matches!(fill_of(&s, a), Color::Cmyk { .. }));
|
||||
s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
|
||||
assert!(matches!(fill_of(&s, a), Color::Gray { .. }));
|
||||
s.execute("edit.colors.toRGB", &json!({})).unwrap();
|
||||
assert!(matches!(fill_of(&s, a), Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturate_and_adjust_balance() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "color": [0.8, 0.4, 0.4]})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("edit.colors.saturate", &json!({"intensity": -100})).unwrap();
|
||||
let [_, sat, _] = fill_of(&s, a).to_hsb();
|
||||
assert!(sat < 1e-4);
|
||||
s.execute("edit.colors.adjustBalance", &json!({"r": 10, "g": -10})).unwrap();
|
||||
let [r, g, _] = fill_of(&s, a).to_rgb();
|
||||
assert!((r - 0.9).abs() < 1e-3 && (g - 0.7).abs() < 1e-3, "{r} {g}");
|
||||
assert!(s.execute("edit.colors.adjustBalance", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_colors_horizontally() {
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = (0..3).map(|i| rect(&mut s, 200.0 - i as f64 * 100.0, 0.0, 10.0, 10.0)).collect();
|
||||
fill(&mut s, ids[2], "#000000"); // leftmost
|
||||
fill(&mut s, ids[0], "#ffffff"); // rightmost
|
||||
fill(&mut s, ids[1], "#ff0000");
|
||||
sel(&mut s, &ids);
|
||||
s.execute("edit.colors.blendHorizontally", &json!({})).unwrap();
|
||||
assert_eq!(fill_of(&s, ids[1]).to_hex(), "#808080");
|
||||
sel(&mut s, &ids[..2]);
|
||||
assert!(s.execute("edit.colors.blendVertically", &json!({})).is_err(), "needs three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_front_to_back_uses_stacking() {
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = (0..3).map(|_| rect(&mut s, 0.0, 0.0, 10.0, 10.0)).collect();
|
||||
fill(&mut s, ids[0], "#000000");
|
||||
fill(&mut s, ids[2], "#ffffff");
|
||||
sel(&mut s, &ids);
|
||||
s.execute("edit.colors.blendFrontToBack", &json!({})).unwrap();
|
||||
assert_eq!(fill_of(&s, ids[1]).to_hex(), "#808080");
|
||||
}
|
||||
|
||||
// ---------- Paste without formatting / Find ----------
|
||||
|
||||
#[test]
|
||||
fn paste_without_formatting_resets_text_style() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Bold", "size": 40})).unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
|
||||
let p = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let NodeKind::Text(tt) = &node(&s, p).kind else { panic!() };
|
||||
assert_eq!(tt.plain_text(), "Bold");
|
||||
assert_eq!(tt.first_style().size, 12.0);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// Clipboard keeps its formatting.
|
||||
let NodeKind::Text(ct) = &s.clipboard[0].kind else { panic!() };
|
||||
assert_eq!(ct.first_style().size, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_replace_options() {
|
||||
let mut s = session();
|
||||
let a = text(&mut s, "Cat cat catalog");
|
||||
let b = text(&mut s, "the CAT");
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog", "wholeWord": true})).unwrap();
|
||||
assert_eq!(r["count"], 3);
|
||||
assert_eq!(plain(&s, a), "dog dog catalog");
|
||||
assert_eq!(plain(&s, b), "the dog");
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "Dog", "replace": "x", "matchCase": true})).unwrap();
|
||||
assert_eq!(r["count"], 0);
|
||||
assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_cycles_through_text_objects() {
|
||||
let mut s = session();
|
||||
let a = text(&mut s, "apple");
|
||||
let _ = text(&mut s, "banana");
|
||||
let c = text(&mut s, "pineapple");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "APPLE"})).unwrap()), a);
|
||||
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "apple"})).unwrap()), c);
|
||||
assert_eq!(id_of(&s.execute("edit.findNext", &json!({"find": "apple"})).unwrap()), a);
|
||||
assert!(s.execute("edit.findNext", &json!({"find": "zzz"})).unwrap()["id"].is_null());
|
||||
}
|
||||
|
||||
// ---------- Select ----------
|
||||
|
||||
#[test]
|
||||
fn select_same_font_size_and_fill() {
|
||||
let mut s = session();
|
||||
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 20, "text": "a", "size": 20})).unwrap());
|
||||
let b = id_of(&s.execute("text.create", &json!({"x": 0, "y": 60, "text": "b", "size": 20})).unwrap());
|
||||
let c = id_of(&s.execute("text.create", &json!({"x": 0, "y": 90, "text": "c", "size": 30, "font": "Inter"})).unwrap());
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("select.same.fontSize", &json!({})).unwrap()["count"], 2);
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
sel(&mut s, &[c]);
|
||||
assert_eq!(s.execute("select.same.fontFamily", &json!({})).unwrap()["count"], 1);
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("select.same.textFillColor", &json!({})).unwrap()["count"], 3);
|
||||
assert_eq!(s.execute("select.same.fontFamilyStyle", &json!({})).unwrap()["count"], 2);
|
||||
let r = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
|
||||
sel(&mut s, &[r]);
|
||||
assert!(s.execute("select.same.fontSize", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_direction_handles_selects_all_anchors() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("select.object.directionHandles", &json!({})).unwrap()["anchors"], 4);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors.get(&a).unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_point_and_area_text() {
|
||||
let mut s = session();
|
||||
let p = text(&mut s, "point");
|
||||
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "area", "area": {"width": 100, "height": 50}})).unwrap());
|
||||
let _ = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
|
||||
assert_eq!(s.execute("select.object.pointText", &json!({})).unwrap()["count"], 1);
|
||||
assert_eq!(selected(&s), vec![p]);
|
||||
s.execute("select.object.areaText", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
assert_eq!(s.execute("select.object.brushStrokes", &json!({})).unwrap()["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_recall_selection() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("select.recall", &json!({"name": "Selection 1"})).unwrap()["count"], 2);
|
||||
s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "Pair"})).unwrap();
|
||||
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Pair"]));
|
||||
s.execute("select.editSaved", &json!({"name": "Pair", "delete": true})).unwrap();
|
||||
assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err());
|
||||
}
|
||||
|
||||
// ---------- Type ----------
|
||||
|
||||
#[test]
|
||||
fn change_case_variants() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "hello wORLD. again here");
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("type.changeCase", &json!({"case": "upper"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "HELLO WORLD. AGAIN HERE");
|
||||
s.execute("type.changeCase", &json!({"case": "lower"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "hello world. again here");
|
||||
s.execute("type.changeCase", &json!({"case": "title"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Hello World. Again Here");
|
||||
s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "Hello world. Again here");
|
||||
assert!(s.execute("type.changeCase", &json!({"case": "weird"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_punctuation_quotes_dashes_ellipsis() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "\"Hi\" it's 1--2 and 3---4...");
|
||||
sel(&mut s, &[t]);
|
||||
assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_point_area_roundtrip() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Some words");
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("type.convertToAreaType", &json!({})).unwrap();
|
||||
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
|
||||
assert!(matches!(tt.kind, TextKind::Area { .. }));
|
||||
assert_eq!(tt.plain_text(), "Some words");
|
||||
s.execute("type.convertToPointType", &json!({})).unwrap();
|
||||
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
|
||||
assert!(matches!(tt.kind, TextKind::Point));
|
||||
assert_eq!(tt.plain_text(), "Some words");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_to_point_turns_wraps_into_breaks() {
|
||||
let mut s = session();
|
||||
let a = id_of(
|
||||
&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "one two three four five six", "area": {"width": 40, "height": 200}})).unwrap(),
|
||||
);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("type.convertToPointType", &json!({})).unwrap();
|
||||
let txt = plain(&s, a);
|
||||
assert!(txt.contains('\n'), "{txt:?}");
|
||||
assert_eq!(txt.replace('\n', " "), "one two three four five six");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_and_insert_characters() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("type.fillPlaceholder", &json!({})).unwrap();
|
||||
assert!(plain(&s, t).len() > 10);
|
||||
s.execute("text.setText", &json!({"text": "A"})).unwrap();
|
||||
s.execute("type.insert", &json!({"char": "emDash"})).unwrap();
|
||||
s.execute("type.insert", &json!({"char": "copyright"})).unwrap();
|
||||
s.execute("type.insert", &json!({"text": "z"})).unwrap();
|
||||
assert_eq!(plain(&s, t), "A—©z");
|
||||
assert!(s.execute("type.insert", &json!({"char": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_placeholder_fills_frame() {
|
||||
let mut s = session();
|
||||
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "", "area": {"width": 300, "height": 300}})).unwrap());
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("type.fillPlaceholder", &json!({})).unwrap();
|
||||
assert!(plain(&s, a).len() > 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_and_size_menu_items_use_set_style() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "x");
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("text.setStyle", &json!({"size": 36})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"font": "Inter"})).unwrap();
|
||||
let NodeKind::Text(tt) = &node(&s, t).kind else { panic!() };
|
||||
assert_eq!(tt.first_style().size, 36.0);
|
||||
assert_eq!(tt.first_style().font_family, "Inter");
|
||||
}
|
||||
|
||||
// ---------- View → Guides ----------
|
||||
|
||||
#[test]
|
||||
fn make_release_clear_guides() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("view.guides.make", &json!({})).unwrap()["count"], 1);
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { guide: true, .. }));
|
||||
assert!(selected(&s).is_empty());
|
||||
assert_eq!(s.execute("view.guides.release", &json!({})).unwrap()["count"], 1);
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { guide: false, .. }));
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("view.guides.clear", &json!({})).unwrap()["count"], 2);
|
||||
assert!(s.doc().unwrap().doc.guides.is_empty());
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_guides_and_lock() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"], 0);
|
||||
s.execute("guide.move", &json!({"index": 0, "pos": 70})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 70.0);
|
||||
assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap()["locked"], true);
|
||||
assert!(s.guides_locked());
|
||||
assert!(matches!(s.execute("guide.remove", &json!({"index": 0})), Err(EngineError::Disabled(..))));
|
||||
s.execute("view.guides.lock", &json!({})).unwrap();
|
||||
s.execute("guide.remove", &json!({"index": 0})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.guides.is_empty());
|
||||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
// ---------- File ----------
|
||||
|
||||
#[test]
|
||||
fn close_all_documents() {
|
||||
let mut s = session();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("file.closeAll", &json!({})).unwrap()["closed"], 2);
|
||||
assert!(s.active().is_none());
|
||||
assert!(s.execute("file.closeAll", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_color_mode_converts_colours() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
fill(&mut s, a, "#ff0000");
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk);
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Rgb);
|
||||
assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_info_sets_title() {
|
||||
let mut s = session();
|
||||
let r = s.execute("file.info", &json!({"title": "Poster"})).unwrap();
|
||||
assert_eq!(r["title"], "Poster");
|
||||
assert_eq!(s.doc().unwrap().doc.title, "Poster");
|
||||
assert_eq!(s.execute("file.info", &json!({})).unwrap()["colorMode"], "rgb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_new_command_has_params_doc_and_menu() {
|
||||
for id in [
|
||||
"object.lock.above",
|
||||
"object.transformEach",
|
||||
"object.rasterize",
|
||||
"object.blend.make",
|
||||
"edit.colors.invert",
|
||||
"edit.findReplace",
|
||||
"select.same.fontSize",
|
||||
"type.changeCase",
|
||||
"view.guides.make",
|
||||
"file.closeAll",
|
||||
] {
|
||||
let c = find_command(id).unwrap_or_else(|| panic!("{id}"));
|
||||
assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Panel-support commands (`cmd/panelcmds.rs`).
|
||||
|
||||
use drawcraft_color::{GradientKind, Paint};
|
||||
use drawcraft_doc::{AppearanceItem, NodeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> drawcraft_doc::Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_gradient_converts_and_keeps_stops() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute(
|
||||
"paint.editGradient",
|
||||
&json!({"stroke": false, "kind": "radial", "stops": [
|
||||
{"offset": 1.0, "color": "#0000ff"},
|
||||
{"offset": 0.0, "color": "#ff0000", "opacity": 50, "midpoint": 0.3},
|
||||
{"offset": 0.5, "color": "#00ff00"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!("not a gradient") };
|
||||
assert_eq!(g.gradient.kind, GradientKind::Radial);
|
||||
assert_eq!(g.gradient.stops.len(), 3);
|
||||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
|
||||
assert!((g.gradient.stops[0].opacity - 0.5).abs() < 1e-6);
|
||||
assert!((g.gradient.stops[0].midpoint - 0.3).abs() < 1e-6);
|
||||
// One undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(node(&s, id).appearance.fill_paint().color().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_gradient_angle_reverse_and_errors() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("paint.editGradient", &json!({"stroke": false, "angle": 450})).unwrap();
|
||||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||||
assert!((g.angle - 90.0).abs() < 1e-9);
|
||||
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
|
||||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000");
|
||||
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err());
|
||||
assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_duplicate_and_move() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let n0 = node(&s, id).appearance.items.len();
|
||||
s.execute("appearance.duplicateItem", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(node(&s, id).appearance.items.len(), n0 + 1);
|
||||
let last = node(&s, id).appearance.items.len() - 1;
|
||||
s.execute("appearance.moveItem", &json!({"from": last, "to": 0})).unwrap();
|
||||
assert!(matches!(node(&s, id).appearance.items[0], AppearanceItem::Stroke(_)));
|
||||
assert!(s.execute("appearance.duplicateItem", &json!({"index": 99})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards_reorder_and_duplicate() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"name": "Second"})).unwrap();
|
||||
let r = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(r["index"], 2);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards.len(), 3);
|
||||
assert!(d.artboards[2].rect.x0 >= d.artboards[1].rect.x1);
|
||||
s.execute("artboard.reorder", &json!({"index": 1, "to": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_groups_duplicate_sort() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Zed", "color": "#123456"})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Alpha", "color": "#654321"})).unwrap();
|
||||
let r = s.execute("swatch.newGroup", &json!({"name": "Mine", "swatches": ["Zed"], "colors": ["#ff0000"]})).unwrap();
|
||||
assert_eq!(r["name"], "Mine");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let g = d.swatch_groups.iter().find(|g| g.name == "Mine").unwrap();
|
||||
assert_eq!(g.swatches.len(), 2);
|
||||
assert!(!d.swatches.iter().any(|x| x.name == "Zed"));
|
||||
let r = s.execute("swatch.duplicate", &json!({"name": "Alpha"})).unwrap();
|
||||
assert_eq!(r["name"], "Alpha copy");
|
||||
s.execute("swatch.sortByName", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.swatches[0].name.starts_with('['));
|
||||
let names: Vec<_> = d.swatches.iter().skip(1).map(|x| x.name.to_lowercase()).collect();
|
||||
let mut sorted = names.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(names, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_style_delete_duplicate() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graphicStyle.new", &json!({"name": "S1"})).unwrap();
|
||||
assert_eq!(s.execute("graphicStyle.duplicate", &json!({"name": "S1"})).unwrap()["name"], "S1 copy");
|
||||
s.execute("graphicStyle.delete", &json!({"name": "S1"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.graphic_styles.iter().all(|g| g.name != "S1"));
|
||||
assert!(s.execute("graphicStyle.delete", &json!({"name": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_set_format() {
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hello"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute(
|
||||
"text.setFormat",
|
||||
&json!({"ids": [id.0], "kerning": 20, "baselineShift": 3, "hScale": 120, "rotation": 370, "underline": true, "spaceBefore": 6, "hyphenate": true}),
|
||||
)
|
||||
.unwrap();
|
||||
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
||||
let st = t.first_style();
|
||||
assert_eq!(st.kerning, Some(20.0));
|
||||
assert_eq!(st.baseline_shift, 3.0);
|
||||
assert_eq!(st.h_scale, 120.0);
|
||||
assert!((st.rotation - 10.0).abs() < 1e-9);
|
||||
assert!(st.underline);
|
||||
assert_eq!(t.para.space_before, 6.0);
|
||||
assert!(t.para.hyphenate);
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "kerning": "auto"})).unwrap();
|
||||
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
||||
assert_eq!(t.first_style().kerning, None);
|
||||
assert!(s.execute("text.setFormat", &json!({"ids": [id.0]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_advanced_scale_swap_flip() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"startArrow": "Triangle", "profile": "taperEnd"})).unwrap();
|
||||
s.execute("stroke.setAdvanced", &json!({"arrowScale": [50, 200]})).unwrap();
|
||||
s.execute("stroke.setAdvanced", &json!({"swapArrows": true, "flipProfile": "along"})).unwrap();
|
||||
let n = node(&s, id);
|
||||
let st = n.appearance.stroke().unwrap();
|
||||
assert_eq!(st.start_arrow, None);
|
||||
assert_eq!(st.end_arrow, Some(drawcraft_doc::Arrowhead::Triangle));
|
||||
assert_eq!(st.arrow_scale, (200.0, 50.0));
|
||||
assert_eq!(st.profile.as_ref().unwrap().points, drawcraft_doc::WidthProfile::taper_start().points);
|
||||
assert!(s.execute("stroke.setAdvanced", &json!({})).is_err());
|
||||
assert!(s.execute("stroke.setAdvanced", &json!({"flipProfile": "sideways"})).is_err());
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Every-command sweep: each registered command, in three fixture states, with `{}` and with fuzzed
|
||||
//! junk params, must return Ok or Err (never panic), never leave an interaction open, and never
|
||||
//! corrupt the tree.
|
||||
|
||||
use drawcraft_engine::{Session, command_specs};
|
||||
use drawcraft_testkit::catch_quiet;
|
||||
use drawcraft_testkit::fixtures::Fixture;
|
||||
use drawcraft_testkit::invariants::{check_all, check_session, doc_json};
|
||||
use drawcraft_testkit::strategies::{junk_params, junk_values, param_keys};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Commands that replace/close the active document or touch the filesystem are fine to call, but a
|
||||
/// few need their paths redirected into a temp dir (handled by `junk_params`).
|
||||
fn safe_path() -> String {
|
||||
drawcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Run one call; returns a failure description or None.
|
||||
fn probe(fx: Fixture, id: &str, params: &Value) -> Option<String> {
|
||||
let mut s = fx.session();
|
||||
let r = catch_quiet(|| s.execute(id, params));
|
||||
match r {
|
||||
Err(msg) => Some(format!("PANIC {id} {params} [{fx:?}]: {msg}")),
|
||||
Ok(_) => {
|
||||
if s.in_interaction() {
|
||||
return Some(format!("{id} {params} [{fx:?}]: left an interaction open"));
|
||||
}
|
||||
check_session(&s).err().map(|e| format!("{id} {params} [{fx:?}]: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_well_formed() {
|
||||
let specs = command_specs();
|
||||
assert!(specs.len() > 150, "only {} commands", specs.len());
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for c in specs {
|
||||
assert!(ids.insert(c.id), "duplicate command id {}", c.id);
|
||||
assert!(!c.label.is_empty(), "{} has no label", c.id);
|
||||
assert!(c.params.starts_with('{') || c.params.starts_with("same as"), "{}: params doc `{}`", c.id, c.params);
|
||||
assert!(c.id.contains('.'), "{}: ids are namespaced", c.id);
|
||||
}
|
||||
// Every command reports enablement without a document.
|
||||
let s = Session::new();
|
||||
for info in s.commands() {
|
||||
assert_eq!(info.enabled, info.disabled_reason.is_none(), "{}", info.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_with_empty_params_in_every_fixture() {
|
||||
let mut failures = vec![];
|
||||
for fx in Fixture::ALL {
|
||||
for c in command_specs() {
|
||||
if let Some(f) = probe(fx, c.id, &json!({})) {
|
||||
failures.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_without_a_document() {
|
||||
let mut failures = vec![];
|
||||
for c in command_specs() {
|
||||
let mut s = Session::new();
|
||||
match catch_quiet(|| s.execute(c.id, &json!({}))) {
|
||||
Err(p) => failures.push(format!("PANIC {} with no document: {p}", c.id)),
|
||||
// Anything that needs a document must say so via enablement, not fail later.
|
||||
Ok(Err(drawcraft_engine::EngineError::NoDocument)) => failures.push(format!("{}: enabled without a document but needs one", c.id)),
|
||||
Ok(_) => {}
|
||||
}
|
||||
if s.in_interaction() {
|
||||
failures.push(format!("{}: interaction left open", c.id));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the
|
||||
/// junk sweep so the rest of the sweep stays meaningful.
|
||||
const KNOWN_BUGS: &[(&str, &str)] = &[
|
||||
("object.scale", "sx"),
|
||||
("object.transformEach", "scaleH"),
|
||||
("object.transformEach", "scaleV"),
|
||||
("object.distributeSpacing", "spacing"),
|
||||
("artboard.setProps", "x"),
|
||||
("artboard.setProps", "y"),
|
||||
("artboard.setProps", "width"),
|
||||
("artboard.setProps", "height"),
|
||||
];
|
||||
|
||||
fn known_bug(id: &str, p: &Value) -> bool {
|
||||
KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some())
|
||||
}
|
||||
|
||||
/// Regression: huge column counts used to panic with `capacity overflow`.
|
||||
#[test]
|
||||
fn artboard_rearrange_huge_columns() {
|
||||
for fx in Fixture::ALL {
|
||||
for cols in [json!(u64::MAX), json!(1e308)] {
|
||||
let p = json!({ "columns": cols });
|
||||
assert_eq!(probe(fx, "artboard.rearrange", &p), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bug_huge_transform_makes_document_unloadable() {
|
||||
for (id, p) in [
|
||||
("object.scale", json!({"sx": 1e308})),
|
||||
("object.transformEach", json!({"scaleH": 1e308})),
|
||||
("object.distributeSpacing", json!({"spacing": 1e308})),
|
||||
] {
|
||||
let mut s = Fixture::Multi.session();
|
||||
if s.execute(id, &p).is_ok() {
|
||||
drawcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bug_artboard_huge_props_break_svg() {
|
||||
for key in ["x", "y", "width", "height"] {
|
||||
let mut s = Fixture::Single.session();
|
||||
let p = json!({"index": 0, key: 1e308});
|
||||
if s.execute("artboard.setProps", &p).is_ok() {
|
||||
drawcraft_testkit::invariants::check_svg_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{p}: {}", &e[..e.len().min(200)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands too slow to fuzz exhaustively get a reduced junk set (they still run).
|
||||
fn heavy(id: &str) -> bool {
|
||||
id.starts_with("document.serialize") || id.starts_with("document.export") || id == "document.open"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_with_junk_params() {
|
||||
let path = safe_path();
|
||||
let mut failures = vec![];
|
||||
let mut calls = 0usize;
|
||||
for fx in Fixture::ALL {
|
||||
for c in command_specs() {
|
||||
let mut cases = junk_params(c.params, &path);
|
||||
if heavy(c.id) {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for p in &cases {
|
||||
if known_bug(c.id, p) {
|
||||
continue;
|
||||
}
|
||||
calls += 1;
|
||||
if let Some(f) = probe(fx, c.id, p) {
|
||||
failures.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(calls > 5_000, "only {calls} calls");
|
||||
failures.sort();
|
||||
failures.dedup();
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} failures (of {calls} calls):\n{}",
|
||||
failures.len(),
|
||||
failures.iter().take(60).cloned().collect::<Vec<_>>().join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// Junk inside the obvious structured params: ids arrays, anchors, matrices, colours, stops.
|
||||
#[test]
|
||||
fn structured_junk() {
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
("select.set", json!({"ids": [0, u64::MAX, -1, "x", null, 1.5]})),
|
||||
("select.add", json!({"ids": [[1], {"a": 1}]})),
|
||||
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
|
||||
("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})),
|
||||
("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})),
|
||||
("object.transform", json!({"matrix": [1, 2, 3]})),
|
||||
("object.scale", json!({"sx": 0, "sy": 0})),
|
||||
("object.scale", json!({"sx": -100, "sy": 1e308})),
|
||||
("object.rotate", json!({"angle": 1e308, "origin": [1e308, -1e308]})),
|
||||
("object.distort", json!({"corners": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
|
||||
("object.distort", json!({"corners": [[0, 0]], "from": [0, 0, 0, 0]})),
|
||||
("path.create", json!({"anchors": []})),
|
||||
("path.create", json!({"anchors": [{"x": 1e308, "y": -1e308}, {"x": 0, "y": 0, "in": [1e308, 1e308]}], "closed": true})),
|
||||
("path.create", json!({"d": "M 0 0 L"})),
|
||||
("path.create", json!({"d": "M0 0 C 1e308 1e308 -1e308 -1e308 0 0 Z"})),
|
||||
("path.create", json!({"d": "Z Z Z m 1 1 z"})),
|
||||
("path.setAnchors", json!({"id": 2, "subpaths": [{"anchors": [], "closed": true}]})),
|
||||
("path.setAnchors", json!({"id": 1, "subpaths": [{"anchors": [{"x": 0, "y": 0}], "closed": true}]})),
|
||||
("path.insertAnchor", json!({"id": 2, "subpath": 0, "segment": 99, "t": 5})),
|
||||
("path.insertAnchor", json!({"id": 2, "subpath": 7, "segment": 0, "t": -1})),
|
||||
("path.setHandle", json!({"id": 2, "subpath": 0, "anchor": 100, "which": "in", "x": 0, "y": 0})),
|
||||
("path.removeAnchor", json!({"id": 2, "subpath": 0, "anchor": 0})),
|
||||
("path.split", json!({"id": 2, "subpath": 0, "segment": 1000, "t": 0.5})),
|
||||
("path.reshapeSegment", json!({"id": 2, "subpath": 0, "segment": 0, "t": 2.0, "dx": 1e308, "dy": 0})),
|
||||
("path.freehand", json!({"points": [[0, 0]]})),
|
||||
("path.freehand", json!({"points": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
|
||||
("path.freehand", json!({"points": [[1e308, 1e308], [-1e308, 0]], "fidelity": 0})),
|
||||
("path.curvature", json!({"points": [{"x": 0, "y": 0}]})),
|
||||
("path.knife", json!({"points": [[0, 0], [1e9, 1e9]]})),
|
||||
("path.eraseRegion", json!({"points": [[100, 80]], "size": 0})),
|
||||
("path.eraseRegion", json!({"points": [[100, 80], [101, 80]], "size": 1e9})),
|
||||
("path.blob", json!({"points": [[0, 0], [0, 0]], "size": -5})),
|
||||
("path.smoothRegion", json!({"points": [[100, 80], [120, 90]], "radius": 1e308})),
|
||||
("paint.setFill", json!({"gradient": {"stops": []}})),
|
||||
("paint.setFill", json!({"gradient": {"stops": [{"offset": 0.5, "color": "#ff0000"}]}})),
|
||||
(
|
||||
"paint.setFill",
|
||||
json!({"gradient": {"kind": "radial", "stops": [{"offset": -5, "color": [1, 0, 0]}, {"offset": 99, "color": {"gray": 50}}], "angle": 1e308}}),
|
||||
),
|
||||
("paint.setFill", json!({"color": [1e308, -1e308, 2]})),
|
||||
("paint.setFill", json!({"color": {"c": 200, "m": -1, "y": 0, "k": 1e9}})),
|
||||
("paint.setGradientGeom", json!({"start": [0, 0], "end": [0, 0]})),
|
||||
("stroke.set", json!({"weight": -5, "dash": [0, 0, 0], "miterLimit": -1})),
|
||||
("stroke.set", json!({"weight": 1e308, "dash": [-1, -2], "dashOffset": 1e308})),
|
||||
("stroke.set", json!({"dash": [1e-12]})),
|
||||
("transparency.set", json!({"opacity": -50})),
|
||||
("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})),
|
||||
("object.setProps", json!({"opacity": -1, "blend": 5})),
|
||||
("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})),
|
||||
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
|
||||
("effect.apply", json!({"effect": "distort.transform", "params": {"copies": 1e308}})),
|
||||
("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"blur": 1e308, "opacity": -1}})),
|
||||
("effect.apply", json!({"effect": "no.such.effect"})),
|
||||
("effect.remove", json!({"index": 99})),
|
||||
("effect.setParams", json!({"index": 0, "params": null})),
|
||||
("object.path.offsetPath", json!({"offset": 1e308})),
|
||||
("object.path.offsetPath", json!({"offset": -1e308})),
|
||||
("object.path.simplify", json!({"tolerance": -1, "cornerAngle": 1e308})),
|
||||
("object.path.splitIntoGrid", json!({"rows": 500, "columns": 1, "gutter": 1e308})),
|
||||
("object.path.splitIntoGrid", json!({"rows": 0.4})),
|
||||
("object.setBounds", json!({"width": 0, "height": 0})),
|
||||
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
|
||||
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
|
||||
("artboard.new", json!({"width": 0, "height": -1})),
|
||||
("artboard.delete", json!({"index": 0})),
|
||||
("artboard.move", json!({"index": 0, "dx": 1e308, "dy": 0, "moveArt": true})),
|
||||
("node.move", json!({"id": 1, "parent": 2, "index": 0})),
|
||||
("node.move", json!({"id": 2, "parent": 2, "index": 0})),
|
||||
("node.move", json!({"id": 1, "parent": null, "index": 1000})),
|
||||
("layer.setProps", json!({"id": 1, "color": 1000})),
|
||||
("layer.newSublayer", json!({"parent": 2})),
|
||||
("text.create", json!({"x": 0, "y": 0, "text": "\u{0}\u{FFFF}\u{1F600}", "size": -1})),
|
||||
("text.create", json!({"x": 0, "y": 0, "text": "a", "size": 1e308, "area": {"width": -1, "height": 0}})),
|
||||
("text.setStyle", json!({"size": 0, "leading": -1, "tracking": 1e308})),
|
||||
("document.setUnits", json!({"units": "Parsecs"})),
|
||||
("document.open", json!({"name": "x.svg", "dataBase64": "!!!"})),
|
||||
("document.open", json!({"name": "x.svg", "dataBase64": "PHN2Zz4="})),
|
||||
("document.open", json!({"name": "x.pdf", "dataBase64": "JVBERi0xLjQK"})),
|
||||
("document.open", json!({"name": "x.drawcraft", "dataBase64": "e30="})),
|
||||
("document.activate", json!({"index": 99})),
|
||||
("file.close", json!({"index": 99})),
|
||||
("file.new", json!({"width": -1, "height": 0, "artboards": 1000000})),
|
||||
("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})),
|
||||
("command.batch", json!({"commands": "nope"})),
|
||||
];
|
||||
let mut failures = vec![];
|
||||
for fx in Fixture::ALL {
|
||||
for (id, p) in &cases {
|
||||
if let Some(f) = probe(fx, id, p) {
|
||||
failures.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// After a successful fuzzed call, the document still round-trips and exports.
|
||||
#[test]
|
||||
fn fuzzed_calls_keep_documents_serializable() {
|
||||
let path = safe_path();
|
||||
let mut failures = vec![];
|
||||
for c in command_specs() {
|
||||
if heavy(c.id) || c.id.starts_with("file.") {
|
||||
continue;
|
||||
}
|
||||
for v in junk_values().iter().take(8) {
|
||||
for k in param_keys(c.params) {
|
||||
let k = if k == "path" { continue } else { k };
|
||||
let mut s = Fixture::Multi.session();
|
||||
let p = json!({ k.clone(): v });
|
||||
if known_bug(c.id, &p) {
|
||||
continue;
|
||||
}
|
||||
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
|
||||
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
|
||||
failures.push(format!("{} {p}: {e}", c.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = path;
|
||||
failures.sort();
|
||||
failures.dedup();
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
|
||||
/// Undo after any successful command in the Multi fixture restores the exact document.
|
||||
#[test]
|
||||
fn every_command_undoes_exactly() {
|
||||
let mut failures = vec![];
|
||||
for c in command_specs() {
|
||||
if matches!(c.id, "edit.undo" | "edit.redo") || c.id.starts_with("file.") || c.id.starts_with("document.") {
|
||||
continue;
|
||||
}
|
||||
let mut s = Fixture::Multi.session();
|
||||
let before = doc_json(&s.doc().unwrap().doc);
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
let Ok(Ok(_)) = catch_quiet(|| s.execute(c.id, &json!({}))) else { continue };
|
||||
if s.doc().unwrap().history.undo.len() > depth {
|
||||
let _ = s.execute("edit.undo", &json!({}));
|
||||
let after = doc_json(&s.doc().unwrap().doc);
|
||||
if after != before {
|
||||
failures.push(format!("{}: {}", c.id, drawcraft_testkit::invariants::first_diff(&before, &after, "$")));
|
||||
}
|
||||
} else if doc_json(&s.doc().unwrap().doc) != before {
|
||||
failures.push(format!("{}: changed the document without an undo step", c.id));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Minimal reproductions of engine bugs found by the property tests. Each is `#[ignore]`d with a
|
||||
//! `BUG:` reason until fixed; remove the attribute when the fix lands.
|
||||
|
||||
use drawcraft_engine::Session;
|
||||
use drawcraft_testkit::fixtures::{exec, rect, select, session};
|
||||
use drawcraft_testkit::invariants::{check_native_roundtrip_exact, check_session, doc_json, first_diff};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn bug_group_inside_compound() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
select(&mut s, &[a, b]);
|
||||
exec(&mut s, "object.compoundPath.make", json!({}));
|
||||
select(&mut s, &[a]);
|
||||
let _ = s.execute("object.group", &json!({}));
|
||||
check_session(&s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bug_nested_commands_double_journal() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
exec(&mut s, "transparency.set", json!({"opacity": 0}));
|
||||
exec(&mut s, "edit.undo", json!({}));
|
||||
let ids: Vec<&str> = s.journal.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["file.new", "shape.rectangle", "transparency.set", "edit.undo"], "journal: {ids:?}");
|
||||
let mut r = Session::new();
|
||||
for (id, p) in &s.journal.clone() {
|
||||
let _ = r.execute(id, p);
|
||||
}
|
||||
let (want, got) = (doc_json(&s.doc().unwrap().doc), doc_json(&r.doc().unwrap().doc));
|
||||
assert_eq!(want, got, "{}", first_diff(&want, &got, "$"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bug_native_roundtrip_not_bit_exact() {
|
||||
let mut s = session();
|
||||
exec(&mut s, "shape.ellipse", json!({"x": 100.1, "y": 60.3, "width": 120.7, "height": 120.9}));
|
||||
exec(&mut s, "object.rotate", json!({"angle": 33.3}));
|
||||
check_native_roundtrip_exact(&s.doc().unwrap().doc).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bug_bring_to_front_across_layers_underflows() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
exec(&mut s, "layer.new", json!({}));
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
for cmd in ["object.arrange.bringToFront", "object.arrange.bringForward"] {
|
||||
select(&mut s, &[a, b]);
|
||||
let r = drawcraft_testkit::catch_quiet(|| s.execute(cmd, &json!({})));
|
||||
assert!(r.is_ok(), "{cmd} panicked: {:?}", r.err());
|
||||
check_session(&s).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Model-based / stateful property tests: random command sequences drawn from the registry, with
|
||||
//! structural invariants checked after every step and history/serialization invariants at the end.
|
||||
|
||||
use drawcraft_testkit::fixtures::{self, exec};
|
||||
use drawcraft_testkit::invariants::{self, check_all, check_session, doc_json, first_diff, redo_all, undo_all};
|
||||
use drawcraft_testkit::strategies::{Op, arb_ops};
|
||||
use proptest::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn run_sequence(ops: &[Op], full_checks_every: usize) -> Result<(), TestCaseError> {
|
||||
let mut s = fixtures::session();
|
||||
let initial = doc_json(&s.doc().unwrap().doc);
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let (id, params) = op.command(&s);
|
||||
// Any result is fine; panics are not.
|
||||
let _ = s.execute(&id, ¶ms);
|
||||
let check = if i % full_checks_every == 0 || i + 1 == ops.len() { check_all(&mut s) } else { check_session(&s) };
|
||||
if let Err(e) = check {
|
||||
return Err(TestCaseError::fail(format!("after step {i} `{id}` {params}: {e}")));
|
||||
}
|
||||
}
|
||||
// Reach the fully redone state first (the sequence may end with undos).
|
||||
redo_all(&mut s);
|
||||
let fin = doc_json(&s.doc().unwrap().doc);
|
||||
let n = undo_all(&mut s);
|
||||
let back = doc_json(&s.doc().unwrap().doc);
|
||||
prop_assert!(back == initial, "undo all ({n} steps) != initial: {}", first_diff(&initial, &back, "$"));
|
||||
check_session(&s).map_err(TestCaseError::fail)?;
|
||||
let m = redo_all(&mut s);
|
||||
prop_assert_eq!(n, m, "redo count differs from undo count");
|
||||
let again = doc_json(&s.doc().unwrap().doc);
|
||||
prop_assert!(again == fin, "redo all != final: {}", first_diff(&fin, &again, "$"));
|
||||
check_all(&mut s).map_err(TestCaseError::fail)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 96, failure_persistence: None, max_shrink_iters: 2000, ..ProptestConfig::default() })]
|
||||
|
||||
/// ~60 random commands; cheap invariants every step, round trips every 10 steps.
|
||||
#[test]
|
||||
fn random_command_sequences(ops in arb_ops(40..80)) {
|
||||
run_sequence(&ops, 10)?;
|
||||
}
|
||||
|
||||
/// Shorter sequences with the full check (save/load, SVG, inspect) after every step.
|
||||
#[test]
|
||||
fn short_sequences_full_checks(ops in arb_ops(5..20)) {
|
||||
run_sequence(&ops, 1)?;
|
||||
}
|
||||
|
||||
/// Replaying the journal of a random session into a fresh session reproduces the document.
|
||||
#[test]
|
||||
fn journal_replay_reproduces_document(ops in arb_ops(10..40)) {
|
||||
let mut s = fixtures::session();
|
||||
for op in &ops {
|
||||
let _ = op.apply(&mut s);
|
||||
}
|
||||
let want = doc_json(&s.doc().unwrap().doc);
|
||||
let journal = s.journal.clone();
|
||||
let mut r = drawcraft_engine::Session::new();
|
||||
for (id, p) in &journal {
|
||||
let _ = r.execute(id, p);
|
||||
}
|
||||
// The journal includes clipboard state implicitly (copy/cut are journaled), so the replay
|
||||
// must land on the same document.
|
||||
let got = doc_json(&r.doc().unwrap().doc);
|
||||
prop_assert!(got == want, "replay differs: {}", first_diff(&want, &got, "$"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_fixture_satisfies_invariants_and_undoes_to_blank() {
|
||||
let mut s = fixtures::rich_session();
|
||||
check_all(&mut s).unwrap();
|
||||
let fin = doc_json(&s.doc().unwrap().doc);
|
||||
let n = undo_all(&mut s);
|
||||
assert!(n >= 10, "expected a real history, got {n} steps");
|
||||
let blank = doc_json(&fixtures::session_with(500.0, 400.0).doc().unwrap().doc);
|
||||
// Titles differ ("Untitled-1" in both sessions since each has its own counter).
|
||||
assert_eq!(doc_json(&s.doc().unwrap().doc), blank, "{}", first_diff(&blank, &doc_json(&s.doc().unwrap().doc), "$"));
|
||||
redo_all(&mut s);
|
||||
assert_eq!(doc_json(&s.doc().unwrap().doc), fin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_is_one_undo_step_and_rolls_back_on_error() {
|
||||
let mut s = fixtures::session();
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
exec(
|
||||
&mut s,
|
||||
"command.batch",
|
||||
json!({"commands": [
|
||||
{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}},
|
||||
{"command": "shape.ellipse", "params": {"x": 20, "y": 0, "width": 10, "height": 10}},
|
||||
{"command": "select.all", "params": {}},
|
||||
{"command": "object.group", "params": {}},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), 4);
|
||||
let snapshot = doc_json(&s.doc().unwrap().doc);
|
||||
// A failing step rolls the whole batch back and leaves no interaction open.
|
||||
let r = s.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}},
|
||||
{"command": "no.such.command", "params": {}},
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(doc_json(&s.doc().unwrap().doc), snapshot);
|
||||
assert!(!s.in_interaction());
|
||||
// Nested batches are rejected, also without leaking an interaction.
|
||||
let r = s.execute("command.batch", &json!({"commands": [{"command": "command.batch", "params": {"commands": []}}]}));
|
||||
assert!(r.is_err());
|
||||
assert!(!s.in_interaction());
|
||||
exec(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_batch_is_harmless() {
|
||||
let mut s = fixtures::session();
|
||||
let before = doc_json(&s.doc().unwrap().doc);
|
||||
let _ = s.execute("command.batch", &json!({"commands": []}));
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!(doc_json(&s.doc().unwrap().doc), before);
|
||||
check_all(&mut s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_open_via_commands_roundtrips() {
|
||||
let mut s = fixtures::rich_session();
|
||||
let dir = drawcraft_testkit::temp_dir("engine-save");
|
||||
let path = dir.join("rich.drawcraft");
|
||||
exec(&mut s, "document.save", json!({"path": path.to_str().unwrap()}));
|
||||
let want = doc_json(&s.doc().unwrap().doc);
|
||||
exec(&mut s, "document.open", json!({"path": path.to_str().unwrap()}));
|
||||
assert_eq!(s.documents().len(), 2);
|
||||
let got = doc_json(&s.doc().unwrap().doc);
|
||||
// Numbers to 1e-12 (bit-exactness is a known bug: known_bugs.rs::bug_native_roundtrip_not_bit_exact).
|
||||
assert!(invariants::json_approx_eq(&got, &want, 1e-12), "{}", first_diff(&want, &got, "$"));
|
||||
check_all(&mut s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_all_formats_on_rich_doc() {
|
||||
let mut s = fixtures::rich_session();
|
||||
for f in ["drawcraft", "svg", "pdf", "png"] {
|
||||
let v = exec(&mut s, "document.serialize", json!({"format": f}));
|
||||
assert!(v.get("dataBase64").or(v.get("text")).is_some(), "{f}: {v}");
|
||||
}
|
||||
// The SVG re-imports with the same number of visible art objects or more (groups may nest).
|
||||
let svg = exec(&mut s, "document.serialize", json!({"format": "svg"}));
|
||||
let text = svg["text"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| String::from_utf8(drawcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap());
|
||||
let back = drawcraft_svg::import(&text).unwrap();
|
||||
invariants::check_document(&back).unwrap();
|
||||
assert!(back.node_count() >= 5);
|
||||
}
|
||||
@@ -15,4 +15,6 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
drawcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
drawcraft-geom = { workspace = true }
|
||||
@@ -0,0 +1,147 @@
|
||||
//! `.drawcraft` format: round trips, robustness against garbage, versioning, unknown fields.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::{Document, ImageBlob, NodeId};
|
||||
use drawcraft_format::{base64_decode, base64_encode, load, save, sniff};
|
||||
use drawcraft_testkit::fixtures;
|
||||
use drawcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq};
|
||||
use drawcraft_testkit::strategies::arb_ops;
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn rich_doc() -> Document {
|
||||
(*fixtures::rich_session().doc().unwrap().doc).clone()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 128, failure_persistence: None, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn base64_roundtrip(data in prop::collection::vec(any::<u8>(), 0..300)) {
|
||||
let s = base64_encode(&data);
|
||||
prop_assert_eq!(s.len() % 4, 0);
|
||||
prop_assert_eq!(base64_decode(&s), Some(data.clone()));
|
||||
// Whitespace is ignored.
|
||||
let spaced: String = s.chars().flat_map(|c| [c, '\n']).collect();
|
||||
prop_assert_eq!(base64_decode(&spaced), Some(data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_never_panics(s in ".{0,64}") {
|
||||
let _ = base64_decode(&s);
|
||||
}
|
||||
|
||||
/// Arbitrary bytes are rejected (or, if they happen to parse, yield a valid document) — never a panic.
|
||||
#[test]
|
||||
fn load_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..512)) {
|
||||
if let Ok(d) = load(&bytes) {
|
||||
check_document(&d).map_err(TestCaseError::fail)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutating a valid file (byte flips / truncation) never panics the loader.
|
||||
#[test]
|
||||
fn load_mutated_file_never_panics(cut in 0usize..4000, flips in prop::collection::vec((0usize..4000, any::<u8>()), 0..8)) {
|
||||
let mut bytes = save(&Document::new(100.0, 100.0), false);
|
||||
for (i, b) in flips {
|
||||
let n = bytes.len();
|
||||
bytes[i % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.min(bytes.len()));
|
||||
let _ = load(&bytes);
|
||||
}
|
||||
|
||||
/// Documents produced by random editing survive save → load (numbers to 1e-12).
|
||||
#[test]
|
||||
fn random_documents_roundtrip(ops in arb_ops(5..40)) {
|
||||
let mut s = fixtures::session();
|
||||
for op in &ops {
|
||||
let _ = op.apply(&mut s);
|
||||
}
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
check_native_roundtrip(&d).map_err(TestCaseError::fail)?;
|
||||
// Pretty and compact encodings load to the same document.
|
||||
let a = doc_json(&load(&save(&d, true)).unwrap());
|
||||
let b = doc_json(&load(&save(&d, false)).unwrap());
|
||||
prop_assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// Embedded image bytes survive the base64 round trip exactly.
|
||||
#[test]
|
||||
fn images_roundtrip(data in prop::collection::vec(any::<u8>(), 0..2000), key in "[a-z]{1,8}") {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(data.clone()) });
|
||||
let back = load(&save(&d, false)).unwrap();
|
||||
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_document_roundtrips() {
|
||||
let d = rich_doc();
|
||||
check_native_roundtrip(&d).unwrap();
|
||||
let bytes = save(&d, true);
|
||||
assert!(sniff(&bytes));
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["format"], "drawcraft");
|
||||
assert_eq!(v["version"], drawcraft_format::VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_distinguishes_formats() {
|
||||
assert!(sniff(&save(&Document::new(1.0, 1.0), false)));
|
||||
assert!(!sniff(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"));
|
||||
assert!(!sniff(b"%PDF-1.7"));
|
||||
assert!(!sniff(b""));
|
||||
assert!(!sniff(&[0xff, 0xfe, 0x00]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_newer_versions_and_other_formats() {
|
||||
let mut v: Value = serde_json::from_slice(&save(&Document::new(1.0, 1.0), false)).unwrap();
|
||||
v["version"] = json!(drawcraft_format::VERSION + 1);
|
||||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::TooNew(_))));
|
||||
v["version"] = json!(drawcraft_format::VERSION);
|
||||
v["format"] = json!("other");
|
||||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::NotDrawcraft(_))));
|
||||
v["format"] = json!("drawcraft");
|
||||
v["images"] = json!({"x": {"mime": "image/png", "data": "not base64!"}});
|
||||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::BadImage(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_preserved() {
|
||||
let mut v: Value = serde_json::from_slice(&save(&Document::new(1.0, 1.0), false)).unwrap();
|
||||
v["document"]["unknown"] = json!({"futureFeature": {"a": [1, 2, 3]}});
|
||||
let d = load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
let again: Value = serde_json::from_slice(&save(&d, false)).unwrap();
|
||||
assert_eq!(again["document"]["unknown"]["futureFeature"]["a"], json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_repairs_next_id() {
|
||||
let d = rich_doc();
|
||||
let mut v: Value = serde_json::from_slice(&save(&d, false)).unwrap();
|
||||
v["document"]["next_id"] = json!(1);
|
||||
let mut back = load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
check_document(&back).unwrap();
|
||||
let fresh = back.alloc_id();
|
||||
assert!(back.node(fresh).is_none(), "allocated id {fresh} collides");
|
||||
let max = fixtures::all_ids(&back).into_iter().map(|NodeId(i)| i).max().unwrap();
|
||||
assert!(fresh.0 > max);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approx_equal_documents_compare_equal() {
|
||||
let d = rich_doc();
|
||||
assert!(json_approx_eq(&doc_json(&d), &doc_json(&d), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bug_f64_bit_exact_roundtrip() {
|
||||
check_native_roundtrip_exact(&rich_doc()).unwrap();
|
||||
let mut d = Document::new(126.86291501015239, 214.69463130731182);
|
||||
d.raster_effects_ppi = 0.1 + 0.2;
|
||||
check_native_roundtrip_exact(&d).unwrap();
|
||||
}
|
||||
@@ -14,5 +14,6 @@ serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
drawcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Geometry property tests: PathData ↔ BezPath, bounds, hit testing, anchors and shapes.
|
||||
|
||||
use drawcraft_geom::hit::{distance_to_outline, fill_contains, stroke_contains};
|
||||
use drawcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, Vec2, shapes};
|
||||
use drawcraft_testkit::geom::{grid, hausdorff, polygon_area, rect_contains, sample, sample_path};
|
||||
use drawcraft_testkit::strategies::{arb_closed_shape, arb_convex_polygon, arb_path_data, arb_point, arb_rect, arb_star_polygon, polygon_path};
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn els_close(a: &BezPath, b: &BezPath, eps: f64) -> bool {
|
||||
let (a, b) = (a.elements(), b.elements());
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let pc = |p: Point, q: Point| p.distance(q) <= eps;
|
||||
a.iter().zip(b).all(|(x, y)| match (x, y) {
|
||||
(PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => pc(*p, *q),
|
||||
(PathEl::CurveTo(p1, p2, p3), PathEl::CurveTo(q1, q2, q3)) => pc(*p1, *q1) && pc(*p2, *q2) && pc(*p3, *q3),
|
||||
(PathEl::QuadTo(p1, p2), PathEl::QuadTo(q1, q2)) => pc(*p1, *q1) && pc(*p2, *q2),
|
||||
(PathEl::ClosePath, PathEl::ClosePath) => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Winding number computed independently: ray-cast crossings of a flattened polyline.
|
||||
fn crossing_winding(bp: &BezPath, p: Point) -> i32 {
|
||||
let mut w = 0;
|
||||
let mut pts: Vec<Vec<Point>> = vec![];
|
||||
kurbo::flatten(bp, 1e-4, |el| match el {
|
||||
PathEl::MoveTo(q) => pts.push(vec![q]),
|
||||
PathEl::LineTo(q) => pts.last_mut().unwrap().push(q),
|
||||
PathEl::ClosePath => {}
|
||||
_ => unreachable!(),
|
||||
});
|
||||
for poly in pts {
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
|
||||
if a.y <= p.y {
|
||||
if b.y > p.y && (b - a).cross(p - a) > 0.0 {
|
||||
w += 1;
|
||||
}
|
||||
} else if b.y <= p.y && (b - a).cross(p - a) < 0.0 {
|
||||
w -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 256, failure_persistence: None, ..ProptestConfig::default() })]
|
||||
|
||||
/// PathData → BezPath → PathData → BezPath is the identity on the Bézier geometry.
|
||||
#[test]
|
||||
fn pathdata_bezpath_roundtrip(p in arb_path_data()) {
|
||||
let bp = p.to_bezpath();
|
||||
let back = PathData::from_bezpath(&bp);
|
||||
prop_assert!(els_close(&bp, &back.to_bezpath(), 1e-9), "{bp:?}\nvs\n{:?}", back.to_bezpath());
|
||||
prop_assert_eq!(back.subpaths.len(), p.subpaths.len());
|
||||
for (a, b) in p.subpaths.iter().zip(&back.subpaths) {
|
||||
prop_assert_eq!(a.closed, b.closed);
|
||||
prop_assert_eq!(a.anchors.len(), b.anchors.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// kurbo shapes survive BezPath → PathData → BezPath.
|
||||
#[test]
|
||||
fn kurbo_shapes_roundtrip(r in arb_rect(), rad in 0.0..20.0f64) {
|
||||
for bp in [r.to_path(1e-6), kurbo::RoundedRect::from_rect(r, rad).to_path(1e-6), kurbo::Ellipse::from_rect(r).to_path(1e-6)] {
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
prop_assert!(hausdorff(&bp, &pd.to_bezpath(), 8) < 1e-6);
|
||||
prop_assert!((pd.to_bezpath().area() - bp.area()).abs() < 1e-6 * bp.area().abs().max(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Tight bounds contain every sampled curve point and are attained (within a sampling slack).
|
||||
#[test]
|
||||
fn bounds_contain_samples(p in arb_path_data()) {
|
||||
let b = p.bounds().unwrap();
|
||||
let pts = sample_path(&p, 64);
|
||||
for q in &pts {
|
||||
prop_assert!(rect_contains(b, Rect::from_points(*q, *q), 1e-9), "{q:?} outside {b:?}");
|
||||
}
|
||||
let sb = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, q| r.union_pt(*q));
|
||||
prop_assert!(rect_contains(sb, b, 0.5), "bounds {b:?} not tight vs samples {sb:?}");
|
||||
// Control bounds contain the tight bounds.
|
||||
prop_assert!(rect_contains(p.control_bounds().unwrap(), b, 1e-9));
|
||||
}
|
||||
|
||||
/// Bounds commute with translation and uniform scale.
|
||||
#[test]
|
||||
fn bounds_transform_covariant(p in arb_path_data(), d in arb_point(-100.0, 100.0), s in 0.1..5.0f64) {
|
||||
let a = Affine::translate(d.to_vec2()) * Affine::scale(s);
|
||||
let b1 = p.transformed(a).bounds().unwrap();
|
||||
let b0 = p.bounds().unwrap();
|
||||
let want = Rect::new(b0.x0 * s + d.x, b0.y0 * s + d.y, b0.x1 * s + d.x, b0.y1 * s + d.y);
|
||||
let eps = 1e-7 * (1.0 + want.width().abs() + want.height().abs() + d.x.abs() + d.y.abs());
|
||||
prop_assert!(rect_contains(b1, want, eps) && rect_contains(want, b1, eps), "{b1:?} vs {want:?}");
|
||||
}
|
||||
|
||||
/// fill_contains agrees with an independent crossing-number winding on random points.
|
||||
#[test]
|
||||
fn fill_contains_matches_winding(p in arb_closed_shape(), q in arb_point(0.0, 220.0)) {
|
||||
let bp = p.to_bezpath();
|
||||
// Skip points on (or extremely near) the outline, where flattening decides.
|
||||
prop_assume!(distance_to_outline(&bp, q) > 1e-2);
|
||||
let w = crossing_winding(&bp, q);
|
||||
prop_assert_eq!(fill_contains(&bp, FillRule::NonZero, q), w != 0);
|
||||
prop_assert_eq!(fill_contains(&bp, FillRule::EvenOdd, q), w % 2 != 0);
|
||||
}
|
||||
|
||||
/// Even-odd ⊆ non-zero; reversing a path never changes containment.
|
||||
#[test]
|
||||
fn fill_rules_and_reverse(p in arb_path_data(), q in arb_point(0.0, 200.0)) {
|
||||
let bp = p.to_bezpath();
|
||||
prop_assume!(distance_to_outline(&bp, q) > 1e-3);
|
||||
if fill_contains(&bp, FillRule::EvenOdd, q) {
|
||||
prop_assert!(fill_contains(&bp, FillRule::NonZero, q));
|
||||
}
|
||||
let mut r = p.clone();
|
||||
r.reverse();
|
||||
let rb = r.to_bezpath();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
prop_assert_eq!(fill_contains(&bp, rule, q), fill_contains(&rb, rule, q));
|
||||
}
|
||||
// Reversal flips signed area, and reversing twice is the identity.
|
||||
prop_assert!((rb.area() + bp.area()).abs() < 1e-6 * bp.area().abs().max(1.0));
|
||||
r.reverse();
|
||||
prop_assert_eq!(r, p);
|
||||
}
|
||||
|
||||
/// Convex polygon containment equals the half-plane test.
|
||||
#[test]
|
||||
fn convex_polygon_hit(pts in arb_convex_polygon(), q in arb_point(0.0, 200.0)) {
|
||||
let bp = polygon_path(&pts).to_bezpath();
|
||||
prop_assume!(distance_to_outline(&bp, q) > 1e-6);
|
||||
let n = pts.len();
|
||||
let sign = (pts[1] - pts[0]).cross(pts[2] - pts[1]).signum();
|
||||
let inside = (0..n).all(|i| (pts[(i + 1) % n] - pts[i]).cross(q - pts[i]) * sign > 0.0);
|
||||
prop_assert_eq!(fill_contains(&bp, FillRule::NonZero, q), inside);
|
||||
}
|
||||
|
||||
/// Points sampled on the outline are within any positive stroke; distance is ~0 there.
|
||||
#[test]
|
||||
fn stroke_contains_outline(p in arb_path_data(), w in 0.1..10.0f64) {
|
||||
let bp = p.to_bezpath();
|
||||
for q in sample(&bp, 5) {
|
||||
prop_assert!(distance_to_outline(&bp, q) < 1e-6);
|
||||
prop_assert!(stroke_contains(&bp, w, 0.0, q));
|
||||
}
|
||||
}
|
||||
|
||||
/// A point offset along the normal by more than half the width is outside the stroke of an
|
||||
/// isolated line.
|
||||
#[test]
|
||||
fn stroke_excludes_far_points(a in arb_point(0.0, 100.0), d in arb_point(10.0, 100.0), w in 0.5..10.0f64, t in 0.1..0.9f64) {
|
||||
let b = a + d.to_vec2();
|
||||
let line = shapes::line(a, b).to_bezpath();
|
||||
let dir = (b - a).normalize();
|
||||
let n = Vec2::new(-dir.y, dir.x);
|
||||
let m = a.lerp(b, t);
|
||||
prop_assert!(stroke_contains(&line, w, 0.0, m + n * (w / 2.0 * 0.9)));
|
||||
prop_assert!(!stroke_contains(&line, w, 0.0, m + n * (w / 2.0 * 1.1 + 1e-6)));
|
||||
}
|
||||
|
||||
/// Inserting an anchor never changes the curve, adds exactly one anchor and keeps closure.
|
||||
#[test]
|
||||
fn insert_anchor_preserves_shape(p in arb_path_data(), t in 0.05..0.95f64, pick in 0usize..100) {
|
||||
let si = pick % p.subpaths.len();
|
||||
let seg_count = p.subpaths[si].segment_count();
|
||||
prop_assume!(seg_count > 0);
|
||||
let seg = pick % seg_count;
|
||||
let mut q = p.clone();
|
||||
q.subpaths[si].insert_anchor(seg, t);
|
||||
prop_assert_eq!(q.anchor_count(), p.anchor_count() + 1);
|
||||
prop_assert_eq!(q.subpaths[si].closed, p.subpaths[si].closed);
|
||||
prop_assert!(hausdorff(&p.to_bezpath(), &q.to_bezpath(), 16) < 1e-6);
|
||||
prop_assert!((q.length() - p.length()).abs() < 1e-3 * p.length().max(1.0));
|
||||
}
|
||||
|
||||
/// Polygon area via the shoelace formula equals the path's signed area magnitude and
|
||||
/// SubPath::area.
|
||||
#[test]
|
||||
fn polygon_area_agrees(pts in arb_star_polygon()) {
|
||||
let p = polygon_path(&pts);
|
||||
let a = polygon_area(&pts);
|
||||
prop_assert!((p.to_bezpath().area().abs() - a).abs() < 1e-6 * a.max(1.0));
|
||||
prop_assert!((p.subpaths[0].area().abs() - a).abs() < 1e-6 * a.max(1.0));
|
||||
}
|
||||
|
||||
/// Serde round trip of PathData is lossless in-memory (JSON Value).
|
||||
#[test]
|
||||
fn pathdata_serde_roundtrip(p in arb_path_data()) {
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
let back: PathData = serde_json::from_value(v).unwrap();
|
||||
prop_assert_eq!(back, p);
|
||||
}
|
||||
|
||||
/// Nearest point really is on the path and no sampled point is closer.
|
||||
#[test]
|
||||
fn nearest_is_nearest(p in arb_path_data(), q in arb_point(-20.0, 220.0)) {
|
||||
let (_, _, _, pt, d) = p.nearest(q).unwrap();
|
||||
prop_assert!((pt.distance(q) - d).abs() < 1e-6);
|
||||
let best_sample = sample_path(&p, 32).into_iter().map(|s| s.distance(q)).fold(f64::INFINITY, f64::min);
|
||||
prop_assert!(d <= best_sample + 1e-6, "nearest {d} > sampled {best_sample}");
|
||||
}
|
||||
|
||||
/// Regular polygons and stars: anchor counts, all anchors on their radius, closed.
|
||||
#[test]
|
||||
fn polygon_and_star_shapes(c in arb_point(0.0, 100.0), r in 1.0..100.0f64, n in 3u32..20, rot in -360.0..360.0f64, k in 0.1..0.9f64) {
|
||||
let p = shapes::polygon(c, r, n, rot);
|
||||
prop_assert!(p.is_closed());
|
||||
prop_assert_eq!(p.anchor_count(), n as usize);
|
||||
for (_, _, a) in p.anchors() {
|
||||
prop_assert!((a.p.distance(c) - r).abs() < 1e-9 * r.max(1.0));
|
||||
}
|
||||
let s = shapes::star(c, r, r * k, n, rot);
|
||||
prop_assert_eq!(s.anchor_count(), 2 * n as usize);
|
||||
let ds: Vec<f64> = s.anchors().map(|(_, _, a)| a.p.distance(c)).collect();
|
||||
prop_assert!(ds.iter().all(|d| (d - r).abs() < 1e-9 * r || (d - r * k).abs() < 1e-9 * r));
|
||||
}
|
||||
|
||||
/// Rectangles and ellipses: bounds equal the defining rect; area matches.
|
||||
#[test]
|
||||
fn rect_and_ellipse_shapes(r in arb_rect(), rad in 0.0..30.0f64) {
|
||||
let p = shapes::rectangle(r);
|
||||
prop_assert_eq!(p.bounds().unwrap(), r);
|
||||
prop_assert!((p.to_bezpath().area().abs() - r.area()).abs() < 1e-9 * r.area());
|
||||
let e = shapes::ellipse(r);
|
||||
let eb = e.bounds().unwrap();
|
||||
prop_assert!(rect_contains(eb, r, 1e-9) && rect_contains(r, eb, 1e-9));
|
||||
let ea = std::f64::consts::PI * r.width() * r.height() / 4.0;
|
||||
prop_assert!((e.to_bezpath().area().abs() - ea).abs() < 1e-3 * ea, "{} vs {ea}", e.to_bezpath().area());
|
||||
let rr = shapes::rounded_rectangle(r, rad);
|
||||
prop_assert!(rect_contains(r, rr.bounds().unwrap(), 1e-9));
|
||||
prop_assert!(rr.to_bezpath().area().abs() <= r.area() + 1e-9);
|
||||
}
|
||||
|
||||
/// Length of a rectangle is its perimeter; of a line, its distance.
|
||||
#[test]
|
||||
fn lengths(r in arb_rect(), a in arb_point(0.0, 100.0), b in arb_point(0.0, 100.0)) {
|
||||
prop_assert!((shapes::rectangle(r).length() - 2.0 * (r.width() + r.height())).abs() < 1e-6);
|
||||
prop_assert!((shapes::line(a, b).length() - a.distance(b)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_bezpath_merges_duplicate_closing_point() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((10.0, 0.0));
|
||||
bp.line_to((10.0, 10.0));
|
||||
bp.line_to((0.0, 0.0));
|
||||
bp.close_path();
|
||||
let p = PathData::from_bezpath(&bp);
|
||||
assert_eq!(p.anchor_count(), 3);
|
||||
assert!(p.is_closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_degenerate_paths() {
|
||||
let e = PathData::default();
|
||||
assert!(e.is_empty());
|
||||
assert!(e.bounds().is_none());
|
||||
assert_eq!(e.length(), 0.0);
|
||||
assert!(e.nearest(Point::ZERO).is_none());
|
||||
let one = PathData::from_bezpath(&{
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((5.0, 5.0));
|
||||
b
|
||||
});
|
||||
assert_eq!(one.anchor_count(), 1);
|
||||
assert_eq!(one.bounds(), Some(Rect::new(5.0, 5.0, 5.0, 5.0)));
|
||||
assert!(!fill_contains(&one.to_bezpath(), FillRule::NonZero, Point::new(5.0, 5.0)));
|
||||
let _ = grid(Rect::new(0.0, 0.0, 1.0, 1.0), 2);
|
||||
}
|
||||
@@ -18,3 +18,7 @@ drawcraft-geom = { workspace = true }
|
||||
drawcraft-doc = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
drawcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,271 @@
|
||||
//! MCP server properties (in-process, headless backend): tool schemas are valid, every tool
|
||||
//! survives junk arguments, JSON-RPC framing always answers correctly, and random editing through
|
||||
//! MCP keeps the document valid.
|
||||
|
||||
use drawcraft_doc::Document;
|
||||
use drawcraft_mcp::{Headless, Server, tool_definitions};
|
||||
use drawcraft_testkit::catch_quiet;
|
||||
use drawcraft_testkit::invariants::{check_document, doc_json};
|
||||
use drawcraft_testkit::strategies::{arb_ops, junk_values};
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn server() -> Server {
|
||||
Server::new(Box::new(Headless::with_document()))
|
||||
}
|
||||
|
||||
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
|
||||
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
|
||||
let reply = s.handle_line(&line).expect("requests get a reply");
|
||||
let v: Value = serde_json::from_str(&reply).expect("reply is JSON");
|
||||
assert_eq!(v["jsonrpc"], "2.0");
|
||||
assert_eq!(v["id"], id);
|
||||
assert!(v.get("result").is_some() != v.get("error").is_some(), "exactly one of result/error: {v}");
|
||||
v
|
||||
}
|
||||
|
||||
fn call(s: &mut Server, name: &str, args: Value) -> Value {
|
||||
rpc(s, 1, "tools/call", json!({"name": name, "arguments": args}))
|
||||
}
|
||||
|
||||
fn document(s: &mut Server) -> Document {
|
||||
let v = rpc(s, 2, "resources/read", json!({"uri": "drawcraft://document/json"}));
|
||||
let text = v["result"]["contents"][0]["text"].as_str().unwrap_or_else(|| panic!("{v}"));
|
||||
serde_json::from_str(text).expect("document JSON")
|
||||
}
|
||||
|
||||
fn doc_uri_json(s: &mut Server) -> String {
|
||||
let list = rpc(s, 3, "resources/list", json!({}));
|
||||
list["result"]["resources"].as_array().unwrap().iter().find(|r| r["name"] == "document-json").unwrap()["uri"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Minimal JSON-schema sanity: an object schema with typed properties and consistent `required`.
|
||||
fn validate_schema(name: &str, schema: &Value) {
|
||||
assert_eq!(schema["type"], "object", "{name}: inputSchema.type");
|
||||
let props = schema.get("properties").and_then(Value::as_object).unwrap_or_else(|| panic!("{name}: properties must be an object"));
|
||||
for (k, p) in props {
|
||||
let p = p.as_object().unwrap_or_else(|| panic!("{name}.{k}: property schema must be an object"));
|
||||
assert!(
|
||||
p.contains_key("type") || p.contains_key("anyOf") || p.contains_key("oneOf") || p.contains_key("enum") || p.contains_key("$ref"),
|
||||
"{name}.{k}: property has no type"
|
||||
);
|
||||
if let Some(t) = p.get("type") {
|
||||
let ok = |t: &str| ["object", "array", "string", "number", "integer", "boolean", "null"].contains(&t);
|
||||
match t {
|
||||
Value::String(t) => assert!(ok(t), "{name}.{k}: bad type {t}"),
|
||||
Value::Array(ts) => assert!(ts.iter().all(|t| t.as_str().is_some_and(ok)), "{name}.{k}: bad types"),
|
||||
_ => panic!("{name}.{k}: type must be a string"),
|
||||
}
|
||||
if t == "array" {
|
||||
assert!(p.get("items").is_none_or(Value::is_object), "{name}.{k}: items must be a schema");
|
||||
}
|
||||
if t == "object" {
|
||||
assert!(p.get("properties").is_none_or(Value::is_object), "{name}.{k}: nested properties");
|
||||
}
|
||||
}
|
||||
if let Some(e) = p.get("enum") {
|
||||
assert!(e.as_array().is_some_and(|a| !a.is_empty()), "{name}.{k}: empty enum");
|
||||
}
|
||||
}
|
||||
if let Some(req) = schema.get("required") {
|
||||
for r in req.as_array().unwrap_or_else(|| panic!("{name}: required must be an array")) {
|
||||
let r = r.as_str().unwrap();
|
||||
assert!(props.contains_key(r), "{name}: required `{r}` is not a property");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_definitions_are_valid() {
|
||||
let tools = tool_definitions();
|
||||
assert!(tools.len() >= 15);
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for t in &tools {
|
||||
let name = t["name"].as_str().expect("name");
|
||||
assert!(names.insert(name.to_string()), "duplicate tool {name}");
|
||||
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "tool name {name}");
|
||||
assert!(t["description"].as_str().is_some_and(|d| d.len() >= 10), "{name}: description");
|
||||
assert!(t["annotations"]["readOnlyHint"].is_boolean(), "{name}: readOnlyHint");
|
||||
validate_schema(name, &t["inputSchema"]);
|
||||
}
|
||||
// tools/list over the protocol returns the same list.
|
||||
let mut s = server();
|
||||
let v = rpc(&mut s, 1, "tools/list", json!({}));
|
||||
assert_eq!(v["result"]["tools"].as_array().unwrap().len(), tools.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tool_survives_empty_and_junk_arguments() {
|
||||
let dir = drawcraft_testkit::temp_dir("mcp-junk");
|
||||
let safe = dir.join("junk.drawcraft").to_string_lossy().to_string();
|
||||
let mut failures = vec![];
|
||||
for t in tool_definitions() {
|
||||
let name = t["name"].as_str().unwrap().to_string();
|
||||
let props: Vec<String> = t["inputSchema"]["properties"].as_object().unwrap().keys().cloned().collect();
|
||||
let mut cases = vec![json!({}), Value::Null, json!([]), json!("x"), json!(3)];
|
||||
for k in &props {
|
||||
for v in junk_values() {
|
||||
// Never let fuzzed strings name files; keep renders small.
|
||||
let v = if k == "path" {
|
||||
json!(safe)
|
||||
} else if k == "scale" && v.as_f64().is_some_and(|x| x.abs() > 4.0) {
|
||||
json!(4)
|
||||
} else {
|
||||
v
|
||||
};
|
||||
cases.push(json!({ k.clone(): v }));
|
||||
}
|
||||
}
|
||||
for args in cases {
|
||||
let mut s = server();
|
||||
let r = catch_quiet(|| {
|
||||
let v = call(&mut s, &name, args.clone());
|
||||
if let Some(res) = v.get("result") {
|
||||
assert!(res["content"].is_array(), "{name}: result has no content array: {v}");
|
||||
}
|
||||
check_document(&document(&mut s))
|
||||
});
|
||||
match r {
|
||||
Err(p) => failures.push(format!("PANIC {name} {args}: {p}")),
|
||||
Ok(Err(e)) => failures.push(format!("{name} {args}: {e}")),
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framing_never_panics_on_malformed_messages() {
|
||||
let mut s = server();
|
||||
for line in [
|
||||
"",
|
||||
"null",
|
||||
"[]",
|
||||
"[1,2,3]",
|
||||
"{}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":{},\"method\":\"ping\"}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":5}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"nope\"}}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"run_command\",\"arguments\":[]}}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\",\"params\":{\"uri\":\"nope://x\"}}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"1900-01-01\"}}",
|
||||
"\u{0}\u{1}",
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1e400,\"method\":\"ping\"}",
|
||||
] {
|
||||
let r = catch_quiet(|| s.handle_line(line));
|
||||
assert!(r.is_ok(), "panic on {line:?}");
|
||||
if let Ok(Some(reply)) = r {
|
||||
let v: Value = serde_json::from_str(&reply).unwrap_or_else(|e| panic!("{line:?} → invalid JSON reply {reply}: {e}"));
|
||||
assert!(v.is_object() || v.is_array());
|
||||
}
|
||||
}
|
||||
// Still alive afterwards.
|
||||
rpc(&mut s, 7, "ping", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources_are_json() {
|
||||
let mut s = server();
|
||||
let uri = doc_uri_json(&mut s);
|
||||
let v = rpc(&mut s, 1, "resources/read", json!({"uri": uri}));
|
||||
let d: Document = serde_json::from_str(v["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
|
||||
check_document(&d).unwrap();
|
||||
let list = rpc(&mut s, 2, "resources/list", json!({}));
|
||||
for r in list["result"]["resources"].as_array().unwrap() {
|
||||
let v = rpc(&mut s, 3, "resources/read", json!({"uri": r["uri"]}));
|
||||
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
|
||||
serde_json::from_str::<Value>(text).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_redo_tools_restore_documents() {
|
||||
let mut s = server();
|
||||
let d0 = doc_json(&document(&mut s));
|
||||
let v = call(&mut s, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 50, "height": 40, "fill": "#ff0000"}));
|
||||
assert!(v["result"]["isError"] != json!(true), "{v}");
|
||||
call(&mut s, "draw_shape", json!({"shape": "star", "cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
|
||||
let d2 = doc_json(&document(&mut s));
|
||||
// draw + paint are separate undo steps; undo until the original document is back.
|
||||
let mut n = 0;
|
||||
while doc_json(&document(&mut s)) != d0 {
|
||||
let v = call(&mut s, "undo", json!({}));
|
||||
assert!(v["result"]["isError"] != json!(true), "undo failed early: {v}");
|
||||
n += 1;
|
||||
assert!(n < 20);
|
||||
}
|
||||
for _ in 0..n {
|
||||
call(&mut s, "redo", json!({}));
|
||||
}
|
||||
assert_eq!(doc_json(&document(&mut s)), d2);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 24, failure_persistence: None, ..ProptestConfig::default() })]
|
||||
|
||||
/// Random engine command sequences through `run_command` keep the document valid and the
|
||||
/// server responsive; the result equals running the same commands on a local session.
|
||||
#[test]
|
||||
fn run_command_sequences_match_local_session(ops in arb_ops(5..40)) {
|
||||
let mut srv = server();
|
||||
let mut local = drawcraft_testkit::fixtures::session_with(612.0, 792.0);
|
||||
// Match the headless default document.
|
||||
let d_remote = doc_json(&document(&mut srv));
|
||||
let d_local = doc_json(&local.doc().unwrap().doc);
|
||||
prop_assume!(d_remote == d_local);
|
||||
for op in &ops {
|
||||
let (id, params) = op.command(&local);
|
||||
let a = local.execute(&id, ¶ms).is_ok();
|
||||
let v = call(&mut srv, "run_command", json!({"command": id, "params": params}));
|
||||
let b = v["result"]["isError"] != json!(true);
|
||||
prop_assert_eq!(a, b, "`{}` {}: local ok={} mcp ok={} ({})", id, params, a, b, v);
|
||||
}
|
||||
let got = document(&mut srv);
|
||||
check_document(&got).map_err(TestCaseError::fail)?;
|
||||
{ let (g, l) = (doc_json(&got), doc_json(&local.doc().unwrap().doc)); // 1e-12: JSON text round trips aren't bit-exact (serde_json without float_roundtrip; see
|
||||
// crates/format/tests/prop_format.rs::bug_f64_bit_exact_roundtrip).
|
||||
prop_assert!(drawcraft_testkit::invariants::json_approx_eq(&g, &l, 1e-12), "MCP and local sessions diverged: {}", drawcraft_testkit::invariants::first_diff(&l, &g, "$")); }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools driven directly against a Headless backend never leave an interaction open or corrupt
|
||||
/// the session, whatever the arguments. (A gesture that stops after `drag` legitimately stays open
|
||||
/// so an agent can continue it in the next call; that case is not listed.)
|
||||
#[test]
|
||||
fn tools_never_leave_interactions_open() {
|
||||
use drawcraft_mcp::call_tool;
|
||||
use drawcraft_testkit::invariants::check_session;
|
||||
let cases = [
|
||||
("pointer_gesture", json!({"tool": "rectangle", "events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "bogus", "x": 50, "y": 50}]})),
|
||||
("pointer_gesture", json!({"tool": "pen", "events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": "a", "y": 50}]})),
|
||||
(
|
||||
"pointer_gesture",
|
||||
json!({"tool": "selection", "events": [{"kind": "down", "x": 1e308, "y": -1e308}, {"kind": "drag", "x": 1e308, "y": 0}, {"kind": "up", "x": 0, "y": 0}]}),
|
||||
),
|
||||
("pointer_gesture", json!({"tool": "ellipse", "events": [{"kind": "up", "x": 0, "y": 0}]})),
|
||||
("press_key", json!({"key": "Escape"})),
|
||||
(
|
||||
"run_command",
|
||||
json!({"command": "command.batch", "params": {"commands": [{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 5, "height": 5}}, {"command": "nope"}]}}),
|
||||
),
|
||||
];
|
||||
let mut failures = vec![];
|
||||
for (name, args) in cases {
|
||||
let mut h = Headless::with_document();
|
||||
let r = catch_quiet(|| call_tool(&mut h, name, &args));
|
||||
if r.is_err() {
|
||||
failures.push(format!("PANIC {name} {args}"));
|
||||
continue;
|
||||
}
|
||||
// A finished gesture sequence must not leave a drag half-applied.
|
||||
if h.session.in_interaction() {
|
||||
failures.push(format!("{name} {args}: interaction left open"));
|
||||
}
|
||||
if let Err(e) = check_session(&h.session) {
|
||||
failures.push(format!("{name} {args}: {e}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
@@ -15,4 +15,5 @@ thiserror = { workspace = true }
|
||||
linesweeper = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
drawcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||