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>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-09-30 09:32:20 -04:00
1 parent 213b0927ee
commit 42df20ca7f
143 files changed
+12927 -642

No files matched your search

Generated
+26
View File
@@ -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",
]
+1 -1
View File
@@ -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"
+1
View File
@@ -18,4 +18,5 @@ drawcraft-mcp = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
drawcraft-testkit = { workspace = true }
serde_json = { workspace = true }
+260
View File
@@ -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();
}
+14 -7
View File
@@ -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()));
}
}
}
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+4
View File
@@ -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 }
+333
View File
@@ -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(&center(b).x)),
BlendOrder::Vertical => ids.sort_by(|a, b| center(a).y.total_cmp(&center(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 }))
}
+13 -2
View File
@@ -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")),
+499
View File
@@ -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(),
}))
}
+11 -1
View File
@@ -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()
+3
View File
@@ -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())));
}
}
File diff suppressed because it is too large. Load diff
+10
View File
@@ -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
})
}
+25 -11
View File
@@ -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(())
+552
View File
@@ -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()
}
+603
View File
@@ -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
}
+42 -6
View File
@@ -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;
+706
View File
@@ -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}");
}
}
+166
View File
@@ -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());
}
+328
View File
@@ -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"));
}
+57
View File
@@ -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();
}
}
+163
View File
@@ -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, &params);
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);
}
+2
View File
@@ -15,4 +15,6 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
drawcraft-testkit = { workspace = true }
proptest = { workspace = true }
drawcraft-geom = { workspace = true }
+147
View File
@@ -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();
}
+1
View File
@@ -14,5 +14,6 @@ serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
drawcraft-testkit = { workspace = true }
proptest = { workspace = true }
serde_json = { workspace = true }
+275
View File
@@ -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);
}
+4
View File
@@ -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 }
+271
View File
@@ -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, &params).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"));
}
+1
View File
@@ -15,4 +15,5 @@ thiserror = { workspace = true }
linesweeper = { workspace = true }
[dev-dependencies]
drawcraft-testkit = { workspace = true }
proptest = { workspace = true }
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more