diff --git a/Cargo.lock b/Cargo.lock index 6967f29e..ea2618c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1231,6 +1231,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1439,7 +1445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1455,7 +1461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1603,6 +1609,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1611,7 +1626,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1620,7 +1635,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2059,6 +2074,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.189" @@ -3667,6 +3688,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -3697,6 +3724,16 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -4271,6 +4308,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-geom", "vectorcraft-pathops", + "vectorcraft-plugins", "vectorcraft-text", ] @@ -4296,12 +4334,14 @@ dependencies = [ "vectorcraft-metafile", "vectorcraft-pathops", "vectorcraft-pdf", + "vectorcraft-plugins", "vectorcraft-render", "vectorcraft-svg", "vectorcraft-testkit", "vectorcraft-text", "vectorcraft-tools", "vectorcraft-trace", + "wat", ] [[package]] @@ -4419,6 +4459,20 @@ dependencies = [ "vectorcraft-text", ] +[[package]] +name = "vectorcraft-plugins" +version = "0.3.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-geom", + "wasmi", + "wat", +] + [[package]] name = "vectorcraft-render" version = "0.3.0" @@ -4545,11 +4599,13 @@ dependencies = [ "vectorcraft-format", "vectorcraft-geom", "vectorcraft-pdf", + "vectorcraft-plugins", "vectorcraft-render", "vectorcraft-svg", "vectorcraft-testkit", "vectorcraft-text", "vectorcraft-tools", + "wat", ] [[package]] @@ -4719,6 +4775,99 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +dependencies = [ + "leb128fmt", + "wasmparser 0.261.0", +] + +[[package]] +name = "wasmi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.228.0", +] + +[[package]] +name = "wasmi_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.13.2", + "indexmap", +] + +[[package]] +name = "wasmparser" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +dependencies = [ + "bitflags 2.13.2", + "indexmap", + "semver", +] + +[[package]] +name = "wast" +version = "261.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +dependencies = [ + "wast", +] + [[package]] name = "wayland-backend" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index 174eca7e..579950d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ vectorcraft-testkit = { path = "crates/testkit" } vectorcraft-cad = { path = "crates/cad" } vectorcraft-eps = { path = "crates/eps" } vectorcraft-metafile = { path = "crates/metafile" } +vectorcraft-plugins = { path = "crates/plugins" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -43,6 +44,12 @@ log = "0.4" proptest = "1" # tests: a TCP port bound without listening (connections to it are refused) socket2 = "0.6" +# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32 +# too. Without `wat` (text parsing is test-only) and memory64; `validate` keeps full module +# validation on; the portable dispatcher can't overflow the host stack on long-running modules. +wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] } +# Tests: WebAssembly text -> binary for the plug-in fixtures (Apache-2.0 WITH LLVM-exception). +wat = "1.228" # rendering / formats vello_cpu = "0.2" diff --git a/NOTICE b/NOTICE index 5f53d3f5..10c8c554 100644 --- a/NOTICE +++ b/NOTICE @@ -26,3 +26,6 @@ BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0). JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is based in part on the work of the Independent JPEG Group. + +WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is +under the Apache License 2.0 with the LLVM exception. diff --git a/apps/vectorcraft/src/native_menu.rs b/apps/vectorcraft/src/native_menu.rs index 022b8dff..9783a6ef 100644 --- a/apps/vectorcraft/src/native_menu.rs +++ b/apps/vectorcraft/src/native_menu.rs @@ -25,6 +25,8 @@ pub struct NativeMenu { generation: u64, /// Saved views and recent files listed (empty slots are left out, so a change rebuilds). slots: usize, + /// Plug-in registry revision the Plug-ins submenus were built for. + plugins: u64, } /// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they @@ -56,7 +58,7 @@ impl NativeMenu { .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, generation, slots: menus::listed_slots(app) } + Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app), plugins: menus::plugin_revision() } } /// Dispatch clicked items and refresh state. @@ -72,10 +74,11 @@ impl NativeMenu { return; } self.last_refresh = now; - // Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so + // Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so // accelerators and lists are current. if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation || menus::listed_slots(app) != self.slots + || menus::plugin_revision() != self.plugins { *self = NativeMenu::install(app); return; diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index b50e797b..aad25ac9 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-text = { workspace = true } +vectorcraft-plugins = { workspace = true } kurbo = { workspace = true } serde_json = { workspace = true } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index bcd0645f..1b907f68 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -18,6 +18,9 @@ //! art painting its members (its raster effects then apply to the composite). //! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and //! PDF writers. +//! - **Plug-in effects** (`plugin.`, Effect › Plug-ins) are geometry effects run by an +//! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like +//! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is. //! //! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash //! noise (`seed` parameter, default 0). @@ -125,6 +128,7 @@ const STYLIZE: &[&str] = &["Effect", "Stylize"]; const WARP: &[&str] = &["Effect", "Warp"]; const BLUR: &[&str] = &["Effect", "Blur"]; const PATHFINDER: &[&str] = &["Effect", "Pathfinder"]; +const PLUGINS: &[&str] = &["Effect", "Plug-ins"]; /// The warp styles in Illustrator's Style menu order: (id suffix, label). pub const WARP_STYLES: [(&str, &str); 15] = [ @@ -293,21 +297,47 @@ fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInf INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect()) } -/// Catalogue entry for `id`. +/// Catalogue entry for `id` (built-in, or an installed plug-in effect). pub fn effect_info(id: &str) -> Option { - catalog_index().get(id).cloned() + catalog_index().get(id).cloned().or_else(|| plugin_info(id)) +} + +/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.`). +fn plugin_info(id: &str) -> Option { + use vectorcraft_plugins::registry::intern; + let p = vectorcraft_plugins::effect::installed(id)?; + let m = p.manifest(); + let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" }; + Some(EffectInfo { + id: intern(id), + label: intern(&format!("{}{dots}", m.name)), + menu: PLUGINS, + params: intern(&m.params_doc()), + defaults: Value::Object(m.defaults()), + raster: false, + lengths: Lengths::default(), + }) +} + +/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id. +pub fn plugin_effects() -> Vec { + vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect() } /// Dialog defaults for `id` (`None` for unknown effects). pub fn default_params(id: &str) -> Option { - catalog_index().get(id).map(|e| e.defaults.clone()) + match catalog_index().get(id) { + Some(e) => Some(e.defaults.clone()), + None => plugin_info(id).map(|e| e.defaults), + } } /// `params` layered over the defaults of `id` (unknown keys are kept). pub fn merged_params(id: &str, params: &Value) -> Value { let mut out = match catalog_index().get(id).map(|e| &e.defaults) { Some(Value::Object(m)) => m.clone(), - _ => Map::new(), + Some(_) => Map::new(), + None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(), }; if let Value::Object(p) = params { for (k, v) in p { @@ -319,7 +349,9 @@ pub fn merged_params(id: &str, params: &Value) -> Value { /// A new effect record with default parameters (overridden by `params`). pub fn new_effect(id: &str, params: &Value) -> Option { - catalog_index().get(id)?; + if !catalog_index().contains_key(id) { + vectorcraft_plugins::effect::installed(id)?; + } Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) } @@ -347,8 +379,12 @@ pub fn is_raster(id: &str) -> bool { } /// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].) +/// Plug-in effects do, installed or not (a missing plug-in leaves the geometry as it is). pub fn is_geometry(id: &str) -> bool { - !is_raster(id) && !is_pathfinder(id) && id != CROP_MARKS && catalog_index().contains_key(id) + !is_raster(id) + && !is_pathfinder(id) + && id != CROP_MARKS + && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) } /// Any visible geometry effect in the list? @@ -420,6 +456,9 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { use util::*; + if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { + return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone()); + } match id { "distort.freeDistort" => distort::free_distort(path, b, p), "distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index a8ae1adf..69a493bb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,6 +24,7 @@ vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } vectorcraft-eps = { workspace = true } vectorcraft-metafile = { workspace = true } +vectorcraft-plugins = { workspace = true } image = { workspace = true } flate2 = { workspace = true } kurbo = { workspace = true } @@ -35,3 +36,4 @@ log = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } proptest = { workspace = true } +wat = { workspace = true } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8c15b915..2dde733c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}", has_doc, apply ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Effects", [], None, - "{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", + "{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin., menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", always, list ), @@ -146,6 +146,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { fn list(s: &mut Session, p: &Value) -> Result { let catalog: Vec = effects::effect_catalog() .into_iter() + .chain(effects::plugin_effects()) .map(|e| { json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster, "lengths": {"always": e.lengths.always, "absolute": e.lengths.absolute}}) diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 067f2197..fe66845b 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -623,6 +623,7 @@ pub fn open_filters() -> impl Iterator = open_filters().collect(); assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); - assert_eq!(filters.len(), 5 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets"); + assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins"); + assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins"); } #[test] diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 875cd295..8d6aaed3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -45,6 +45,7 @@ mod pathops; mod patterncmds; pub mod pdfcmds; mod place; +pub mod plugin; pub mod prefscmds; pub mod print; pub(crate) mod printadvanced; @@ -244,6 +245,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(css::specs()); v.extend(printpresets::specs()); v.extend(printtiling::specs()); + v.extend(plugin::specs()); v }) } diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs new file mode 100644 index 00000000..e307f9d0 --- /dev/null +++ b/crates/engine/src/cmd/plugin.rs @@ -0,0 +1,238 @@ +//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`). +//! +//! Plug-ins are installed per process (`plugin.install`, or every `*.wasm` in the Additional +//! Plug-ins Folder preference) and listed with `plugin.list` / `plugin.info`. Object filters run +//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a +//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any +//! effect (`effect.apply {effect: "plugin."}`) and drawn by `vectorcraft-effects`. + +use std::sync::{Arc, Mutex}; + +use serde_json::{Map, Value, json}; +use vectorcraft_plugins::{Kind, Plugin, effect, objects, registry}; + +use super::*; + +/// What a plug-in error looks like to the user. +fn err(e: vectorcraft_plugins::Error) -> EngineError { + EngineError::Other(e.to_string()) +} + +/// JSON description of an installed plug-in. +pub fn describe(p: &Plugin) -> Value { + let m = p.manifest(); + let mut v = json!({ + "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, + "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), + "size": p.size(), "source": p.source(), + }); + if m.kind == Kind::Effect { + v["effect"] = json!(effect::effect_id(&m.id)); + } + v +} + +/// The plug-in's own parameters from command params: `params` if given, else the top-level keys +/// minus the ones the command itself uses. +fn plugin_params(p: &Value) -> Value { + if let Some(v) = p.get("params") { + return v.clone(); + } + match p { + Value::Object(m) => { + Value::Object(m.iter().filter(|(k, _)| !matches!(k.as_str(), "id" | "ids")).map(|(k, v)| (k.clone(), v.clone())).collect()) + } + _ => Value::Object(Map::new()), + } +} + +fn plugin_by_id(cmd: &str, p: &Value) -> Result> { + let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?; + registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into()))) +} + +fn run(s: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.run"; + let plugin = plugin_by_id(C, p)?; + let m = plugin.manifest(); + if m.kind != Kind::Filter { + return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id)))); + } + // Bad parameters fail before anything runs. + let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + let st = s.doc()?; + let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + let inputs = objects::filter_targets(&st.doc, &selected); + let parent = st.insertion_parent(); + let bounds = st.doc.bounds_of(&inputs, false); + let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); + let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect))}); + let input = objects::encode_input(&st.doc, &inputs); + let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + let out = objects::decode(&plugin.run(&input, ¶ms).map_err(err)?).map_err(err)?; + let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?; + sel.set(ids.iter().copied()); + Ok(ids) + })?; + Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::>()})) +} + +/// Largest base64 payload `plugin.install` accepts (the module limit, encoded). +const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4; + +/// The `.wasm` extension plug-in files have (File › Open installs them). +pub const EXTS: &[&str] = &["wasm"]; + +fn install(_: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.install"; + let plugin = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) { + (_, Some(data)) => { + if data.len() > MAX_DATA_CHARS { + return Err(bad(C, "`dataBase64` is larger than the module limit")); + } + let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?; + let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?; + match str_param(p, "name") { + Some(name) => plugin.with_source(name), + None => plugin, + } + } + (Some(path), None) => load_path(path)?, + (None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")), + }; + if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() { + return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); + } + Ok(describe(®istry::install(plugin))) +} + +#[cfg(not(target_arch = "wasm32"))] +fn load_path(path: &str) -> Result { + registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err) +} + +#[cfg(target_arch = "wasm32")] +fn load_path(_: &str) -> Result { + Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into())) +} + +/// The plug-in folder last loaded from the preferences, and what happened. +static FOLDER: Mutex> = Mutex::new(None); + +/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since +/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only. +pub(crate) fn sync_prefs(s: &Session) { + let folder = s.prefs.plugins_folder.trim(); + if folder.is_empty() { + return; + } + let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + if last.as_ref().is_some_and(|(f, _)| f == folder) { + return; + } + *last = Some((folder.to_string(), load_folder(folder))); +} + +#[cfg(not(target_arch = "wasm32"))] +fn load_folder(folder: &str) -> Value { + match registry::load_folder(std::path::Path::new(folder)) { + Ok(r) => { + json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) + } + Err(e) => json!({"folder": folder, "error": e.to_string()}), + } +} + +#[cfg(target_arch = "wasm32")] +fn load_folder(folder: &str) -> Value { + json!({"folder": folder, "error": "plug-in folders are not available on the web"}) +} + +fn reload(s: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.reload"; + let folder = match p.get("path") { + Some(v) => v.as_str().map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "`path` must be a folder"))?.to_string(), + None => s.prefs.plugins_folder.trim().to_string(), + }; + if folder.is_empty() { + return Err(bad(C, "no folder: pass `path` or set Preferences › Performance & Storage › Additional Plug-ins Folder")); + } + let report = load_folder(&folder); + if let Some(e) = report.get("error").and_then(Value::as_str) { + return Err(EngineError::Other(e.to_string())); + } + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone())); + Ok(report) +} + +fn list(_: &mut Session, _: &Value) -> Result { + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone()); + Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder})) +} + +fn info(_: &mut Session, p: &Value) -> Result { + let plugin = plugin_by_id("plugin.info", p)?; + let mut v = describe(&plugin); + v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null); + v["defaults"] = Value::Object(plugin.manifest().defaults()); + v["lastError"] = json!(effect::last_error(plugin.id())); + Ok(v) +} + +fn remove(_: &mut Session, p: &Value) -> Result { + let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "plugin.list", + "List Plug-ins", + [], + None, + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + always, + list + ), + cmd!( + query "plugin.info", + "Plug-in Info", + [], + None, + "{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}", + always, + info + ), + cmd!( + "plugin.run", + "Run Plug-in", + [], + None, + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection)} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + has_doc, + run + ), + cmd!( + query "plugin.install", + "Install Plug-in", + [], + None, + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + always, + install + ), + cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove), + cmd!( + query "plugin.reload", + "Reload Plug-ins", + ["Object", "Plug-ins"], + None, + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}", + always, + reload + ), + ] +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index fb56a320..9e9cb2bc 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -455,6 +455,8 @@ impl Session { st.revision += 1; } } + // Plug-ins in a newly set Additional Plug-ins Folder are installed. + super::plugin::sync_prefs(self); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fa851c08..677bc655 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1228,6 +1228,8 @@ mod tests_pdfraster; #[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_plugins; +#[cfg(test)] mod tests_prefs; #[cfg(test)] mod tests_previewbounds; diff --git a/crates/engine/src/tests_plugins.rs b/crates/engine/src/tests_plugins.rs new file mode 100644 index 00000000..aa72c0a0 --- /dev/null +++ b/crates/engine/src/tests_plugins.rs @@ -0,0 +1,214 @@ +//! WebAssembly plug-ins (#121): object filters through `plugin.run` (one undo step, +//! transactional), live-effect plug-ins in appearance stacks, and the `plugin.*` management +//! commands. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_geom::{PathData, Point, SubPath}; +use vectorcraft_plugins::wat as tpl; + +use super::*; + +/// The example Desaturate plug-in (built from `crates/plugins/example`). +const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); +const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn install(s: &mut Session, wasm: &[u8]) -> Value { + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})) +} + +fn install_wat(s: &mut Session, src: &str) -> Value { + install(s, &wat::parse_str(src).unwrap()) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); + s +} + +/// A 10 × 10 rectangle at `x` filled with `fill` and no stroke. +fn rect(s: &mut Session, x: f64, fill: &str) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + run(s, "paint.setFill", json!({"ids": [id.0], "color": fill})); + run(s, "paint.setStroke", json!({"ids": [id.0], "none": true})); + id +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn triangle() -> PathData { + PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 0.0), Point::new(15.0, 20.0)], true)) +} + +#[test] +fn object_filter_recolours_the_selection_as_one_undo_step() { + let mut s = session(); + let info = install(&mut s, DESATURATE); + assert_eq!((info["id"].as_str(), info["kind"].as_str()), (Some(DESATURATE_ID), Some("filter"))); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 40.0, "#0000ff"); + run(&mut s, "select.all", json!({})); + let undo = s.doc().unwrap().history.undo.len(); + let r = run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "params": {"amount": 100}})); + assert_eq!(r["ids"], json!([a.0, b.0])); + for id in [a, b] { + let [r, g, b] = node(&s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated(); + assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6, "grey: {r} {g} {b}"); + } + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Desaturate"); + assert_eq!(st.selection.objects, [a, b]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + // Plug-in parameters may also be top-level keys; wrong types are refused before it runs. + run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "amount": 0})); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. }))); +} + +#[test] +fn object_filters_add_replace_and_generate_objects() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 40.0, "#00ff00"); + run(&mut s, "select.all", json!({})); + // Keeps A, drops B and adds a triangle above A. + let out = + json!({"objects": [{"id": a.0}, {"path": triangle(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 1}}], "name": "Tri"}]}); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.replace", "filter", "{}"), &out.to_string())); + let r = run(&mut s, "plugin.run", json!({"id": "test.engine.replace"})); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids[0], a); + let st = s.doc().unwrap(); + assert!(st.doc.node(b).is_none()); + let layer: Vec = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, [a, ids[1]]); + assert_eq!(st.doc.node(ids[1]).unwrap().name.as_deref(), Some("Tri")); + assert_eq!(st.selection.objects, ids); + // A generator with nothing selected adds to the current layer. + let generated = json!({"objects": [{"path": triangle()}]}); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.generate", "filter", "{}"), &generated.to_string())); + run(&mut s, "select.none", json!({})); + let r = run(&mut s, "plugin.run", json!({"id": "test.engine.generate"})); + let made = NodeId(r["ids"][0].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().id, made); +} + +#[test] +fn failing_plugins_leave_the_document_and_history_alone() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let man = |id: &str| tpl::manifest(id, "filter", "{}"); + install_wat(&mut s, &tpl::module(&man("test.engine.trap"), "unreachable", "", 1)); + install_wat(&mut s, &tpl::returning(&man("test.engine.far"), r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#)); + install_wat(&mut s, &tpl::returning(&man("test.engine.declines"), r#"{"error":"Select two paths."}"#)); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for id in ["test.engine.trap", "test.engine.far", "test.engine.declines", "test.engine.missing"] { + let e = s.execute("plugin.run", &json!({"id": id})).unwrap_err(); + if id == "test.engine.declines" { + assert!(e.to_string().contains("Select two paths."), "{e}"); + } + assert_eq!(s.doc().unwrap().doc, before, "{id}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{id}"); + } +} + +#[test] +fn live_effect_plugins_reshape_editable_art_and_expand() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + // An effect that draws a triangle, and one that hands the geometry back. + let tri = json!({"objects": [{"path": triangle()}]}); + install_wat( + &mut s, + &tpl::returning(&tpl::manifest("test.engine.tri", "effect", r#"{"size":{"type":"number","min":0,"max":10,"default":3}}"#), &tri.to_string()), + ); + install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.echo", "effect", "{}"), tpl::ECHO, "", 1)); + let catalog = run(&mut s, "effect.list", json!({}))["catalog"].clone(); + let entry = catalog.as_array().unwrap().iter().find(|e| e["id"] == "plugin.test.engine.tri").expect("listed"); + assert_eq!((entry["label"].as_str(), entry["menu"].clone()), (Some("Test test.engine.tri…"), json!(["Effect", "Plug-ins"]))); + assert_eq!(entry["defaults"], json!({"size": 3.0})); + // A filter can't be applied as an effect, nor an effect run as a filter. + install(&mut s, DESATURATE); + assert!(s.execute("effect.apply", &json!({"ids": [a.0], "effect": format!("plugin.{DESATURATE_ID}")})).is_err()); + assert!(s.execute("plugin.run", &json!({"id": "test.engine.tri"})).is_err()); + + let effected = |s: &Session| { + let n = node(s, a); + let path = n.path_data().unwrap().clone(); + let b = path.bounds().unwrap(); + vectorcraft_render::effects::apply_geometry(&n.appearance.effects, &path, b) + }; + run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.echo"})); + assert_eq!(effected(&s), *node(&s, a).path_data().unwrap(), "identity"); + // Re-evaluated when the object changes (non-destructive). + run(&mut s, "select.all", json!({})); + run(&mut s, "object.move", json!({"dx": 50, "dy": 0})); + assert_eq!(effected(&s), *node(&s, a).path_data().unwrap()); + run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}})); + assert_eq!(effected(&s), triangle()); + assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4})); + // Uninstalled: the geometry is drawn as it is. + run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"})); + assert_eq!(effected(&s), *node(&s, a).path_data().unwrap()); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.tri", "effect", "{}"), &tri.to_string())); + // Expand Appearance bakes it. + run(&mut s, "select.all", json!({})); + run(&mut s, "effect.expandAppearance", json!({})); + let mut baked = None; + s.doc().unwrap().doc.walk(|n| { + if let Some(p) = n.path_data() { + baked = Some(p.clone()); + } + }); + assert_eq!(baked.unwrap().bounds(), triangle().bounds()); +} + +#[test] +fn plugin_management_commands() { + let mut s = session(); + install(&mut s, DESATURATE); + let list = run(&mut s, "plugin.list", json!({})); + assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == DESATURATE_ID)); + let info = run(&mut s, "plugin.info", json!({"id": DESATURATE_ID})); + assert_eq!(info["defaults"], json!({"amount": 100.0})); + assert_eq!(info["params"], "{amount: 0..100 (100)}"); + assert_eq!(info["manifest"]["kind"], "filter"); + // Refused installs. + for bad in [ + json!({}), + json!({"dataBase64": "!!"}), + json!({"dataBase64": "AGFzbQ=="}), + json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE), "replace": false}), + ] { + assert!(s.execute("plugin.install", &bad).is_err(), "{bad}"); + } + assert!(s.execute("plugin.info", &json!({"id": "test.engine.nothing"})).is_err()); + assert!(s.execute("plugin.remove", &json!({"id": "test.engine.nothing"})).is_err()); + assert!(s.execute("plugin.reload", &json!({})).is_err(), "no folder set"); + + // The Additional Plug-ins Folder preference installs what's in it. + let dir = std::env::temp_dir().join(format!("vectorcraft-engine-plugins-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let src = tpl::returning(&tpl::manifest("test.engine.folder", "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(dir.join("folder.wasm"), wat::parse_str(src).unwrap()).unwrap(); + std::fs::write(dir.join("broken.wasm"), b"\0asm?").unwrap(); + run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": dir.display().to_string()})); + assert!(vectorcraft_plugins::registry::get("test.engine.folder").is_some()); + run(&mut s, "plugin.remove", json!({"id": "test.engine.folder"})); + let r = run(&mut s, "plugin.reload", json!({})); + assert_eq!(r["loaded"], json!(["test.engine.folder"])); + assert_eq!(r["failed"].as_array().unwrap().len(), 1); + assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["loaded"], json!(["test.engine.folder"])); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs new file mode 100644 index 00000000..ac982a20 --- /dev/null +++ b/crates/engine/tests/plugin_fuzz.rs @@ -0,0 +1,271 @@ +//! Untrusted plug-ins never crash the app: mutated and garbage modules handed to `plugin.install`, +//! and plug-ins returning hostile output (random JSON in the object model's shape: huge or +//! malformed coordinates, unknown ids, broken paints and gradients, junk of every type) as object +//! filters (`plugin.run`) or live effects (`effect.apply`). Each run must end as an error that +//! leaves the document as it was, or as a document that then renders and exports, without a +//! panic. +//! +//! `PROPTEST_CASES=2000 cargo test -p vectorcraft-engine --test plugin_fuzz` runs a deeper search. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use proptest::prelude::*; +use serde_json::{Map, Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; +use vectorcraft_testkit::catch_quiet; +use vectorcraft_testkit::fixtures::rich_session; + +const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); + +/// 64 cases each, unless `PROPTEST_CASES` asks for more. +fn config() -> ProptestConfig { + let mut c = ProptestConfig { failure_persistence: None, ..ProptestConfig::default() }; + if std::env::var_os("PROPTEST_CASES").is_none() { + c.cases = 64; + } + c +} + +fn install(s: &mut Session, wasm: &[u8]) -> Result { + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})).map_err(|e| e.to_string()) +} + +/// The document renders and exports without panicking. +fn renders(s: &Session, what: &str) -> Result<(), TestCaseError> { + let d = &s.doc().unwrap().doc; + let r = catch_quiet(|| { + let mut r = vectorcraft_render::Renderer::new(); + if let Some(ab) = d.artboards.first() { + let _ = r.render_region(d, ab.rect, 0.25, true).to_png(); + } + let _ = vectorcraft_svg::export(d, &vectorcraft_svg::ExportOptions::default()); + let _ = vectorcraft_engine::export_pdf(d, &vectorcraft_pdf::PdfOptions::default()); + let _ = vectorcraft_format::save_file(d); + }); + r.map_err(|msg| TestCaseError::fail(format!("{what}: panicked: {msg}"))) +} + +/// Junk of every JSON type. +fn arb_junk() -> impl Strategy { + let leaf = prop_oneof![ + Just(Value::Null), + any::().prop_map(Value::from), + any::().prop_map(Value::from), + (-1e3f64..1e3).prop_map(Value::from), + "[a-zA-Z#]{0,6}".prop_map(Value::from), + ]; + leaf.prop_recursive(2, 12, 3, |inner| { + prop_oneof![ + prop::collection::vec(inner.clone(), 0..3).prop_map(Value::Array), + prop::collection::btree_map("[a-z]{1,5}", inner, 0..3).prop_map(|m| Value::Object(m.into_iter().collect())), + ] + }) +} + +/// Numbers that tend to break arithmetic. +fn arb_num() -> impl Strategy { + prop_oneof![ + 6 => (-600.0f64..600.0).prop_map(Value::from), + 1 => Just(json!(0)), + 1 => Just(json!(-0.0)), + 1 => Just(json!(1e308)), + 1 => Just(json!(-1e308)), + 1 => Just(json!(4.0e6)), + 1 => Just(json!(4.000001e6)), + 1 => Just(json!(1e-308)), + 1 => Just(json!(u64::MAX)), + 1 => Just(json!(i64::MIN)), + 1 => arb_junk(), + ] +} + +fn arb_point() -> impl Strategy { + prop_oneof![ + 8 => (arb_num(), arb_num()).prop_map(|(x, y)| json!([x, y])), + 1 => (arb_num(), arb_num()).prop_map(|(x, y)| json!({"x": x, "y": y})), + 1 => arb_junk(), + ] +} + +fn arb_path() -> impl Strategy { + let anchor = ( + arb_point(), + prop::option::of(arb_point()), + prop::option::of(arb_point()), + prop::option::of(prop_oneof![Just("Smooth"), Just("Corner"), Just("Weird")]), + ) + .prop_map(|(p, i, o, k)| { + let mut a = json!({"p": p}); + if let Some(i) = i { + a["in"] = i; + } + if let Some(o) = o { + a["out"] = o; + } + if let Some(k) = k { + a["kind"] = json!(k); + } + a + }); + let sub = (prop::collection::vec(anchor, 0..6), any::()).prop_map(|(anchors, closed)| json!({"anchors": anchors, "closed": closed})); + prop_oneof![9 => prop::collection::vec(sub, 0..3).prop_map(|s| json!({"subpaths": s})), 1 => arb_junk()] +} + +fn arb_color() -> impl Strategy { + prop_oneof![ + (arb_num(), arb_num(), arb_num()).prop_map(|(r, g, b)| json!({"model": "rgb", "r": r, "g": g, "b": b})), + (arb_num(), arb_num(), arb_num(), arb_num()).prop_map(|(c, m, y, k)| json!({"model": "cmyk", "c": c, "m": m, "y": y, "k": k})), + arb_num().prop_map(|k| json!({"model": "gray", "k": k})), + (arb_num(), arb_num(), arb_num()).prop_map(|(l, a, b)| json!({"model": "lab", "l": l, "a": a, "b": b})), + arb_junk(), + ] +} + +fn arb_paint() -> impl Strategy { + let stop = (arb_num(), arb_color(), arb_num(), arb_num()).prop_map(|(o, c, a, m)| json!({"offset": o, "color": c, "opacity": a, "midpoint": m})); + let freeform = ( + prop::collection::vec((arb_point(), arb_color(), arb_num()).prop_map(|(at, c, s)| json!({"at": at, "color": c, "spread": s})), 0..3), + arb_junk(), + ) + .prop_map(|(points, lines)| json!({"points": points, "lines": if lines.is_array() { lines } else { json!([[0, 1, 9]]) }})); + let gradient = ( + prop_oneof![Just("Linear"), Just("Radial"), Just("Freeform"), Just("Conic")], + prop::collection::vec(stop, 0..4), + prop::option::of((arb_point(), arb_point(), arb_num(), prop::option::of(arb_point()))), + arb_num(), + prop::option::of(freeform), + ) + .prop_map(|(kind, stops, geom, angle, ff)| { + let mut g = json!({"type": "gradient", "gradient": {"kind": kind, "stops": stops}, "angle": angle}); + if let Some((start, end, aspect, focal)) = geom { + g["geom"] = json!({"start": start, "end": end, "aspect": aspect, "focal": focal}); + } + if let Some(ff) = ff { + g["freeform"] = ff; + } + g + }); + prop_oneof![ + 2 => Just(Value::Null), + 3 => (arb_color(), prop::option::of(prop_oneof![Just("White"), Just("Nope")]), arb_num()) + .prop_map(|(c, sw, t)| json!({"type": "solid", "color": c, "swatch": sw, "tint": t})), + 3 => gradient, + 1 => (prop_oneof![Just("Nope"), Just("")], prop::collection::vec(arb_num(), 6)).prop_map(|(p, xf)| json!({"type": "pattern", "pattern": p, "xf": xf})), + 1 => Just(json!({"type": "none"})), + 1 => arb_junk(), + ] +} + +/// An output object: an input object's id (by index into the inputs) or a random one, and any of +/// the object keys. +fn arb_object() -> impl Strategy, Value)> { + let stroke = (arb_paint(), prop::option::of(arb_num())).prop_map(|(p, w)| json!({"paint": p, "width": w})); + ( + prop::option::of(0usize..8), + prop::option::of(prop_oneof![Just(json!("path")), Just(json!("compound")), arb_junk()]), + prop::option::of(arb_path()), + prop::option::of(prop_oneof![Just(json!("evenodd")), Just(json!("nonzero")), arb_junk()]), + prop::option::of(prop::collection::vec(arb_paint(), 0..3)), + prop::option::of(prop::collection::vec(stroke, 0..3)), + prop::option::of(arb_num()), + prop::option::of(prop_oneof![Just(json!("name")), arb_junk()]), + ) + .prop_map(|(id, t, path, rule, fills, strokes, opacity, name)| { + let mut o = Map::new(); + for (k, v) in [ + ("type", t), + ("path", path), + ("fillRule", rule), + ("fills", fills.map(Value::from)), + ("strokes", strokes.map(Value::from)), + ("opacity", opacity), + ("name", name), + ] { + if let Some(v) = v { + o.insert(k.into(), v); + } + } + (id, Value::Object(o)) + }) +} + +/// The output document: objects (ids resolved against `inputs`; out-of-range indices become +/// unknown ids), or junk. +fn output(objects: &[(Option, Value)], inputs: &[u64], junk: Option) -> String { + if let Some(j) = junk { + return j.to_string(); + } + let list: Vec = objects + .iter() + .map(|(id, o)| { + let mut o = o.clone(); + if let Some(i) = id { + o["id"] = json!(inputs.get(*i).copied().unwrap_or(9_999_999 + *i as u64)); + } + o + }) + .collect(); + json!({ "objects": list }).to_string() +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn hostile_filter_and_effect_output_never_crashes( + objects in prop::collection::vec(arb_object(), 0..5), + junk in prop::option::weighted(0.1, arb_junk()), + effect in any::(), + ) { + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let inputs: Vec = vectorcraft_plugins::objects::filter_targets(&st.doc, &st.selection.objects).iter().map(|i| i.0).collect(); + let out = output(&objects, &inputs, junk); + let id = if effect { "fuzz.effect" } else { "fuzz.filter" }; + let src = tpl::returning(&tpl::manifest(id, if effect { "effect" } else { "filter" }, "{}"), &out); + install(&mut s, &wat::parse_str(src).unwrap()).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let r = catch_quiet(|| { + if effect { + s.execute("effect.apply", &json!({"effect": format!("plugin.{id}")})).map(|_| ()) + } else { + s.execute("plugin.run", &json!({"id": id})).map(|_| ()) + } + }); + match r { + Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))), + Ok(Err(_)) => prop_assert!(s.doc().unwrap().doc == before, "a failed run changed the document: {}", out), + Ok(Ok(())) => {} + } + renders(&s, &out)?; + } + + #[test] + fn mutated_and_garbage_modules_never_crash( + flips in prop::collection::vec((any::(), any::()), 0..8), + cut in prop::option::of(any::()), + garbage in prop::collection::vec(any::(), 0..64), + ) { + let mut bytes = DESATURATE.to_vec(); + for (i, b) in &flips { + let i = i.index(bytes.len()); + bytes[i] = *b; + } + if let Some(c) = cut { + bytes.truncate(c.index(bytes.len())); + } + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + for module in [bytes, [b"\0asm\x01\0\0\0".as_slice(), &garbage].concat(), garbage.clone()] { + let r = catch_quiet(|| { + if let Ok(info) = install(&mut s, &module) { + let _ = s.execute("plugin.run", &json!({"id": info["id"]})); + } + }); + prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); + } + renders(&s, "after mutated modules")?; + } +} diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml new file mode 100644 index 00000000..1fb4bd77 --- /dev/null +++ b/crates/plugins/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vectorcraft-plugins" +description = "Sandboxed WebAssembly plug-ins for VectorCraft: a resource-limited wasmi host for object filters and live effects" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +vectorcraft-geom = { workspace = true } +vectorcraft-color = { workspace = true } +vectorcraft-doc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +wasmi = { workspace = true } + +[dev-dependencies] +wat = { workspace = true } diff --git a/crates/plugins/example/Cargo.lock b/crates/plugins/example/Cargo.lock new file mode 100644 index 00000000..b4299570 --- /dev/null +++ b/crates/plugins/example/Cargo.lock @@ -0,0 +1,105 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "vectorcraft-plugin-desaturate" +version = "1.0.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/plugins/example/Cargo.toml b/crates/plugins/example/Cargo.toml new file mode 100644 index 00000000..4b4da0a0 --- /dev/null +++ b/crates/plugins/example/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "vectorcraft-plugin-desaturate" +description = "Example VectorCraft WebAssembly object filter plug-in: Desaturate" +version = "1.0.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +# Not a member of the VectorCraft workspace: it builds for wasm32-unknown-unknown only. +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/crates/plugins/example/build.sh b/crates/plugins/example/build.sh new file mode 100644 index 00000000..1bedbc2f --- /dev/null +++ b/crates/plugins/example/build.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Builds the Desaturate example plug-in and refreshes the test fixture built from it. +# Needs the wasm32-unknown-unknown target: `rustup target add wasm32-unknown-unknown`. +set -eu +cd "$(dirname "$0")" +cargo build --release --target wasm32-unknown-unknown +out=target/wasm32-unknown-unknown/release/vectorcraft_plugin_desaturate.wasm +cp "$out" ../tests/fixtures/desaturate.wasm +echo "built $out ($(wc -c < "$out") bytes) -> crates/plugins/tests/fixtures/desaturate.wasm" diff --git a/crates/plugins/example/src/lib.rs b/crates/plugins/example/src/lib.rs new file mode 100644 index 00000000..a1b08d86 --- /dev/null +++ b/crates/plugins/example/src/lib.rs @@ -0,0 +1,104 @@ +//! Desaturate: an example VectorCraft object filter plug-in (plug-in ABI v1, see +//! `docs/plugins.md`). +//! +//! It reads the selected paths as JSON, moves every colour (solid fills and strokes, gradient +//! stops, freeform gradient points) towards grey by `amount` percent, keeping each colour in its +//! own colour model, and hands the objects back. Only `serde_json` is used: no imports, no WASI. + +use serde_json::{Value, json}; + +const MANIFEST: &str = r#"{ + "id": "org.vectorcraft.example.desaturate", + "name": "Desaturate", + "version": "1.0.0", + "kind": "filter", + "author": "VectorCraft contributors", + "description": "Moves the colours of the selected paths towards grey; an example of the plug-in ABI.", + "params": { + "amount": {"type": "number", "min": 0, "max": 100, "default": 100} + } +}"#; + +#[unsafe(no_mangle)] +pub extern "C" fn vc_abi_version() -> u32 { + 1 +} + +/// `len << 32 | ptr` of the UTF-8 manifest JSON. +#[unsafe(no_mangle)] +pub extern "C" fn vc_manifest() -> u64 { + pack(MANIFEST.as_bytes()) +} + +/// A block of `size` bytes the host writes the input and the parameters into. Never freed: every +/// run gets a fresh instance. +#[unsafe(no_mangle)] +pub extern "C" fn vc_alloc(size: u32) -> u32 { + let mut block = Vec::::with_capacity(size as usize); + let ptr = block.as_mut_ptr(); + std::mem::forget(block); + ptr as u32 +} + +/// Runs the filter: `input` holds `{"objects": [...]}`, `params` the parameters. Returns the +/// output JSON as `len << 32 | ptr`, or a negative error code. +/// +/// # Safety +/// The host guarantees both blocks came from `vc_alloc` and hold `*_len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn vc_run(input: *const u8, input_len: u32, params: *const u8, params_len: u32) -> i64 { + // SAFETY: see the function's contract. + let (input, params) = unsafe { (std::slice::from_raw_parts(input, input_len as usize), std::slice::from_raw_parts(params, params_len as usize)) }; + let (Ok(mut doc), Ok(params)) = (serde_json::from_slice::(input), serde_json::from_slice::(params)) else { return -1 }; + let amount = (params["amount"].as_f64().unwrap_or(100.0) / 100.0).clamp(0.0, 1.0); + if doc["objects"].as_array().is_none_or(|o| o.is_empty()) { + return output(&json!({"error": "Select some paths to desaturate."})); + } + desaturate(&mut doc, amount); + output(&json!({"objects": doc["objects"]})) +} + +/// Every colour (an object with a `model`) inside `v`, moved `k` of the way to grey. +fn desaturate(v: &mut Value, k: f64) { + match v { + Value::Object(o) => { + let model = o.get("model").and_then(Value::as_str).map(str::to_string); + let get = |key: &str| o.get(key).and_then(Value::as_f64).unwrap_or(0.0); + let new: Option> = match model.as_deref() { + Some("rgb") => { + let (r, g, b) = (get("r"), get("g"), get("b")); + let y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + Some(vec![("r", r + (y - r) * k), ("g", g + (y - g) * k), ("b", b + (y - b) * k)]) + } + Some("cmyk") => { + let (c, m, y) = (get("c"), get("m"), get("y")); + let mean = (c + m + y) / 3.0; + Some(vec![("c", c + (mean - c) * k), ("m", m + (mean - m) * k), ("y", y + (mean - y) * k)]) + } + Some("lab") => Some(vec![("a", get("a") * (1.0 - k)), ("b", get("b") * (1.0 - k))]), + _ => None, + }; + match new { + Some(new) => { + for (key, x) in new { + o.insert(key.into(), json!(x)); + } + } + None => o.values_mut().for_each(|c| desaturate(c, k)), + } + } + Value::Array(a) => a.iter_mut().for_each(|c| desaturate(c, k)), + _ => {} + } +} + +/// Keeps `v` serialised in memory for the host to read, as `len << 32 | ptr`. +fn output(v: &Value) -> i64 { + let Ok(bytes) = serde_json::to_vec(v) else { return -2 }; + let bytes = bytes.leak(); + pack(bytes) as i64 +} + +fn pack(bytes: &[u8]) -> u64 { + ((bytes.len() as u64) << 32) | bytes.as_ptr() as u64 +} diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs new file mode 100644 index 00000000..b95f12a0 --- /dev/null +++ b/crates/plugins/src/effect.rs @@ -0,0 +1,130 @@ +//! Live-effect plug-ins: a plug-in of kind `effect` rewrites one object's geometry each time it is +//! drawn, from its appearance stack (an `Effect` record with id `plugin.`). +//! +//! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by +//! plug-in installation, parameters and input geometry: the plug-in runs again only when one of +//! them changes. Failures are cached too (the geometry is then drawn unchanged) and reported by +//! [`last_error`]. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Mutex; + +use serde_json::{Value, json}; +use vectorcraft_geom::{FillRule, PathData, Rect}; + +use crate::objects::{decode, output_geometry}; +use crate::{Error, Kind, Limits, Result, registry}; + +/// The effect id prefix of live-effect plug-ins: `plugin.`. +pub const PREFIX: &str = "plugin."; + +/// Wall-clock budget of one live-effect run (drawing waits for it), in milliseconds. +pub const WALL_TIME_MS: u64 = 5_000; + +/// Cached results kept before the cache starts over. +const CACHE_SIZE: usize = 512; + +/// The plug-in id of effect id `id` (`plugin.`). +pub fn plugin_id(effect_id: &str) -> Option<&str> { + effect_id.strip_prefix(PREFIX) +} + +/// The effect id of plug-in `id`. +pub fn effect_id(plugin_id: &str) -> String { + format!("{PREFIX}{plugin_id}") +} + +/// An installed live-effect plug-in by its effect id. +pub fn installed(effect_id: &str) -> Option> { + registry::get(plugin_id(effect_id)?).filter(|p| p.manifest().kind == Kind::Effect) +} + +#[derive(PartialEq)] +struct Key { + serial: u64, + params: String, + bounds: [u64; 4], + rule: FillRule, + path: PathData, +} + +impl Key { + fn hash64(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (self.serial, &self.params, self.bounds, self.rule).hash(&mut h); + for sp in &self.path.subpaths { + sp.closed.hash(&mut h); + for a in &sp.anchors { + for p in [a.p, a.h_in, a.h_out] { + (p.x.to_bits(), p.y.to_bits()).hash(&mut h); + } + } + } + h.finish() + } +} + +type Cache = HashMap)>; + +static CACHE: Mutex> = Mutex::new(None); +static ERRORS: Mutex>> = Mutex::new(None); + +/// The last error of live-effect plug-in `id` (cleared when it next succeeds). +pub fn last_error(id: &str) -> Option { + ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned() +} + +fn note(id: &str, r: &Result) { + let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); + let errors = errors.get_or_insert_with(HashMap::new); + match r { + Ok(_) => errors.remove(id), + Err(e) => errors.insert(id.to_string(), e.to_string()), + }; +} + +/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`): the new geometry, +/// or `None` when the plug-in isn't installed, isn't an effect, or fails (the caller then keeps +/// the geometry). +pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule) -> Option { + let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?; + let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?); + let key = Key { + serial: plugin.serial(), + params: resolved.to_string(), + bounds: [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(f64::to_bits), + rule, + path: path.clone(), + }; + let h = key.hash64(); + if let Some((k, out)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&h)) + && *k == key + { + return out.clone(); + } + let r = run(&plugin, resolved, path, bounds, rule); + note(id, &r); + let out = r.ok(); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let cache = cache.get_or_insert_with(HashMap::new); + if cache.len() >= CACHE_SIZE { + cache.clear(); + } + cache.insert(h, (key, out.clone())); + out +} + +fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule) -> Result { + let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]); + params["_context"] = json!({"mode": "effect", "bounds": b}); + let rule = match rule { + FillRule::NonZero => "nonzero", + FillRule::EvenOdd => "evenodd", + }; + let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b}]}); + let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(¶ms)); + let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) }; + let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }; + Ok(output_geometry(decode(&plugin.run_limited(&input, ¶ms, &limits)?)?)) +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs new file mode 100644 index 00000000..beafd045 --- /dev/null +++ b/crates/plugins/src/lib.rs @@ -0,0 +1,59 @@ +//! Sandboxed WebAssembly plug-ins for VectorCraft. +//! +//! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented +//! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with: +//! +//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that +//! imports anything is rejected; +//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is +//! also enforced (native builds), so infinite loops end with an error; +//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; +//! - a **fresh instance per run**, so no state leaks between calls. +//! +//! Every failure (malformed module, trap, exhausted budget, bad output) is an [`Error`]; nothing +//! a module does can panic or hang the host. +//! +//! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with +//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns +//! the objects to keep, change or add, applied to the document as one edit +//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each +//! time it is drawn, from an appearance stack. The process-wide [`registry`] holds the installed +//! plug-ins. +#![forbid(unsafe_code)] + +pub mod effect; +pub mod manifest; +pub mod objects; +pub mod registry; +mod runtime; +pub mod wat; + +pub use manifest::{Kind, Manifest, ParamSpec}; +pub use runtime::{ABI_VERSION, Limits, Plugin}; + +/// A plug-in error. Every way a module can misbehave ends here, never in a panic. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not a valid plug-in module: {0}")] + Module(String), + #[error("plug-in ABI: {0}")] + Abi(String), + #[error("plug-in manifest: {0}")] + Manifest(String), + #[error("plug-in trapped: {0}")] + Trap(String), + #[error("plug-in exceeded its {0}")] + Limit(String), + #[error("plug-in parameters: {0}")] + Params(String), + #[error("plug-in output: {0}")] + Output(String), + #[error("plug-in failed: {0}")] + Failed(String), + #[error("no plug-in {0:?} is installed")] + NotFound(String), + #[error("{0}")] + Io(String), +} + +pub type Result = std::result::Result; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs new file mode 100644 index 00000000..e38bb579 --- /dev/null +++ b/crates/plugins/src/manifest.rs @@ -0,0 +1,331 @@ +//! The plug-in manifest (`vc_manifest`) and parameter schema. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::{Error, Result}; + +/// Longest manifest the host reads. +pub const MAX_MANIFEST_BYTES: usize = 64 * 1024; +/// Most parameters a plug-in may declare. +pub const MAX_PARAMS: usize = 64; + +/// What a plug-in is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kind { + /// An object filter: reads the selected paths and returns the objects to keep, change or add + /// (Object › Plug-ins, `plugin.run`). + Filter, + /// A live effect: rewrites one object's geometry each time it is drawn, from its appearance + /// stack (Effect › Plug-ins, `effect.apply`). + Effect, +} + +impl Kind { + pub fn name(self) -> &'static str { + match self { + Kind::Filter => "filter", + Kind::Effect => "effect", + } + } +} + +/// One declared parameter. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ParamSpec { + /// A real number in `min..=max`. + Number { min: f64, max: f64, default: f64 }, + /// A whole number in `min..=max`. + Int { + #[serde(default = "int_min")] + min: i64, + #[serde(default = "int_max")] + max: i64, + #[serde(default)] + default: i64, + }, + /// A checkbox. + Bool { + #[serde(default)] + default: bool, + }, + /// One of `options` (a dropdown). + Choice { + options: Vec, + #[serde(default)] + default: Option, + }, +} + +fn int_min() -> i64 { + -1_000_000 +} +fn int_max() -> i64 { + 1_000_000 +} + +impl ParamSpec { + /// The value a parameter takes when it isn't given. + pub fn default_value(&self) -> Value { + match self { + ParamSpec::Number { default, .. } => json!(default), + ParamSpec::Int { default, .. } => json!(default), + ParamSpec::Bool { default } => json!(default), + ParamSpec::Choice { options, default } => json!(default.clone().or_else(|| options.first().cloned()).unwrap_or_default()), + } + } +} + +/// The manifest a plug-in returns from `vc_manifest` (UTF-8 JSON). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + /// Stable id, `[A-Za-z0-9._-]{1,64}` (reverse-DNS style is recommended: `org.example.tidy`). + pub id: String, + /// Menu label. + pub name: String, + #[serde(default)] + pub version: String, + pub kind: Kind, + #[serde(default)] + pub description: String, + #[serde(default)] + pub author: String, + /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. + #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] + pub params: Vec<(String, ParamSpec)>, +} + +impl Manifest { + /// Parses and validates manifest JSON. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_MANIFEST_BYTES { + return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len()))); + } + let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?; + let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?; + man.validate()?; + Ok(man) + } + + /// The manifest of a module still being loaded. + pub(crate) fn placeholder() -> Manifest { + Manifest { + id: String::new(), + name: String::new(), + version: String::new(), + kind: Kind::Filter, + description: String::new(), + author: String::new(), + params: Vec::new(), + } + } + + fn validate(&self) -> Result<()> { + if !valid_id(&self.id) { + return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id))); + } + let name = self.name.trim(); + if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) { + return Err(Error::Manifest("`name` must be 1-64 printable characters".into())); + } + if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { + return Err(Error::Manifest("`version`, `description` or `author` is too long".into())); + } + for (k, spec) in &self.params { + if !valid_key(k) { + return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); + } + match spec { + ParamSpec::Number { min, max, default } => { + if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max { + return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); + } + } + ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))), + ParamSpec::Choice { options, default } => { + if options.is_empty() + || options.len() > 64 + || options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control)) + { + return Err(Error::Manifest(format!("parameter `{k}`: choices must be 1-64 non-empty, printable strings"))); + } + if default.as_ref().is_some_and(|d| !options.contains(d)) { + return Err(Error::Manifest(format!("parameter `{k}`: default is not one of the options"))); + } + } + _ => {} + } + } + Ok(()) + } + + /// The parameters documented the way command and effect parameters are + /// (`{amount: 0..100 (50), mode: "soft"|"hard" (soft), mono: bool (false)}`). + pub fn params_doc(&self) -> String { + let parts: Vec = self + .params + .iter() + .map(|(k, spec)| match spec { + ParamSpec::Number { min, max, default } => format!("{k}: {min}..{max} ({default})"), + ParamSpec::Int { min, max, default } => format!("{k}: int {min}..{max} ({default})"), + ParamSpec::Bool { default } => format!("{k}: bool ({default})"), + ParamSpec::Choice { options, .. } => { + let opts: Vec = options.iter().map(|o| format!("{o:?}")).collect(); + format!("{k}: {} ({})", opts.join("|"), spec.default_value().as_str().unwrap_or_default()) + } + }) + .collect(); + format!("{{{}}}", parts.join(", ")) + } + + /// Every parameter at its default value. + pub fn defaults(&self) -> Map { + self.params.iter().map(|(k, spec)| (k.clone(), spec.default_value())).collect() + } + + /// Validates user `params` against the schema: missing keys take their defaults, numbers are + /// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong + /// type is an error. + pub fn resolve_params(&self, params: &Value) -> Result> { + let given = match params { + Value::Null => &Map::new(), + Value::Object(m) => m, + _ => return Err(Error::Params("plug-in parameters must be a JSON object".into())), + }; + let mut out = Map::new(); + for (k, spec) in &self.params { + let Some(v) = given.get(k).filter(|v| !v.is_null()) else { + out.insert(k.clone(), spec.default_value()); + continue; + }; + let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}")); + let resolved = match spec { + ParamSpec::Number { min, max, .. } => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)), + ParamSpec::Int { min, max, .. } => { + let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?; + json!((f.round().clamp(*min as f64, *max as f64)) as i64) + } + ParamSpec::Bool { .. } => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?), + ParamSpec::Choice { options, .. } => { + let s = v.as_str().ok_or_else(|| wrong("a string"))?; + if !options.iter().any(|o| o == s) { + return Err(Error::Params(format!("`{k}` must be one of {}", options.join(", ")))); + } + json!(s) + } + }; + out.insert(k.clone(), resolved); + } + Ok(out) + } +} + +/// Reads the `params` object keeping its key order (serde_json's `Map` would sort it). +fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Vec<(String, ParamSpec)>; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an object of parameter specs") + } + fn visit_unit(self) -> std::result::Result { + Ok(Vec::new()) + } + fn visit_map>(self, mut map: A) -> std::result::Result { + let mut out: Vec<(String, ParamSpec)> = Vec::new(); + while let Some((k, v)) = map.next_entry::()? { + if out.len() >= MAX_PARAMS { + return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})"))); + } + if out.iter().any(|(o, _)| *o == k) { + return Err(serde::de::Error::custom(format!("parameter `{k}` is declared twice"))); + } + out.push((k, v)); + } + Ok(out) + } + } + d.deserialize_any(V) +} + +fn params_as_object(p: &[(String, ParamSpec)], s: S) -> std::result::Result { + use serde::ser::SerializeMap; + let mut m = s.serialize_map(Some(p.len()))?; + for (k, v) in p { + m.serialize_entry(k, v)?; + } + m.end() +} + +/// `[A-Za-z0-9._-]{1,64}`. +pub fn valid_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// `[A-Za-z0-9_]{1,64}`, not starting with `_` and not a key the commands and dialogs use +/// themselves. +fn valid_key(k: &str) -> bool { + (1..=64).contains(&k.len()) + && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + && !k.starts_with('_') + && !matches!(k, "id" | "ids" | "params" | "preview") +} + +#[cfg(test)] +mod tests { + use super::*; + + const FULL: &str = r#"{"id":"org.example.tone","name":"Tone","version":"1.2","kind":"effect", + "params":{"amount":{"type":"number","min":0,"max":100,"default":50}, + "mode":{"type":"choice","options":["soft","hard"],"default":"hard"}, + "mono":{"type":"bool"},"seed":{"type":"int","min":0,"max":9}}}"#; + + #[test] + fn parses_and_keeps_param_order() { + let m = Manifest::parse(FULL.as_bytes()).unwrap(); + assert_eq!(m.id, "org.example.tone"); + assert_eq!(m.kind, Kind::Effect); + assert_eq!(m.params.iter().map(|p| p.0.as_str()).collect::>(), ["amount", "mode", "mono", "seed"]); + assert_eq!(m.params_doc(), r#"{amount: 0..100 (50), mode: "soft"|"hard" (hard), mono: bool (false), seed: int 0..9 (0)}"#); + assert_eq!(Value::Object(m.defaults()), json!({"amount": 50.0, "mode": "hard", "mono": false, "seed": 0})); + } + + #[test] + fn resolves_defaults_clamps_and_rejects_wrong_types() { + let m = Manifest::parse(FULL.as_bytes()).unwrap(); + let p = m.resolve_params(&json!({"amount": 500, "seed": 3.6, "junk": 1})).unwrap(); + assert_eq!(Value::Object(p), json!({"amount": 100.0, "mode": "hard", "mono": false, "seed": 4})); + assert!(m.resolve_params(&json!({"amount": "lots"})).is_err()); + assert!(m.resolve_params(&json!({"mode": "medium"})).is_err()); + assert!(m.resolve_params(&json!({"mono": 1})).is_err()); + assert!(m.resolve_params(&json!([1, 2])).is_err()); + assert!(m.resolve_params(&Value::Null).is_ok()); + } + + #[test] + fn rejects_bad_manifests() { + for bad in [ + "", + "[]", + "not json", + r#"{"id":"","name":"x","kind":"filter"}"#, + r#"{"id":"a b","name":"x","kind":"filter"}"#, + r#"{"id":"a","name":"","kind":"filter"}"#, + r#"{"id":"a","name":"x","kind":"panel"}"#, + r#"{"id":"a","name":"x"}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"preview":{"type":"bool"}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"a":{"type":"bool"},"a":{"type":"bool"}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#, + ] { + assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}"); + } + assert!(Manifest::parse(&[0xff, 0xfe]).is_err()); + assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err()); + } +} diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs new file mode 100644 index 00000000..7c0d60f0 --- /dev/null +++ b/crates/plugins/src/objects.rs @@ -0,0 +1,597 @@ +//! The object model plug-ins read and write: paths and compound paths as JSON. +//! +//! The host hands a plug-in `{"objects": [...]}` (see [`encode`]) and reads the same shape back +//! ([`decode`]). Geometry and paint use the native file format's forms (`PathData`, `Paint`), so +//! nothing is lost on the way through; everything a plug-in returns is validated (finite, +//! in-range coordinates, capped counts, clamped colours) before it reaches the document. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{FillRule, PathData, Point, Rect}; + +use crate::{Error, Result}; + +/// Coordinates a plug-in may return, in points (the document's own canvas limit). +pub const MAX_COORD: f64 = 4.0e6; +/// Most objects one output may hold. +pub const MAX_OBJECTS: usize = 100_000; +/// Most anchor points one output may hold, over all objects. +pub const MAX_ANCHORS: usize = 4_000_000; +/// Most fills or strokes per object, gradient stops per gradient and freeform points or lines +/// per freeform gradient. +pub const MAX_ITEMS: usize = 256; +/// Thickest stroke a plug-in may set, in points. +pub const MAX_STROKE_WIDTH: f64 = 1000.0; + +/// One object returned by a plug-in. `None` fields keep the object's value (new objects take the +/// defaults: a path, non-zero rule, no fills or strokes, full opacity). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OutObject { + /// The input object this updates; `None`: a new object. + pub id: Option, + /// `"compound"` (true) or `"path"` (false). + pub compound: Option, + /// `Some("")` clears the name. + pub name: Option, + pub path: Option, + pub rule: Option, + /// Fill paints in paint order (bottom first). + pub fills: Option>, + pub strokes: Option>, + pub opacity: Option, +} + +/// One stroke of an [`OutObject`]. +#[derive(Clone, Debug, PartialEq)] +pub struct OutStroke { + pub paint: Paint, + /// `None`: keep the stroke's weight (1 pt for a new stroke). + pub width: Option, +} + +fn rule_name(r: FillRule) -> &'static str { + match r { + FillRule::NonZero => "nonzero", + FillRule::EvenOdd => "evenodd", + } +} + +fn rect_json(r: Option) -> Value { + r.map_or(Value::Null, |r| json!([r.x0, r.y0, r.x1, r.y1])) +} + +fn paint_json(p: &Paint) -> Value { + match p { + Paint::None => Value::Null, + p => serde_json::to_value(p).unwrap_or(Value::Null), + } +} + +/// The geometry of a path or compound path (a compound's members' subpaths in order) and its fill +/// rule. `None` for other objects. +pub fn geometry(n: &Node) -> Option<(PathData, FillRule, bool)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule, false)), + NodeKind::Compound { children, rule } => { + Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule, true)) + } + _ => None, + } +} + +/// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path, +/// fillRule, fills, strokes, opacity, bounds}`. +pub fn encode(n: &Node) -> Option { + let (path, rule, compound) = geometry(n)?; + let fills: Vec = + n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect(); + let strokes: Vec = n + .appearance + .items + .iter() + .filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(json!({"paint": paint_json(&s.paint), "width": s.width})) } else { None }) + .collect(); + let mut o = json!({ + "id": n.id.0, "type": if compound { "compound" } else { "path" }, "path": path, "fillRule": rule_name(rule), + "fills": fills, "strokes": strokes, "opacity": n.opacity, "bounds": rect_json(path.bounds()), + }); + if let Some(name) = &n.name { + o["name"] = json!(name); + } + Some(o) +} + +/// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths; +/// others are left out). +pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { + let objects: Vec = ids.iter().filter_map(|id| doc.node(*id)).filter_map(encode).collect(); + serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default() +} + +fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { + Error::Output(format!("{at}: {msg}")) +} + +fn check_point(p: Point, at: &str) -> Result<()> { + if p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD { + Ok(()) + } else { + Err(out_err(at, format!("coordinate out of range (limit ±{MAX_COORD} pt)"))) + } +} + +/// A path from plug-in output: native `PathData` JSON, every coordinate finite and in range, empty +/// subpaths dropped; `anchors` counts towards [`MAX_ANCHORS`]. +fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result { + let mut path: PathData = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; + path.subpaths.retain(|sp| !sp.anchors.is_empty()); + *anchors = anchors.saturating_add(path.anchor_count()); + if *anchors > MAX_ANCHORS { + return Err(out_err(at, format!("too many anchor points (limit {MAX_ANCHORS})"))); + } + for (_, _, a) in path.anchors() { + for p in [a.p, a.h_in, a.h_out] { + check_point(p, at)?; + } + } + if path.is_empty() { + return Err(out_err(at, "the path has no anchor points")); + } + Ok(path) +} + +fn unit(v: f32) -> f32 { + v.clamp(0.0, 1.0) +} + +/// `c` with its components in range. +fn clamp_color(c: Color) -> Color { + match c { + Color::Rgb { r, g, b } => Color::Rgb { r: unit(r), g: unit(g), b: unit(b) }, + Color::Cmyk { c, m, y, k } => Color::Cmyk { c: unit(c), m: unit(m), y: unit(y), k: unit(k) }, + Color::Gray { k } => Color::Gray { k: unit(k) }, + Color::Lab { l, a, b } => Color::Lab { l: l.clamp(0.0, 100.0), a: a.clamp(-128.0, 127.0), b: b.clamp(-128.0, 127.0) }, + } +} + +/// A paint from plug-in output (`null`: none), with colours, opacities and offsets clamped and +/// gradient and pattern placements checked. +fn decode_paint(v: &Value, at: &str) -> Result { + if v.is_null() { + return Ok(Paint::None); + } + let mut p: Paint = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; + match &mut p { + Paint::None => {} + Paint::Solid { color, tint, .. } => { + *color = clamp_color(*color); + *tint = unit(*tint); + } + Paint::Gradient(g) => { + let stops = &mut g.gradient.stops; + if stops.is_empty() || stops.len() > MAX_ITEMS { + return Err(out_err(at, format!("a gradient needs 1-{MAX_ITEMS} stops"))); + } + for s in stops.iter_mut() { + s.offset = unit(s.offset); + s.opacity = unit(s.opacity); + s.midpoint = s.midpoint.clamp(0.13, 0.87); + s.tint = unit(s.tint); + s.color = clamp_color(s.color); + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + g.angle = g.angle.rem_euclid(360.0); + if let Some(geom) = &mut g.geom { + check_point(geom.start, at)?; + check_point(geom.end, at)?; + if let Some(f) = geom.focal { + check_point(f, at)?; + } + geom.aspect = if geom.aspect.is_finite() { geom.aspect.clamp(0.01, 100.0) } else { 1.0 }; + } + if let Some(ff) = &mut g.freeform { + if ff.points.len() > MAX_ITEMS || ff.lines.len() > MAX_ITEMS || ff.lines.iter().any(|l| l.len() > MAX_ITEMS) { + return Err(out_err(at, format!("a freeform gradient holds at most {MAX_ITEMS} points and lines"))); + } + let n = ff.points.len(); + if ff.lines.iter().flatten().any(|i| *i >= n) { + return Err(out_err(at, "a freeform gradient line names a point it doesn't have")); + } + for pt in &mut ff.points { + check_point(pt.at, at)?; + pt.color = clamp_color(pt.color); + pt.opacity = unit(pt.opacity); + pt.spread = unit(pt.spread); + } + } + } + Paint::Pattern { xf, .. } => { + if !xf.as_coeffs().iter().all(|c| c.is_finite() && c.abs() <= MAX_COORD) { + return Err(out_err(at, "pattern transform out of range")); + } + } + } + Ok(p) +} + +fn opt<'a>(o: &'a serde_json::Map, key: &str) -> Option<&'a Value> { + o.get(key).filter(|v| !v.is_null()) +} + +fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result { + let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?; + let mut out = OutObject::default(); + if let Some(id) = opt(o, "id") { + out.id = Some(NodeId(id.as_u64().ok_or_else(|| out_err(at, "`id` must be the id of an input object"))?)); + } + if let Some(t) = opt(o, "type") { + out.compound = Some(match t.as_str() { + Some("path") => false, + Some("compound") => true, + _ => return Err(out_err(at, "`type` must be \"path\" or \"compound\"")), + }); + } + if let Some(n) = o.get("name") { + let name = match n { + Value::Null => String::new(), + n => n.as_str().ok_or_else(|| out_err(at, "`name` must be a string"))?.chars().filter(|c| !c.is_control()).take(256).collect(), + }; + out.name = Some(name); + } + if let Some(p) = opt(o, "path") { + out.path = Some(decode_path(p, &format!("{at}.path"), anchors)?); + } + if let Some(r) = opt(o, "fillRule") { + out.rule = Some(match r.as_str().map(str::to_ascii_lowercase).as_deref() { + Some("nonzero") => FillRule::NonZero, + Some("evenodd") => FillRule::EvenOdd, + _ => return Err(out_err(at, "`fillRule` must be \"nonzero\" or \"evenodd\"")), + }); + } + let list = |key: &str| -> Result>> { + match opt(o, key) { + None => Ok(None), + Some(Value::Array(a)) if a.len() <= MAX_ITEMS => Ok(Some(a)), + Some(_) => Err(out_err(at, format!("`{key}` must be an array of at most {MAX_ITEMS} entries"))), + } + }; + if let Some(fills) = list("fills")? { + out.fills = Some(fills.iter().enumerate().map(|(i, f)| decode_paint(f, &format!("{at}.fills[{i}]"))).collect::>()?); + } + if let Some(strokes) = list("strokes")? { + let mut v = Vec::with_capacity(strokes.len()); + for (i, s) in strokes.iter().enumerate() { + let at = format!("{at}.strokes[{i}]"); + let s = s.as_object().ok_or_else(|| out_err(&at, "a stroke must be {\"paint\", \"width\"}"))?; + let paint = decode_paint(s.get("paint").unwrap_or(&Value::Null), &at)?; + let width = match opt(s, "width") { + None => None, + Some(w) => Some(w.as_f64().ok_or_else(|| out_err(&at, "`width` must be a number"))?.clamp(0.0, MAX_STROKE_WIDTH)), + }; + v.push(OutStroke { paint, width }); + } + out.strokes = Some(v); + } + if let Some(op) = opt(o, "opacity") { + out.opacity = Some(unit(op.as_f64().ok_or_else(|| out_err(at, "`opacity` must be a number"))? as f32)); + } + Ok(out) +} + +/// Parses a plug-in's output: `{"objects": [...]}`, or `{"error": "message"}` when the plug-in +/// declined (the message reaches the user). +pub fn decode(bytes: &[u8]) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; + if let Some(e) = v.get("error").filter(|e| !e.is_null()) { + let msg: String = e.as_str().map_or_else(|| e.to_string(), str::to_string).chars().filter(|c| !c.is_control()).take(300).collect(); + return Err(Error::Failed(msg)); + } + let objects = v.get("objects").and_then(Value::as_array).ok_or_else(|| Error::Output("expected {\"objects\": [...]}".into()))?; + if objects.len() > MAX_OBJECTS { + return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len()))); + } + let mut anchors = 0; + objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect() +} + +/// The paints of `ap`'s fills (`fill`) or strokes, in paint order. +fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> { + ap.items.iter().filter(|i| i.is_fill() == fill).map(AppearanceItem::paint).collect() +} + +/// `p` without swatch links: a plug-in's colours are its own, so only paints it returned +/// unchanged keep theirs. +fn unlinked(mut p: Paint) -> Paint { + match &mut p { + Paint::Solid { swatch, tint, .. } => { + *swatch = None; + *tint = 1.0; + } + Paint::Gradient(g) => { + g.swatch = None; + for s in &mut g.gradient.stops { + s.swatch = None; + s.tint = 1.0; + } + } + Paint::None | Paint::Pattern { .. } => {} + } + p +} + +/// `p` as it may enter `doc`: unchanged from `was` (the paint in the same slot before), or +/// without swatch links; a pattern must exist in the document. +fn admit(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result { + if let Paint::Pattern { pattern, .. } = &p + && doc.pattern(pattern).is_none() + { + return Err(out_err(at, format!("the document has no pattern {pattern:?}"))); + } + Ok(if was == Some(&p) { p } else { unlinked(p) }) +} + +/// Sets the paints of `ap`'s fills (`fill`) or strokes to `new`, in paint order: extra items are +/// removed (from the top), missing ones added above the last item of their kind (fills at the +/// bottom, strokes on top when the object has none). +fn set_items(ap: &mut Appearance, fill: bool, new: Vec<(Paint, Option)>) { + let idx: Vec = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect(); + for &i in idx.iter().skip(new.len()).rev() { + ap.remove_item(i); + } + let kept = new.len().min(idx.len()); + let mut at = idx.get(..kept).and_then(<[usize]>::last).map_or(if fill { 0 } else { ap.items.len() }, |i| i + 1); + for (k, (paint, width)) in new.into_iter().enumerate() { + match idx.get(k).and_then(|&i| ap.items.get_mut(i)) { + Some(AppearanceItem::Fill(f)) => f.paint = paint, + Some(AppearanceItem::Stroke(s)) => { + s.paint = paint; + if let Some(w) = width { + s.width = w; + } + } + None => { + let item = if fill { + AppearanceItem::Fill(FillLayer::new(paint)) + } else { + AppearanceItem::Stroke(StrokeLayer::new(paint, width.unwrap_or(1.0))) + }; + ap.insert_item(at, item); + at += 1; + } + } + } +} + +/// A compound path's members: one path per subpath of `path`, keeping the ids of `old` members. +fn members(doc: &mut Document, old: &[Arc], path: PathData) -> Vec> { + path.subpaths + .into_iter() + .enumerate() + .map(|(i, sp)| { + let id = old.get(i).map_or_else(|| doc.alloc_id(), |c| c.id); + Arc::new(Node::path(id, PathData::single(sp), Appearance::default())) + }) + .collect() +} + +/// Writes `o` into `n` (an input object, or a fresh node for a new one). +fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()> { + let (cur_path, cur_rule, cur_compound) = geometry(n).unwrap_or((PathData::default(), FillRule::NonZero, false)); + let compound = o.compound.unwrap_or(cur_compound); + let rule = o.rule.unwrap_or(cur_rule); + match (&mut n.kind, compound) { + (NodeKind::Path { path, rule: r, live, .. }, false) => { + if let Some(p) = o.path { + *path = p; + *live = None; + } + *r = rule; + } + (NodeKind::Compound { children, rule: r }, true) => { + if let Some(p) = o.path { + *children = members(doc, children, p); + } + *r = rule; + } + (NodeKind::Compound { .. }, false) => { + n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false }; + } + (_, true) => n.kind = NodeKind::Compound { children: members(doc, &[], o.path.unwrap_or(cur_path)), rule }, + (_, false) => n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false }, + } + for (fill, new) in [ + (true, o.fills.map(|f| f.into_iter().map(|p| (p, None)).collect::>())), + (false, o.strokes.map(|s| s.into_iter().map(|s| (s.paint, s.width)).collect())), + ] { + let Some(new) = new else { continue }; + let was = paints(&n.appearance, fill); + let admitted = new + .into_iter() + .enumerate() + .map(|(i, (p, w))| Ok((admit(doc, p, was.get(i).copied(), &format!("{at}.{}[{i}]", if fill { "fills" } else { "strokes" }))?, w))) + .collect::>>()?; + set_items(&mut n.appearance, fill, admitted); + } + if let Some(op) = o.opacity { + n.opacity = op; + } + if let Some(name) = o.name { + n.name = Some(name).filter(|s| !s.trim().is_empty()); + } + Ok(()) +} + +/// The paths and compound paths a filter reads from the selection `selected`, in paint order: +/// selected ones, and those inside selected groups and layers; hidden, locked, guide and clipping +/// paths are left out (so are paths inside compound paths, blends and other live objects). +pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec { + fn visit(list: &[Arc], sel: &HashSet, inside: bool, out: &mut Vec) { + for n in list { + if !n.visible || n.locked { + continue; + } + let picked = inside || sel.contains(&n.id); + match &n.kind { + NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked => out.push(n.id), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out), + _ => {} + } + } + } + let sel: HashSet = selected.iter().copied().collect(); + let mut out = vec![]; + visit(&doc.layers, &sel, false, &mut out); + out +} + +/// The tree edits of [`apply_output`], made in one pass over the document. +#[derive(Default)] +struct Plan { + replace: HashMap, + delete: HashSet, + before: HashMap>, + after: HashMap>, + /// New objects that go on top of a container (`None`: the top-level list). + top: Option<(Option, Vec)>, +} + +impl Plan { + fn pending(&self) -> bool { + !(self.replace.is_empty() && self.delete.is_empty() && self.before.is_empty() && self.after.is_empty() && self.top.is_none()) + } + + /// The edited `list` (the children of `parent`), `None` when nothing in it changed. + fn list(&mut self, parent: Option, list: &[Arc]) -> Option>> { + let mut out: Option>> = None; + for (i, c) in list.iter().enumerate() { + if !self.pending() { + if let Some(o) = &mut out { + o.extend(list.iter().skip(i).cloned()); + } + return out; + } + let mut emitted: Vec> = self.before.remove(&c.id).unwrap_or_default().into_iter().map(Arc::new).collect(); + let mut changed = !emitted.is_empty(); + if self.delete.remove(&c.id) { + changed = true; + } else if let Some(r) = self.replace.remove(&c.id) { + emitted.push(Arc::new(r)); + changed = true; + } else if let Some(ch) = match &c.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => self.list(Some(c.id), children), + _ => None, + } { + let mut m = (**c).clone(); + if let Some(slot) = m.children_mut() { + *slot = ch; + } + emitted.push(Arc::new(m)); + changed = true; + } else { + emitted.push(c.clone()); + } + if let Some(a) = self.after.remove(&c.id) { + emitted.extend(a.into_iter().map(Arc::new)); + changed = true; + } + if changed && out.is_none() { + out = Some(list.iter().take(i).cloned().collect()); + } + if let Some(o) = &mut out { + o.extend(emitted); + } + } + if self.top.as_ref().is_some_and(|(p, _)| *p == parent) + && let Some((_, nodes)) = self.top.take() + { + out.get_or_insert_with(|| list.to_vec()).extend(nodes.into_iter().map(Arc::new)); + } + out + } +} + +/// Applies an object filter's output to `doc`. +/// +/// `inputs` are the objects the plug-in was given, in paint order ([`filter_targets`]). Output +/// objects with an `id` update that input object (keys they leave out keep their values); objects +/// without one are new and go just above the object listed before them, or, listed before any +/// input object, just below the first one listed after them (at the first input object's place +/// when the output keeps none; on top of `parent` when there were no inputs). Input objects missing +/// from the output are deleted. Returns the resulting objects in output order (to select). +pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, parent: Option) -> Result> { + let wanted: HashSet = inputs.iter().copied().collect(); + let mut originals: HashMap = HashMap::new(); + doc.walk(|n| { + if wanted.contains(&n.id) { + originals.insert(n.id, n.clone()); + } + }); + let mut plan = Plan::default(); + let mut result = Vec::with_capacity(out.len()); + // The run of new objects since the last input object listed, and that object. + let mut run: Vec = vec![]; + let mut prev: Option = None; + for (i, o) in out.into_iter().enumerate() { + let at = format!("objects[{i}]"); + match o.id { + Some(id) => { + let mut n = originals.remove(&id).ok_or_else(|| { + let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; + out_err(&at, format!("{} {why}", id.0)) + })?; + write(doc, &mut n, o, &at)?; + if !run.is_empty() { + let nodes = std::mem::take(&mut run); + match prev { + Some(p) => plan.after.insert(p, nodes), + None => plan.before.insert(id, nodes), + }; + } + plan.replace.insert(id, n); + prev = Some(id); + result.push(id); + } + None => { + if o.path.is_none() { + return Err(out_err(&at, "a new object needs a `path`")); + } + let mut n = Node::path(doc.alloc_id(), PathData::default(), Appearance::default()); + write(doc, &mut n, o, &at)?; + result.push(n.id); + run.push(n); + } + } + } + if !run.is_empty() { + match prev.or(inputs.first().copied()) { + Some(p) if prev.is_some() => { + plan.after.insert(p, run); + } + Some(first) => { + plan.before.insert(first, run); + } + None => plan.top = Some((parent, run)), + } + } + // Input objects the output left out are deleted. + plan.delete = originals.into_keys().collect(); + if let Some(list) = plan.list(None, &doc.layers) { + doc.layers = list; + } + if plan.pending() { + return Err(Error::Output("the objects to change are no longer in the document".into())); + } + Ok(result) +} + +/// The geometry of an effect's output: every returned object's path, in order. +pub fn output_geometry(objects: Vec) -> PathData { + PathData::new(objects.into_iter().filter_map(|o| o.path).flat_map(|p| p.subpaths).collect()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs new file mode 100644 index 00000000..80f16ff1 --- /dev/null +++ b/crates/plugins/src/objects/tests.rs @@ -0,0 +1,160 @@ +use super::*; +use vectorcraft_geom::SubPath; + +fn square(x: f64) -> PathData { + PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)], true)) +} + +/// A document with paths A, B and C (bottom to top) on its layer, plus a locked path and a guide. +fn doc() -> (Document, Vec, NodeId) { + let mut d = Document::new(200.0, 200.0); + let layer = d.layers[0].id; + let mut ids = vec![]; + for (i, name) in ["A", "B", "C"].into_iter().enumerate() { + let mut n = Node::path(d.alloc_id(), square(i as f64 * 20.0), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 1.0)); + n.name = Some(name.into()); + ids.push(d.insert(Some(layer), usize::MAX, n).unwrap()); + } + let mut locked = Node::path(d.alloc_id(), square(80.0), Appearance::default()); + locked.locked = true; + d.insert(Some(layer), usize::MAX, locked).unwrap(); + let guide = Node::new(d.alloc_id(), NodeKind::Path { path: square(90.0), rule: FillRule::NonZero, live: None, clipping: false, guide: true }); + d.insert(Some(layer), usize::MAX, guide).unwrap(); + (d, ids, layer) +} + +fn names(d: &Document, layer: NodeId) -> Vec { + d.children(Some(layer)).unwrap().iter().map(|n| n.name.clone().unwrap_or_else(|| "?".into())).collect() +} + +fn decode_json(v: Value) -> Result> { + decode(v.to_string().as_bytes()) +} + +#[test] +fn targets_are_selected_paths_in_paint_order() { + let (mut d, ids, layer) = doc(); + assert_eq!(filter_targets(&d, &[ids[2], ids[0]]), [ids[0], ids[2]]); + // A selected layer gives its paths, without the locked one and the guide. + assert_eq!(filter_targets(&d, &[layer]), ids); + d.node_mut(ids[1]).unwrap().visible = false; + assert_eq!(filter_targets(&d, &[layer]), [ids[0], ids[2]]); +} + +#[test] +fn objects_round_trip_through_the_json_model() { + let (mut d, ids, _) = doc(); + let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()); + g.gradient.stops[0].swatch = Some("White".into()); + d.node_mut(ids[0]).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(g))); + let input: Value = serde_json::from_slice(&encode_input(&d, &ids)).unwrap(); + let a = &input["objects"][0]; + assert_eq!((a["type"].as_str(), a["name"].as_str(), a["fillRule"].as_str()), (Some("path"), Some("A"), Some("nonzero"))); + assert_eq!(a["fills"][0]["color"], json!({"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0})); + assert_eq!(a["strokes"][0]["paint"]["type"], "gradient"); + assert_eq!(a["bounds"], json!([0.0, 0.0, 10.0, 10.0])); + // Echoed back unchanged, nothing changes (swatch links included). + let before = d.clone(); + let out = decode(&serde_json::to_vec(&input).unwrap()).unwrap(); + assert_eq!(apply_output(&mut d, &ids, out, None).unwrap(), ids); + assert_eq!(d, before); +} + +#[test] +fn outputs_update_add_and_delete_in_place() { + let (mut d, ids, layer) = doc(); + let [a, b, c] = [ids[0].0, ids[1].0, ids[2].0]; + let new = |name: &str| json!({"name": name, "path": square(5.0), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}}]}); + // A recoloured, B dropped, C kept; new objects around them. + let out = json!({"objects": [new("n1"), {"id": a, "fills": [null, {"type": "solid", "color": {"model": "rgb", "r": 5, "g": 0, "b": 0}}]}, new("n2"), {"id": c}, new("n3")]}); + let sel = apply_output(&mut d, &ids, decode_json(out).unwrap(), None).unwrap(); + assert_eq!(names(&d, layer), ["n1", "A", "n2", "C", "n3", "?", "?"]); + assert_eq!(sel.len(), 5); + assert!(d.node(NodeId(b)).is_none()); + let na = d.node(NodeId(a)).unwrap(); + let fills = paints(&na.appearance, true); + assert_eq!(fills, [&Paint::None, &Paint::solid(Color::rgb(1.0, 0.0, 0.0))], "a second fill was added; colours are clamped"); + let n1 = d.node(sel[0]).unwrap(); + assert_eq!(n1.appearance.fill_paint(), Paint::solid(Color::gray(0.5))); + assert!(n1.appearance.stroke().is_none()); +} + +#[test] +fn new_objects_take_the_place_of_replaced_inputs_or_go_on_top() { + let (mut d, ids, layer) = doc(); + let out = json!({"objects": [{"name": "X", "path": square(0.0)}]}); + apply_output(&mut d, &ids[1..], decode_json(out.clone()).unwrap(), None).unwrap(); + assert_eq!(names(&d, layer), ["A", "X", "?", "?"], "B and C replaced by X at B's place"); + // No inputs (a generator): on top of the given container. + let (mut d, _, layer) = doc(); + apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer)).unwrap(); + assert_eq!(names(&d, layer).last().map(String::as_str), Some("X")); +} + +#[test] +fn geometry_type_and_strokes_change() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect()); + let blue = json!({"type": "solid", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}}); + let out = json!({"objects": [{"id": a.0, "type": "compound", "path": two, "fillRule": "evenodd", "strokes": [{"paint": blue, "width": 4000}], "opacity": 0.5, "name": ""}]}); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap(); + let n = d.node(a).unwrap(); + let NodeKind::Compound { children, rule } = &n.kind else { panic!("not a compound: {:?}", n.kind) }; + assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd)); + assert_eq!(n.appearance.stroke().unwrap().width, MAX_STROKE_WIDTH); + assert_eq!((n.opacity, n.name.as_deref()), (0.5, None)); + assert_eq!(geometry(n).unwrap().0, two); + // And back to a single path; strokes removed. + let out = json!({"objects": [{"id": a.0, "type": "path", "strokes": []}]}); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap(); + let n = d.node(a).unwrap(); + assert!(matches!(&n.kind, NodeKind::Path { path, .. } if *path == two)); + assert!(n.appearance.stroke().is_none()); +} + +#[test] +fn changed_paints_lose_swatch_links_and_patterns_must_exist() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + d.node_mut(a).unwrap().appearance.set_fill(Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 }); + let linked = json!({"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}, "swatch": "Red", "tint": 0.5}); + apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [linked]}]})).unwrap(), None).unwrap(); + assert!(matches!(d.node(a).unwrap().appearance.fill_paint(), Paint::Solid { swatch: Some(_), .. }), "unchanged: kept"); + let other = json!({"type": "solid", "color": {"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}, "swatch": "Red", "tint": 0.5}); + apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [other]}]})).unwrap(), None).unwrap(); + assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + let pattern = json!({"objects": [{"id": a.0, "fills": [{"type": "pattern", "pattern": "Dots"}]}]}); + assert!(apply_output(&mut d, &[a], decode_json(pattern.clone()).unwrap(), None).is_err()); + d.patterns.push(serde_json::from_value(json!({"name": "Dots", "tile": {"x0": 0, "y0": 0, "x1": 10, "y1": 10}, "art": []})).unwrap()); + assert!(apply_output(&mut d, &[a], decode_json(pattern).unwrap(), None).is_ok()); +} + +#[test] +fn bad_outputs_are_refused() { + let (d, ids, _) = doc(); + let a = ids[0].0; + let far = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e9, 0.0)], false)); + for bad in [ + json!([]), + json!({"objects": 3}), + json!({"objects": [7]}), + json!({"objects": [{"id": 999}]}), + json!({"objects": [{"id": a}, {"id": a}]}), + json!({"objects": [{"name": "no path"}]}), + json!({"objects": [{"id": a, "path": far}]}), + json!({"objects": [{"id": a, "path": {"subpaths": []}}]}), + json!({"objects": [{"id": a, "type": "text"}]}), + json!({"objects": [{"id": a, "fillRule": "odd"}]}), + json!({"objects": [{"id": a, "fills": [{"type": "gradient", "gradient": {"kind": "Linear", "stops": []}}]}]}), + json!({"objects": [{"id": a, "fills": [{"type": "solid"}]}]}), + json!({"objects": [{"id": a, "strokes": [3]}]}), + json!({"objects": [{"id": a, "opacity": "full"}]}), + json!({"error": "nothing to do"}), + ] { + let mut d = d.clone(); + let r = decode_json(bad.clone()).and_then(|out| apply_output(&mut d, &ids, out, None)); + assert!(r.is_err(), "{bad}"); + } + assert!(decode(b"\xff\xfe").is_err()); +} diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs new file mode 100644 index 00000000..a0f18692 --- /dev/null +++ b/crates/plugins/src/registry.rs @@ -0,0 +1,111 @@ +//! The process-wide set of installed plug-ins. +//! +//! Plug-ins are installed per process, not per document: live effects are drawn from every open +//! document (and from scratch sessions such as previews), and they must find the same plug-ins. + +use std::collections::{BTreeMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +#[cfg(not(target_arch = "wasm32"))] +use crate::Error; +use crate::{Limits, Plugin, Result}; + +static PLUGINS: RwLock>> = RwLock::new(BTreeMap::new()); +static REVISION: AtomicU64 = AtomicU64::new(0); + +/// Installs `plugin`, replacing one with the same id. +pub fn install(mut plugin: Plugin) -> Arc { + plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); + let p = Arc::new(plugin); + PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone()); + p +} + +/// Loads `bytes` with the default limits and installs the plug-in. +pub fn install_bytes(bytes: &[u8]) -> Result> { + Ok(install(Plugin::load(bytes, Limits::default())?)) +} + +pub fn get(id: &str) -> Option> { + PLUGINS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} + +/// Installed plug-ins, sorted by id. +pub fn list() -> Vec> { + PLUGINS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect() +} + +/// Uninstalls `id`; false if it wasn't installed. +pub fn remove(id: &str) -> bool { + let gone = PLUGINS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some(); + if gone { + REVISION.fetch_add(1, Ordering::Relaxed); + } + gone +} + +/// Changes whenever a plug-in is installed or removed (for caches such as menus). +pub fn revision() -> u64 { + REVISION.load(Ordering::Relaxed) +} + +/// `s` as a `&'static str`, for catalogues that hold static strings (effect and menu labels). Each +/// distinct string is stored once, so memory stays bounded by the plug-ins ever installed. +pub fn intern(s: &str) -> &'static str { + static SET: Mutex>> = Mutex::new(None); + let mut set = SET.lock().unwrap_or_else(|e| e.into_inner()); + let set = set.get_or_insert_with(HashSet::new); + if let Some(v) = set.get(s) { + return v; + } + let v: &'static str = Box::leak(s.to_string().into_boxed_str()); + set.insert(v); + v +} + +/// Reads a `.wasm` file (refusing anything that isn't a regular file or exceeds the module limit) +/// and loads it. +#[cfg(not(target_arch = "wasm32"))] +pub fn load_file(path: &std::path::Path, limits: Limits) -> Result { + use std::io::Read; + let meta = std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + if !meta.is_file() { + return Err(Error::Io(format!("{} is not a file", path.display()))); + } + if meta.len() > limits.max_module_bytes as u64 { + return Err(Error::Module(format!("{} is {} bytes (limit {})", path.display(), meta.len(), limits.max_module_bytes))); + } + let f = std::fs::File::open(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + let mut bytes = Vec::new(); + f.take(limits.max_module_bytes as u64 + 1).read_to_end(&mut bytes).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string())) +} + +/// What [`load_folder`] did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FolderReport { + /// Ids of the plug-ins installed. + pub loaded: Vec, + /// `(file, error)` for each `.wasm` that failed to load. + pub failed: Vec<(String, String)>, +} + +/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is +/// reported and skipped; the others still load. +#[cfg(not(target_arch = "wasm32"))] +pub fn load_folder(dir: &std::path::Path) -> Result { + let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; + let mut files: Vec = + rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect(); + files.sort(); + files.truncate(1000); + let mut report = FolderReport::default(); + for f in files { + match load_file(&f, Limits::default()) { + Ok(p) => report.loaded.push(install(p).id().to_string()), + Err(e) => report.failed.push((f.display().to_string(), e.to_string())), + } + } + Ok(report) +} diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs new file mode 100644 index 00000000..d33aab7e --- /dev/null +++ b/crates/plugins/src/runtime.rs @@ -0,0 +1,275 @@ +//! The wasmi host: loading, instantiating and calling a plug-in under resource limits. + +use wasmi::{ + Config, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, TypedResumableCall, + WasmParams, WasmResults, +}; + +use crate::manifest::{MAX_MANIFEST_BYTES, Manifest}; +use crate::{Error, Result}; + +/// The plug-in ABI version this host implements (`vc_abi_version` must return it). +pub const ABI_VERSION: i32 = 1; + +/// Fuel handed out per slice; between slices the host checks the deadline. +const FUEL_SLICE: u64 = 20_000_000; + +/// Resource limits for running plug-ins. +#[derive(Clone, Debug, PartialEq)] +pub struct Limits { + /// Largest module accepted. + pub max_module_bytes: usize, + /// Cap on a plug-in's linear memory. The input and the parameters may use half of it. + pub max_memory_bytes: usize, + /// Largest output JSON read back. + pub max_output_bytes: usize, + /// Instructions (fuel) any call may use, plus [`Limits::fuel_per_byte`] per byte of input for + /// `vc_run`. + pub fuel_base: u64, + pub fuel_per_byte: u64, + /// Deepest call nesting. + pub max_recursion_depth: usize, + /// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no + /// monotonic clock in `std`, so the fuel budget alone bounds it there. + pub wall_time_ms: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_module_bytes: 32 << 20, + max_memory_bytes: 512 << 20, + max_output_bytes: 64 << 20, + fuel_base: 50_000_000, + fuel_per_byte: 4_000, + max_recursion_depth: 1024, + wall_time_ms: 60_000, + } + } +} + +/// A loaded, validated plug-in. Cheap to share (`Arc`) and safe to run from several threads: +/// every run gets its own store and instance. +pub struct Plugin { + manifest: Manifest, + engine: Engine, + module: Module, + limits: Limits, + size: usize, + source: Option, + serial: u64, +} + +impl std::fmt::Debug for Plugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Plugin").field("manifest", &self.manifest).field("size", &self.size).field("source", &self.source).finish() + } +} + +/// When a run must stop (native: a wall-clock deadline). +struct Deadline { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant, +} + +impl Deadline { + fn new(limits: &Limits) -> Self { + #[cfg(target_arch = "wasm32")] + let _ = limits; + Self { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms), + } + } + fn check(&self) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + if std::time::Instant::now() >= self.at { + return Err(Error::Limit("time budget".into())); + } + Ok(()) + } +} + +/// One live instance: a store with its own memory limit, and the module's exports. +struct Live { + store: Store, + instance: Instance, + memory: Memory, +} + +impl Live { + fn func(&self, name: &str) -> Result> { + self.instance.get_typed_func::(&self.store, name).map_err(|e| Error::Abi(format!("export `{name}`: {e}"))) + } + + /// The `len << 32 | ptr` block a plug-in returned, copied out (at most `max` bytes). + fn read_packed(&self, packed: i64, what: &str, max: usize) -> Result> { + let packed = packed as u64; + let (ptr, len) = ((packed & 0xffff_ffff) as usize, (packed >> 32) as usize); + if len > max { + return Err(Error::Limit(format!("output size ({what} is {len} bytes, limit {max})"))); + } + let bytes = self.memory.data(&self.store).get(ptr..ptr.saturating_add(len)); + bytes.map(<[u8]>::to_vec).ok_or_else(|| Error::Abi(format!("{what} points outside memory"))) + } +} + +fn wasm_err(e: wasmi::Error) -> Error { + if e.as_trap_code() == Some(wasmi::TrapCode::OutOfFuel) { Error::Limit("instruction budget".into()) } else { Error::Trap(e.to_string()) } +} + +/// `vc_run(input_ptr, input_len, params_ptr, params_len) -> len << 32 | ptr` (negative: error). +type RunArgs = (i32, i32, i32, i32); + +impl Plugin { + /// Validates and loads a module: checks the ABI version, the exports and reads the manifest. + pub fn load(bytes: &[u8], limits: Limits) -> Result { + if bytes.len() > limits.max_module_bytes { + return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes))); + } + if !bytes.starts_with(b"\0asm") { + return Err(Error::Module("not a WebAssembly binary (missing \\0asm header)".into())); + } + let mut config = Config::default(); + config.consume_fuel(true).enforced_limits(EnforcedLimits::strict()).set_max_recursion_depth(limits.max_recursion_depth.max(16)); + let engine = Engine::new(&config); + let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?; + if let Some(imp) = module.imports().next() { + return Err(Error::Abi(format!( + "the module imports `{}.{}`, but plug-ins get no host imports (no WASI, files or network)", + imp.module(), + imp.name() + ))); + } + let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), source: None, serial: 0 }; + let deadline = Deadline::new(&p.limits); + let mut live = p.instantiate()?; + let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; + let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?; + if v != ABI_VERSION { + return Err(Error::Abi(format!("vc_abi_version returned {v}; this host implements version {ABI_VERSION}"))); + } + let manifest: TypedFunc<(), i64> = live.func("vc_manifest")?; + let packed = p.call(&mut live, &manifest, (), p.limits.fuel_base, &deadline)?; + let text = live.read_packed(packed, "vc_manifest", MAX_MANIFEST_BYTES).map_err(|e| match e { + Error::Limit(_) => Error::Manifest(format!("manifest is too long (limit {MAX_MANIFEST_BYTES} bytes)")), + e => e, + })?; + p.manifest = Manifest::parse(&text)?; + // The other exports must exist with the right types. + live.func::("vc_alloc")?; + live.func::("vc_run")?; + Ok(p) + } + + pub fn manifest(&self) -> &Manifest { + &self.manifest + } + pub fn id(&self) -> &str { + &self.manifest.id + } + pub fn limits(&self) -> &Limits { + &self.limits + } + /// Module size in bytes. + pub fn size(&self) -> usize { + self.size + } + /// Where the module was loaded from, if it came from a file. + pub fn source(&self) -> Option<&str> { + self.source.as_deref() + } + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + /// A number unique to this installation (re-installing a plug-in gives it a new one), so + /// caches of its results can tell versions apart. 0 until installed. + pub fn serial(&self) -> u64 { + self.serial + } + pub(crate) fn set_serial(&mut self, serial: u64) { + self.serial = serial; + } + + fn instantiate(&self) -> Result { + let limits = + StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); + let mut store = Store::new(&self.engine, limits); + store.limiter(|l| l); + // The start function (if any) runs on this fuel. + store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?; + let linker = Linker::::new(&self.engine); + let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?; + let memory = + instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?; + Ok(Live { store, instance, memory }) + } + + /// Calls `f` with at most `fuel` instructions, in slices, checking the deadline between them. + fn call(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { + let mut left = fuel; + let give = left.min(FUEL_SLICE); + left -= give; + live.store.set_fuel(give).map_err(wasm_err)?; + let mut call = f.call_resumable(&mut live.store, params).map_err(wasm_err)?; + loop { + match call { + TypedResumableCall::Finished(r) => return Ok(r), + TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())), + TypedResumableCall::OutOfFuel(inv) => { + let need = inv.required_fuel(); + if left == 0 || need > left { + return Err(Error::Limit("instruction budget".into())); + } + deadline.check()?; + let give = left.min(FUEL_SLICE.max(need)); + left -= give; + live.store.set_fuel(give).map_err(wasm_err)?; + call = inv.resume(&mut live.store).map_err(wasm_err)?; + } + } + } + } + + /// Runs `vc_run` on `input` (the objects JSON) with `params` (the parameters JSON) in a fresh + /// instance and returns the output JSON bytes. + pub fn run(&self, input: &[u8], params: &[u8]) -> Result> { + self.run_limited(input, params, &self.limits) + } + + /// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one). + pub fn run_limited(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result> { + // wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must + // still not take the document down (native builds; wasm aborts on panic anyway). + let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits))); + run.map_err(|_| Error::Trap("the runtime panicked".into()))? + } + + fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result> { + let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2); + total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; + let deadline = Deadline::new(limits); + let mut live = self.instantiate()?; + let alloc: TypedFunc = live.func("vc_alloc")?; + let run: TypedFunc = live.func("vc_run")?; + let place = |live: &mut Live, bytes: &[u8]| -> Result { + let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, &deadline)? as u32 as usize; + if ptr == 0 { + return Err(Error::Failed("vc_alloc returned 0 (out of memory)".into())); + } + let outside = || Error::Abi("vc_alloc returned a block outside memory".into()); + let end = ptr.checked_add(bytes.len()).ok_or_else(outside)?; + live.memory.data_mut(&mut live.store).get_mut(ptr..end).ok_or_else(outside)?.copy_from_slice(bytes); + Ok(ptr) + }; + let iptr = place(&mut live, input)?; + let pptr = place(&mut live, params)?; + let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte)); + let packed = self.call(&mut live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, &deadline)?; + if packed < 0 { + return Err(Error::Failed(format!("vc_run returned error code {packed}"))); + } + live.read_packed(packed, "the output", limits.max_output_bytes) + } +} diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs new file mode 100644 index 00000000..df544a8e --- /dev/null +++ b/crates/plugins/src/wat.rs @@ -0,0 +1,47 @@ +//! Minimal plug-ins in WebAssembly text (WAT): the skeleton `docs/plugins.md` describes, which the +//! tests turn into modules (with the `wat` crate) to exercise the host. + +/// Where [`returning`] keeps its output in memory (the manifest sits at 16). +const OUTPUT_AT: usize = 0x1_0000; + +/// `s` as the body of a WAT string literal (every byte that isn't a letter, digit or space +/// escaped). +pub fn string(s: &str) -> String { + s.bytes().map(|b| if b.is_ascii_alphanumeric() || b == b' ' { char::from(b).to_string() } else { format!("\\{b:02x}") }).collect() +} + +/// A plug-in module: `manifest` (JSON), a `vc_run` whose body is `run` (parameters `$in $il $pp +/// $pl`, result i64), extra module fields `extra` and `pages` of initial memory. `vc_alloc` grows +/// memory by whole pages, so its blocks never overlap data segments. +pub fn module(manifest: &str, run: &str, extra: &str, pages: u32) -> String { + format!( + r#"(module + {extra} + (memory (export "memory") {pages}) + (data (i32.const 16) "{data}") + (func (export "vc_abi_version") (result i32) (i32.const 1)) + (func (export "vc_manifest") (result i64) (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16))) + (func (export "vc_alloc") (param $n i32) (result i32) (local $old i32) + (local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16)))) + (if (result i32) (i32.eq (local.get $old) (i32.const -1)) (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536))))) + (func (export "vc_run") (param $in i32) (param $il i32) (param $pp i32) (param $pl i32) (result i64) + {run}))"#, + data = string(manifest), + len = manifest.len(), + ) +} + +/// A `vc_run` body returning the input unchanged. +pub const ECHO: &str = "(i64.or (i64.shl (i64.extend_i32_u (local.get $il)) (i64.const 32)) (i64.extend_i32_u (local.get $in)))"; + +/// A plug-in that always returns `output` (whatever it is given). +pub fn returning(manifest: &str, output: &str) -> String { + let run = format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len()); + let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output)); + module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1) +} + +/// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs). +pub fn manifest(id: &str, kind: &str, params: &str) -> String { + format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"{kind}","params":{params}}}"#) +} diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs new file mode 100644 index 00000000..337254b6 --- /dev/null +++ b/crates/plugins/tests/abi.rs @@ -0,0 +1,208 @@ +//! The plug-in ABI end to end: the example Desaturate plug-in, and hostile modules (infinite +//! loops, memory bombs, out-of-bounds access, bad manifests and outputs…), which must all fail +//! with an error, quickly. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_plugins::{Error, Kind, Limits, Plugin, registry, wat as tpl}; + +/// The example Desaturate plug-in, built from `crates/plugins/example`. +const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm"); + +const MANIFEST: &str = r#"{"id":"test.plugin","name":"Test","version":"1","kind":"filter"}"#; + +fn build(src: &str) -> Vec { + wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}\n{src}")) +} + +/// A filter whose `vc_run` body is `run`, with `extra` module fields. +fn module(manifest: &str, run: &str, extra: &str) -> Vec { + build(&tpl::module(manifest, run, extra, 1)) +} + +fn ok_module(run: &str) -> Vec { + module(MANIFEST, run, "") +} + +const INPUT: &str = r#"{"objects":[]}"#; + +/// Runs `bytes` on an empty input; returns the error and how long it took. +fn run_err(bytes: &[u8], limits: Limits) -> (Error, Duration) { + let t = Instant::now(); + let r = Plugin::load(bytes, limits).and_then(|p| p.run(INPUT.as_bytes(), b"{}")); + (r.expect_err("hostile module must fail"), t.elapsed()) +} + +#[test] +fn example_desaturate_loads_and_reports_its_manifest() { + let p = Plugin::load(DESATURATE, Limits::default()).unwrap(); + let m = p.manifest(); + assert_eq!(m.id, "org.vectorcraft.example.desaturate"); + assert_eq!(m.name, "Desaturate"); + assert_eq!(m.kind, Kind::Filter); + assert_eq!(m.params_doc(), "{amount: 0..100 (100)}"); +} + +#[test] +fn example_desaturate_moves_every_colour_model_to_grey() { + let p = Plugin::load(DESATURATE, Limits::default()).unwrap(); + let input = json!({"objects": [{"id": 3, "type": "path", "fills": [ + {"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}}, + {"type": "gradient", "gradient": {"kind": "Linear", "stops": [ + {"offset": 0.0, "color": {"model": "lab", "l": 50.0, "a": 40.0, "b": -20.0}}, + {"offset": 1.0, "color": {"model": "cmyk", "c": 0.9, "m": 0.0, "y": 0.3, "k": 0.1}}]}}]}]}); + let out = p.run(input.to_string().as_bytes(), br#"{"amount": 100}"#).unwrap(); + let out: Value = serde_json::from_slice(&out).unwrap(); + let fills = &out["objects"][0]["fills"]; + let rgb = &fills[0]["color"]; + assert!((rgb["r"].as_f64().unwrap() - 0.2126).abs() < 1e-9 && rgb["r"] == rgb["g"] && rgb["g"] == rgb["b"], "{rgb}"); + let stops = &fills[1]["gradient"]["stops"]; + assert_eq!((stops[0]["color"]["a"].as_f64(), stops[0]["color"]["b"].as_f64()), (Some(0.0), Some(0.0))); + let cmyk = &stops[1]["color"]; + assert!((cmyk["c"].as_f64().unwrap() - 0.4).abs() < 1e-9 && cmyk["k"] == 0.1, "{cmyk}"); + assert_eq!(out["objects"][0]["id"], 3, "ids come back, so the objects are updated in place"); + // Half way. + let half: Value = serde_json::from_slice(&p.run(input.to_string().as_bytes(), br#"{"amount": 50}"#).unwrap()).unwrap(); + assert!((half["objects"][0]["fills"][0]["color"]["r"].as_f64().unwrap() - 0.6063).abs() < 1e-9); + // Nothing to work on: the plug-in's own message. + let none = p.run(INPUT.as_bytes(), b"{}").unwrap(); + assert!(matches!(vectorcraft_plugins::objects::decode(&none), Err(Error::Failed(m)) if m.contains("Select some paths"))); +} + +#[test] +fn input_and_params_reach_the_plugin_and_output_comes_back() { + let p = Plugin::load(&build(&tpl::module(MANIFEST, tpl::ECHO, "", 1)), Limits::default()).unwrap(); + let input = br#"{"objects":[{"id":1}]}"#; + assert_eq!(p.run(input, b"{}").unwrap(), input); + // Returns the parameters instead. + let params = module(MANIFEST, "(i64.or (i64.shl (i64.extend_i32_u (local.get $pl)) (i64.const 32)) (i64.extend_i32_u (local.get $pp)))", ""); + let p = Plugin::load(¶ms, Limits::default()).unwrap(); + assert_eq!(p.run(input, br#"{"amount":5}"#).unwrap(), br#"{"amount":5}"#); + let fixed = Plugin::load(&build(&tpl::returning(MANIFEST, r#"{"objects":[]}"#)), Limits::default()).unwrap(); + assert_eq!(fixed.run(input, b"{}").unwrap(), br#"{"objects":[]}"#); +} + +#[test] +fn hostile_modules_fail_quickly() { + let quick = Duration::from_secs(5); + let cases: Vec<(&str, Vec)> = vec![ + ("infinite loop", ok_module("(loop $l (br $l)) (i64.const 0)")), + ("memory.grow bomb, then trap", ok_module("(loop $l (br_if $l (i32.ne (memory.grow (i32.const 512)) (i32.const -1)))) unreachable")), + ("memory.grow forever", ok_module("(loop $l (drop (memory.grow (i32.const 1))) (br $l)) (i64.const 0)")), + ("out-of-bounds write", ok_module("(i32.store (i32.const -4) (i32.const 0)) (i64.const 0)")), + ("trap", ok_module("unreachable")), + ("division by zero", ok_module("(drop (i32.div_u (i32.const 1) (i32.const 0))) (i64.const 0)")), + ("deep recursion", module(MANIFEST, "(call $r) (i64.const 0)", "(func $r (call $r))")), + ("error code", ok_module("(i64.const -7)")), + ("output outside memory", ok_module("(i64.const 0x00000100fffffff0)")), + ("huge output", ok_module("(i64.const 0x7fffffff00000000)")), + ( + "wrong ABI version", + build(&tpl::module(MANIFEST, "(i64.const 0)", "", 1).replace("(result i32) (i32.const 1))", "(result i32) (i32.const 2))")), + ), + ("bad manifest", module("not json", "(i64.const 0)", "")), + ("manifest with a bad id", module(r#"{"id":"a b","name":"x","kind":"filter"}"#, "(i64.const 0)", "")), + ("manifest of the wrong kind", module(r#"{"id":"a","name":"x","kind":"panel"}"#, "(i64.const 0)", "")), + ("start function loops", module(MANIFEST, "(i64.const 0)", "(func $spin (loop $l (br $l))) (start $spin)")), + ( + "WASI import", + module(MANIFEST, "(i64.const 0)", r#"(import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#), + ), + ]; + for (name, bytes) in cases { + let (e, t) = run_err(&bytes, Limits::default()); + assert!(t < quick, "{name} took {t:?}"); + eprintln!("{name}: {e} ({t:?})"); + } +} + +#[test] +fn malformed_and_oversized_modules_are_refused() { + let half = &DESATURATE[..DESATURATE.len() / 2]; + for (name, bytes) in [("empty", &b""[..]), ("garbage", &b"\0asm\x01\0\0\0\xff\xff\xff"[..]), ("not wasm", &b"MZ\x90\0"[..]), ("truncated", half)] + { + assert!(matches!(Plugin::load(bytes, Limits::default()), Err(Error::Module(_))), "{name}"); + } + let tiny = Limits { max_module_bytes: 100, ..Limits::default() }; + assert!(matches!(Plugin::load(DESATURATE, tiny), Err(Error::Module(_)))); + // Huge initial memory, manifest outside memory, missing exports, bogus allocations. + let huge = build(r#"(module (memory (export "memory") 60000))"#); + assert!(Plugin::load(&huge, Limits::default()).is_err()); + let no_run = build( + r#"(module (memory (export "memory") 1) (func (export "vc_abi_version") (result i32) (i32.const 1)) + (func (export "vc_manifest") (result i64) (i64.const 0)))"#, + ); + assert!(Plugin::load(&no_run, Limits::default()).is_err()); + let src = r#"(module (memory (export "memory") 1) (func (export "vc_abi_version") (result i32) (i32.const 1)) + (func (export "vc_manifest") (result i64) (i64.const 0x00000064ffffff00)) + (func (export "vc_alloc") (param i32) (result i32) (i32.const 16)) + (func (export "vc_run") (param i32 i32 i32 i32) (result i64) (i64.const 0)))"#; + assert!(matches!(Plugin::load(&build(src), Limits::default()), Err(Error::Abi(_)))); + let bogus_alloc = build(&tpl::module(MANIFEST, "(i64.const 0)", "", 1).replace( + "(func (export \"vc_alloc\") (param $n i32) (result i32) (local $old i32)", + "(func (export \"vc_alloc\") (param $n i32) (result i32) (local $old i32) (return (i32.const -16))", + )); + let (e, _) = run_err(&bogus_alloc, Limits::default()); + assert!(matches!(e, Error::Abi(_)), "{e}"); +} + +#[test] +fn budgets_stop_runaway_plugins() { + let spin = ok_module("(loop $l (br $l)) (i64.const 0)"); + let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, fuel_per_byte: 10, ..Limits::default() }); + assert_eq!(e, Error::Limit("instruction budget".into())); +} + +#[test] +fn wall_clock_budget_stops_runaway_plugins() { + let spin = ok_module("(loop $l (br $l)) (i64.const 0)"); + // Wall-clock budget, with an effectively unlimited instruction budget. + let (e, t) = run_err(&spin, Limits { fuel_base: u64::MAX / 4, wall_time_ms: 300, ..Limits::default() }); + assert_eq!(e, Error::Limit("time budget".into())); + assert!(t < Duration::from_secs(3), "{t:?}"); +} + +#[test] +fn memory_cap_limits_input_and_growth() { + let echo = build(&tpl::module(MANIFEST, tpl::ECHO, "", 1)); + let p = Plugin::load(&echo, Limits { max_memory_bytes: 1 << 20, ..Limits::default() }).unwrap(); + assert!(p.run(&vec![b' '; 300 << 10], b"{}").is_ok(), "fits in half the memory"); + assert!(matches!(p.run(&vec![b' '; 600 << 10], b"{}"), Err(Error::Limit(_))), "doesn't"); + // Growing past the cap fails inside the module (memory.grow returns -1): vc_alloc gives 0. + let p = Plugin::load(&echo, Limits { max_memory_bytes: 64 << 10, ..Limits::default() }).unwrap(); + assert!(matches!(p.run(b"{}", b"{}"), Err(Error::Failed(m)) if m.contains("vc_alloc"))); +} + +#[test] +fn registry_install_list_remove() { + // Its own id: the registry is process-wide and tests run in parallel. + let id = "test.registry"; + let p = registry::install_bytes(&module(r#"{"id":"test.registry","name":"R","kind":"filter"}"#, "(i64.const 0)", "")).unwrap(); + assert_eq!(p.id(), id); + let rev = registry::revision(); + assert!(registry::list().iter().any(|p| p.id() == id)); + assert!(registry::get(id).is_some()); + assert!(registry::remove(id)); + assert!(!registry::remove(id)); + assert!(registry::get(id).is_none()); + assert!(registry::revision() > rev); + assert!(std::ptr::eq(registry::intern("Abc"), registry::intern("Abc")), "interned once"); +} + +#[test] +fn folder_loading_skips_bad_files() { + let dir = std::env::temp_dir().join(format!("vectorcraft-plugins-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a-desaturate.wasm"), DESATURATE).unwrap(); + std::fs::write(dir.join("b-broken.wasm"), b"\0asm junk").unwrap(); + std::fs::write(dir.join("notes.txt"), b"ignored").unwrap(); + let r = registry::load_folder(&dir).unwrap(); + assert_eq!(r.loaded, ["org.vectorcraft.example.desaturate"]); + assert_eq!(r.failed.len(), 1); + assert!(r.failed[0].0.ends_with("b-broken.wasm")); + assert!(registry::load_folder(&dir.join("missing")).is_err()); + assert!(registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/plugins/tests/fixtures/desaturate.wasm b/crates/plugins/tests/fixtures/desaturate.wasm new file mode 100644 index 00000000..a8b4cdf0 Binary files /dev/null and b/crates/plugins/tests/fixtures/desaturate.wasm differ diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index dc2b1d26..df08cfee 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,6 +18,7 @@ vectorcraft-tools = { workspace = true } vectorcraft-engine = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-effects = { workspace = true } +vectorcraft-plugins = { workspace = true } vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } vectorcraft-eps = { workspace = true } @@ -31,3 +32,4 @@ log = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } +wat = { workspace = true } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index c7b3a88d..2a116c94 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -112,7 +112,11 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (id, relative) = (d.str("__effect"), d.bool("relative")); - let changed = form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()); + // Plug-in effects get fields from their parameter schema. + let changed = match vectorcraft_plugins::effect::installed(&id) { + Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params), + None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()), + }; ui.add_space(6.0); let mut pv = d.bool("preview"); let pv_changed = ui.checkbox(&mut pv, "Preview").changed(); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 8a20a3d4..22a9d152 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -205,6 +205,48 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn changed } +/// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers +/// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns +/// true when a value changed. +pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, vectorcraft_plugins::ParamSpec)]) -> bool { + use vectorcraft_plugins::ParamSpec; + let t = Tokens::get(ui.ctx()); + let mut changed = false; + egui::Grid::new("plugin-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for (k, spec) in specs { + ui.label(egui::RichText::new(humanize(k)).color(t.text)); + let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value()); + let new = match spec { + ParamSpec::Number { min, max, .. } => { + let mut x = cur.as_f64().unwrap_or(*min).clamp(*min, *max); + let speed = ((max - min) / 200.0).clamp(0.01, 10.0); + ui.add(egui::DragValue::new(&mut x).range(*min..=*max).speed(speed).max_decimals(2)).changed().then(|| json!(x)) + } + ParamSpec::Int { min, max, .. } => { + let mut x = cur.as_i64().unwrap_or(*min).clamp(*min, *max); + ui.add(egui::DragValue::new(&mut x).range(*min..=*max).speed(0.2)).changed().then(|| json!(x)) + } + ParamSpec::Bool { .. } => { + let mut b = cur.as_bool().unwrap_or(false); + ui.checkbox(&mut b, "").changed().then(|| json!(b)) + } + ParamSpec::Choice { options, .. } => { + let labels: Vec<&str> = options.iter().map(String::as_str).collect(); + crate::widgets::dropdown(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0) + .and_then(|i| options.get(i)) + .map(|o| json!(o)) + } + }; + if let Some(v) = new { + d.fields.insert(k.clone(), v); + changed = true; + } + ui.end_row(); + } + }); + changed +} + /// A field label from its camelCase key (`miterLimit` → "Miter Limit:"). pub(super) fn humanize(k: &str) -> String { let mut s = String::new(); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index a775978a..dde768d5 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -40,6 +40,7 @@ mod path_ops; pub mod pdf_presets; pub mod place; pub mod placement_options; +pub mod plugin; mod png_options; pub mod print; pub mod print_presets; @@ -241,6 +242,7 @@ registry! { Print: [print::KIND] => print::SPEC, PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC, PrintPresets: [print_presets::KIND] => print_presets::SPEC, + Plugin: [plugin::KIND] => plugin::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs new file mode 100644 index 00000000..81699993 --- /dev/null +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -0,0 +1,104 @@ +//! WebAssembly plug-ins in the menus and their generated dialogs. +//! +//! Object › Plug-ins lists the installed object filters (`plugin.dialog {id}`), then Install +//! Plug-in… and Reload Plug-ins; Effect › Plug-ins lists the live-effect plug-ins, which open the +//! effect dialog (`effect.dialog`). A filter with parameters opens dialog `plugin`: one field per +//! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live +//! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs +//! at once. + +use serde_json::{Map, Value, json}; +use vectorcraft_plugins::{Kind, registry}; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::menus::Item; +use crate::state::Dialog; + +/// The dialog kind of plug-in filters. +pub const KIND: &str = "plugin"; + +const CMD: &str = "plugin.run"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, max_width: Some(420.0), ..DialogSpec::FORM }; + +/// A menu label for plug-in `name`: with an ellipsis when it opens a dialog. +fn label(name: &str, has_params: bool) -> &'static str { + let dots = if has_params && !name.ends_with('…') { "…" } else { "" }; + registry::intern(&format!("{name}{dots}")) +} + +/// Object › Plug-ins: the installed object filters, then Install Plug-in… and Reload Plug-ins. +pub fn object_menu() -> Vec { + let mut items: Vec = registry::list() + .iter() + .filter(|p| p.manifest().kind == Kind::Filter) + .map(|p| Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() }))) + .collect(); + if items.is_empty() { + items.push(Item::Header("No filter plug-ins installed")); + } + items.extend([ + Item::Sep, + Item::Cmd("Install Plug-in…", "ui.installPlugin", Value::Null), + Item::Cmd("Reload Plug-ins", "plugin.reload", Value::Null), + ]); + items +} + +/// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without +/// options apply at once, as built-in ones do. +pub fn effect_menu() -> Option { + let items: Vec = vectorcraft_effects::plugin_effects() + .into_iter() + .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }) + .collect(); + (!items.is_empty()).then_some(Item::Sub("Plug-ins", items)) +} + +/// `plugin.dialog {id, params?}`: run filter `id` at once when it takes no parameters or `params` +/// are given, else open its dialog prefilled with its defaults. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let id = p.get("id").and_then(Value::as_str).ok_or("missing `id` (see plugin.list)")?; + let plugin = registry::get(id).ok_or_else(|| format!("no plug-in {id:?} is installed"))?; + let m = plugin.manifest(); + if m.kind != Kind::Filter { + return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)); + } + let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); + if m.params.is_empty() || given.is_some() { + return app.run(CMD, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)})); + } + let mut fields: Map = m.defaults(); + fields.insert("__plugin".into(), json!(id)); + fields.insert("__label".into(), json!(m.name.trim_end_matches('…'))); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(Value::Null) +} + +fn params(d: &Dialog) -> Value { + json!({"id": d.str("__plugin"), "params": form::params(d)}) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + // A plug-in uninstalled while its dialog is open closes it. + let Some(plugin) = registry::get(&d.str("__plugin")) else { return true }; + let m = plugin.manifest(); + if !m.description.is_empty() { + ui.label(egui::RichText::new(&m.description).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + ui.add_space(8.0); + } + form::schema_fields(ui, d, &m.params); + let (label, p) = (d.str("__label"), params(d)); + form::preview(app, ui, d, &label, CMD, p); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, CMD, params(d)) +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index ecfb281b..2e62387b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -19,6 +19,13 @@ const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft" /// files are imported. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { let ext = fileio::extension(name); + // A WebAssembly plug-in is installed. + if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { + let r = + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}))?; + app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name))); + return Ok(()); + } let presets = [ (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), @@ -65,6 +72,19 @@ fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result) -> Result { + let path = match path { + Some(p) => p, + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => pick_open(app, &FilePick { filters: vec![("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?, + }; + let r = app.run("plugin.install", json!({ "path": path }))?; + app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); + Ok(r) +} + /// File → Open… pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { if let Some(f) = app.services.open_async.as_mut() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 059e0106..fd7f998d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -64,6 +64,8 @@ mod tests_pdfoutput; #[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_plugins; +#[cfg(test)] mod tests_printps; #[cfg(test)] mod tests_printtiling; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3477c2e9..14d2b760 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -530,6 +530,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", ), + ( + "plugin.dialog", + "Plug-in…", + "", + "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", + ), + ( + "ui.installPlugin", + "Install Plug-in…", + "", + "{path?: a .wasm file} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -945,6 +957,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::print::open_preset(app, p), + "plugin.dialog" => crate::dialogs::plugin::open(app, p), + "ui.installPlugin" => io::install_plugin(app, s("path")), _ => return None, }; Some(r) @@ -1113,6 +1127,12 @@ pub fn listed_slots(app: &VectorcraftApp) -> usize { views + io::recent_files(app).len().min(RECENT_IDS.len()) + user(swatchlib::libraries(&app.session)) + user(stylelib::libraries(&app.session)) } +/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins +/// list them, so a menu built once (the native one) is rebuilt. +pub fn plugin_revision() -> u64 { + vectorcraft_plugins::registry::revision() +} + /// Type → Recent Fonts slots. const RECENT_FONT_IDS: [&str; 10] = [ "type.recentFont1", @@ -1184,6 +1204,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "css.copy" | "css.exportFile" => app.session.active().is_some(), "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), + "plugin.dialog" => app.session.active().is_some(), _ => true, } } @@ -1543,6 +1564,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { cp("As Multiple Assets", "assets.add", json!({"multiple": true})), ], ), + Sep, + sub("Plug-ins", crate::dialogs::plugin::object_menu()), ], ), ( @@ -1942,7 +1965,7 @@ fn label_of(it: &Item) -> &'static str { pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { let dialog = label.ends_with('…') && p.as_object().is_some_and(|o| !o.is_empty()) - && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode"); + && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog"); if dialog { return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); } @@ -2249,6 +2272,8 @@ fn effect_menu() -> Vec { }) .collect(); if sub_name == "Blur" { + // Live-effect plug-ins close the vector effects. + out.extend(crate::dialogs::plugin::effect_menu()); if !items.is_empty() { out.push(Sep); out.push(Item::Header("Raster Effects")); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index bab72d90..2f4822ea 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -133,19 +133,25 @@ fn click_item(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, set_pstate(ctx, "ap-multi", (Some(n.id), set)); } -fn catalog() -> &'static [(String, String, Vec)] { - static C: OnceLock)>> = OnceLock::new(); - C.get_or_init(|| { - vectorcraft_effects::effect_catalog() - .into_iter() - .map(|e| (e.id.to_string(), e.label.trim_end_matches('…').to_string(), e.menu.iter().map(|s| s.to_string()).collect())) - .collect() - }) +/// An effect as the fx menu lists it: (id, label without the ellipsis, Effect-menu path). +fn catalog_entry(e: vectorcraft_effects::EffectInfo) -> (String, String, Vec) { + (e.id.to_string(), e.label.trim_end_matches('…').to_string(), e.menu.iter().map(|s| s.to_string()).collect()) } -/// Display label of an effect id ("stylize.dropShadow" → "Drop Shadow"). +fn catalog() -> &'static [(String, String, Vec)] { + static C: OnceLock)>> = OnceLock::new(); + C.get_or_init(|| vectorcraft_effects::effect_catalog().into_iter().map(catalog_entry).collect()) +} + +/// Display label of an effect id ("stylize.dropShadow" → "Drop Shadow"; plug-in effects by their +/// plug-in's name). pub fn effect_label(id: &str) -> String { - catalog().iter().find(|c| c.0 == id).map(|c| c.1.clone()).unwrap_or_else(|| id.rsplit('.').next().unwrap_or(id).to_string()) + catalog() + .iter() + .find(|c| c.0 == id) + .map(|c| c.1.clone()) + .or_else(|| vectorcraft_effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string())) + .unwrap_or_else(|| id.rsplit('.').next().unwrap_or(id).to_string()) } /// "Opacity: Default" or "Opacity: 50% Multiply". @@ -704,7 +710,7 @@ fn has_options(id: &str) -> bool { /// edits go through `effect.setParams`. fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect) { let t = Tokens::get(ui.ctx()); - let defaults = vectorcraft_effects::effect_catalog().into_iter().find(|c| c.id == e.id).map(|c| c.defaults).unwrap_or(Value::Null); + let defaults = vectorcraft_effects::default_params(&e.id).unwrap_or(Value::Null); let mut params = defaults.as_object().cloned().unwrap_or_default(); if let Some(cur) = e.params.as_object() { for (key, v) in cur { @@ -861,7 +867,8 @@ fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: /// opens the effect dialog. pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { let mut groups: Vec<(String, Vec<(String, String)>)> = vec![]; - for (id, label, menu) in catalog() { + let plugins: Vec<_> = vectorcraft_effects::plugin_effects().into_iter().map(catalog_entry).collect(); + for (id, label, menu) in catalog().iter().chain(&plugins) { let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into()); match groups.iter_mut().find(|(n, _)| *n == g) { Some((_, v)) => v.push((id.clone(), label.clone())), diff --git a/crates/ui-egui/src/tests_plugins.rs b/crates/ui-egui/src/tests_plugins.rs new file mode 100644 index 00000000..7cd254f1 --- /dev/null +++ b/crates/ui-egui/src/tests_plugins.rs @@ -0,0 +1,115 @@ +//! WebAssembly plug-ins in the UI (#121): Object › Plug-ins and Effect › Plug-ins, the generated +//! filter dialog with its live preview, plug-in effects in the effect dialog, and installing +//! `.wasm` files through File › Open. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +use crate::state::Dialog; +use crate::{VectorcraftApp, dialogs, io, menus, theme}; + +const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); +const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app +} + +/// One headless frame of the dialog layer. +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let mut out = ctx.run_ui(Default::default(), |ui| dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn red_rect(app: &mut VectorcraftApp) -> vectorcraft_doc::NodeId { + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + vectorcraft_doc::NodeId(id) +} + +fn fill_rgb(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> [f32; 3] { + app.session.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap().to_rgb_uncalibrated() +} + +fn entry<'a>(entries: &'a [menus::MenuEntry], path: &[&str], label: &str) -> Option<&'a menus::MenuEntry> { + entries.iter().find(|e| e.path == path && e.label == label) +} + +#[test] +fn plugins_are_listed_in_the_object_and_effect_menus() { + let mut app = app(); + let before = menus::plugin_revision(); + io::open_bytes(&mut app, "desaturate.wasm", DESATURATE, None).unwrap(); + let effect = tpl::module(&tpl::manifest("test.ui.echo", "effect", r#"{"wiggle":{"type":"bool"}}"#), tpl::ECHO, "", 1); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(effect).unwrap())})).unwrap(); + assert_ne!(menus::plugin_revision(), before, "the native menu is rebuilt"); + let entries = menus::menu_entries(&app); + let filter = entry(&entries, &["Object", "Plug-ins"], "Desaturate…").expect("filter listed"); + assert_eq!((filter.command.as_deref(), &filter.params), (Some("plugin.dialog"), &json!({"id": DESATURATE_ID}))); + assert!(entry(&entries, &["Object", "Plug-ins"], "Install Plug-in…").is_some()); + assert!(entry(&entries, &["Object", "Plug-ins"], "Reload Plug-ins").is_some()); + let fx = entry(&entries, &["Effect", "Plug-ins"], "Test test.ui.echo…").expect("effect listed"); + assert_eq!((fx.command.as_deref(), &fx.params), (Some("effect.dialog"), &json!({"effect": "plugin.test.ui.echo"}))); + // Menu clicks on items with params don't turn into the generic parameter dialog. + assert_eq!(menus::click_target("Desaturate…", "plugin.dialog", &filter.params).0, "plugin.dialog"); +} + +#[test] +fn filter_dialog_previews_and_commits_one_undo_step() { + let mut app = app(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)})).unwrap(); + let id = red_rect(&mut app); + let undo = app.session.doc().unwrap().history.undo.len(); + menus::invoke(&mut app, "plugin.dialog", json!({"id": DESATURATE_ID})); + let d = app.ui.dialog.as_ref().expect("dialog open"); + assert_eq!((d.kind.as_str(), d.str("__label"), d.fields.get("amount")), ("plugin", "Desaturate".to_string(), Some(&json!(100.0)))); + // Agents set fields like `ui.dialog.set` does; the frame previews them. + app.ui.dialog.as_mut().unwrap().fields.insert("amount".into(), json!(50)); + frame(&mut app); + assert!(app.session.in_interaction(), "previewing"); + let [r, g, _] = fill_rgb(&app, id); + assert!(r < 1.0 && g > 0.0, "half desaturated on the canvas: {r} {g}"); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Desaturate"); + // Cancel rolls the preview back. + menus::invoke(&mut app, "plugin.dialog", json!({"id": DESATURATE_ID})); + frame(&mut app); + let previewed = fill_rgb(&app, id); + dialogs::cancel(&mut app); + assert_ne!(fill_rgb(&app, id), previewed); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // With params, the menu command runs at once. + app.run("plugin.dialog", json!({"id": DESATURATE_ID, "params": {"amount": 100}})).unwrap(); + let [r, g, b] = fill_rgb(&app, id); + assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6); + assert!(app.run("plugin.dialog", json!({"id": "test.ui.missing"})).is_err()); +} + +#[test] +fn plugin_effects_open_the_effect_dialog_with_their_schema() { + let mut app = app(); + let src = + tpl::module(&tpl::manifest("test.ui.fx", "effect", r#"{"mode":{"type":"choice","options":["a","b"],"default":"b"}}"#), tpl::ECHO, "", 1); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap(); + let id = red_rect(&mut app); + app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx"})).unwrap(); + let d: &Dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("__label"), d.str("mode")), ("effect", "Test test.ui.fx".to_string(), "b".to_string())); + frame(&mut app); + dialogs::confirm(&mut app).unwrap(); + let fx = &app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects; + assert_eq!((fx[0].id.as_str(), &fx[0].params), ("plugin.test.ui.fx", &json!({"mode": "b"}))); + assert_eq!(crate::panels::appearance::effect_label("plugin.test.ui.fx"), "Test test.ui.fx"); + // Editing it again starts from its values. + app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx", "index": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("mode"), Some(&Value::from("b"))); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bc7d57e7..d9e8cf04 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -86,6 +86,13 @@ effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setPara `effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard", value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it. +Plug-in dialogs: `engine.execute {command: "plugin.dialog", params: {id}}` (Object › Plug-ins) runs an object filter +plug-in at once when it takes no parameters, else opens the `plugin` dialog: one field per declared parameter (named +after it), `__plugin` (the plug-in id) and `preview`. It previews `plugin.run` on the canvas while open; +`ui.dialog.confirm` keeps the result as one undo step and `ui.dialog.cancel` rolls it back. Live-effect plug-ins open +the `effect` dialog (`effect.dialog {effect: "plugin."}`). `ui.installPlugin {path?}` installs a `.wasm` (without +a path it asks for one); see [plugins.md](plugins.md). + Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, `includeSpotBlacks`); `ui.dialog.set` then `ui.dialog.confirm` runs it on the selection. diff --git a/docs/mcp.md b/docs/mcp.md index f5cb473e..d6367f90 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1678,3 +1678,18 @@ to `object.setBounds`, which scales the other dimension by the same factor; agen {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"constrainProportions","value":true}}} {"name":"run_command","arguments":{"command":"object.setBounds","params":{"width":200,"proportional":true}}} ``` + +## Plug-ins + +WebAssembly plug-ins ([plugins.md](plugins.md)) are installed per process and reached through commands: +`plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, +schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one +undo step; it may change, delete or add objects, and the result is selected). A live-effect plug-in is an effect with +id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and +`effect.list` lists it. A failing plug-in returns an error and leaves the document as it was. + +```json +{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} +{"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +``` diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..1c45e22a --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,219 @@ +# Plug-ins: the WebAssembly plug-in API (ABI v1) + +VectorCraft does not host native plug-ins built for other illustration apps: that needs unsafe FFI into +arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** +run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's +plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. + +There are two kinds of plug-in: + +- **Object filters** read the selected paths and compound paths (geometry, fills, strokes, gradients, opacity) + and return the objects to keep, change or add. A run is one undo step. Use them for recolouring, path + clean-up, halftones, generative patterns and the like. +- **Live effects** rewrite one object's geometry each time it is drawn, from its appearance stack, like the + built-in Distort & Transform effects. The art stays editable: move or reshape it and the effect follows. + +This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the +`plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). + +## Using plug-ins + +| Command | Params | What it does | +|---|---|---| +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, size, source}], "folder": report}` | +| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | +| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true` | Validates and installs a module; returns its `plugin.list` entry. | +| `plugin.remove` | `{"id"}` | Uninstalls. | +| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped. | + +Live effects are applied like any effect: `effect.apply {"effect": "plugin.", "params": {…}}`, edited with +`effect.setParams`, listed by `effect.list` (menu `Effect › Plug-ins`) and baked by Object › Expand Appearance. + +In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload +Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with +parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns) with a live +preview; without parameters it runs at once. **File › Open** of a `.wasm` file installs it too (on the web this is +how plug-ins get in). + +**Preference folder.** Edit › Preferences › Performance & Storage › *Additional Plug-ins Folder*: when set, every +`*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; +the web build installs from bytes). + +Plug-ins are installed **per process**, not per document. Documents store a live effect as an ordinary effect record +(`{"id": "plugin.", "params": {…}}`): while the plug-in is installed it is drawn, exported and expanded; without it +the object is drawn as if the effect weren't there, and the record is kept for when the plug-in comes back. + +## The sandbox + +The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter +(MIT OR Apache-2.0). It builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits +(`vectorcraft_plugins::Limits`, defaults): + +| Limit | Default | On violation | +|---|---|---| +| Host imports | none: no WASI, file system, network, clock or randomness | module rejected at install | +| Module size | 32 MiB | rejected | +| Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | +| Output | 64 MiB of JSON | `Limit("output size")` | +| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run` | `Limit("instruction budget")` | +| Wall clock | 60 s per filter run, 5 s per live-effect run (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Call depth | 1,024 frames | trap | +| Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | + +Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed +modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the +document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that +fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). + +wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM +sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts +the process instead of returning an error. + +Drawing evaluates effects often, so live-effect results are cached by plug-in installation, parameters and input +geometry: a plug-in runs again only when one of them changes. + +## ABI v1 + +A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign extension, multi-value, +saturating conversions; no SIMD, no memory64) with **no imports** and these exports. Integers are `i32` unless noted; +pointers are offsets into the module's memory. + +| Export | Signature | Meaning | +|---|---|---| +| `memory` | memory | The module's linear memory. | +| `vc_abi_version` | `() -> i32` | Must return `1`. | +| `vc_manifest` | `() -> i64` | `(len << 32) \| ptr` of the UTF-8 manifest JSON (≤ 64 KiB). | +| `vc_alloc` | `(size) -> ptr` | A block of `size` bytes the host may write, or `0` on failure. Never freed: each run gets a fresh instance. | +| `vc_run` | `(input, input_len, params, params_len) -> i64` | Runs the plug-in on the input JSON with the parameters JSON; returns `(len << 32) \| ptr` of the output JSON, or a negative error code. | + +### Manifest + +```json +{ + "id": "org.example.tidy", + "name": "Tidy Paths", + "version": "1.0.0", + "kind": "filter", + "author": "Example", + "description": "What it does.", + "params": { + "amount": {"type": "number", "min": 0, "max": 100, "default": 50}, + "steps": {"type": "int", "min": 1, "max": 16, "default": 4}, + "closed": {"type": "bool", "default": false}, + "mode": {"type": "choice", "options": ["soft", "hard"], "default": "soft"} + } +} +``` + +- `id`: 1–64 characters from `A-Z a-z 0-9 . _ -` (reverse-DNS recommended). Installing a module with the same id + replaces the old one. A live effect's effect id is `plugin.`. +- `name`: the menu label (1–64 printable characters). `kind`: `"filter"` (object filter) or `"effect"` (live effect). +- `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or + `preview`. The host validates the user's values before calling: missing values take the default, numbers are + clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. + +### Input + +`input` is UTF-8 JSON: `{"objects": [...]}`. An object filter gets the selected paths and compound paths in paint +order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide and clipping +paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never +touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one +object, holding only geometry (`type`, `path`, `fillRule`, `bounds`). + +```json +{"objects": [ + {"id": 12, "type": "path", "name": "Leaf", + "path": {"subpaths": [{"closed": true, "anchors": [ + {"p": [10, 10]}, + {"p": [60, 10], "in": [45, 0], "out": [75, 20], "kind": "Smooth"}, + {"p": [35, 60]}]}]}, + "fillRule": "nonzero", + "fills": [{"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.1}}], + "strokes": [{"paint": {"type": "gradient", "gradient": {"kind": "Linear", "stops": [ + {"offset": 0, "color": {"model": "gray", "k": 0}}, + {"offset": 1, "color": {"model": "cmyk", "c": 0, "m": 0.5, "y": 1, "k": 0}}]}, "angle": 0}, + "width": 2}], + "opacity": 1, + "bounds": [10, 0, 75, 60]} +]} +``` + +- `type`: `"path"` or `"compound"` (a compound path: `path` holds its members' subpaths, painted as one). +- `path`: VectorCraft's own path form. Each subpath is a run of anchors; `p` is the anchor, `in` / `out` its handles + (absent: no handle), `kind` `"Smooth"` when the handles stay collinear. Coordinates are points, y down. +- `fills`, `strokes`: the object's fills and strokes in paint order (bottom first); `null` is no paint. Paints use the + native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient": + {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}]}, "geom"?, "angle", + "freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", + c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except + Lab's. +- `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). + +### Parameters + +`params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_context`: + +```json +{"amount": 50, "mode": "soft", + "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792]}} +``` + +`mode` is `"filter"` or `"effect"`; `bounds` the selection's geometric bounds (a live effect: the reference box of the +effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the +selection's centre, else the first. + +### Output + +The output is UTF-8 JSON in the input's shape: `{"objects": [...]}`. To decline with a message for the user, return +`{"error": "Select two paths."}`. + +For an **object filter**, the output is the new state of the input objects: + +- An object with the `id` of an input object updates it; keys it leaves out keep their value (`{"id": 12}` keeps the + object as it is). `fills` and `strokes` set the object's fills and strokes in order: extra entries add fills (above + the last one) or strokes, missing ones are removed; each stroke keeps its weight unless `width` is given (a new one: 1 pt). +- An object without an `id` is new and needs a `path`. It goes just above the object listed before it, or, listed + before any input object, just below the first one listed after it. New objects take a path's defaults: no fills or + strokes, full opacity. +- Input objects missing from the output are deleted. The order of the input objects themselves doesn't change. +- `type` turns a path into a compound path (one member per subpath) or back; `name: ""` clears the name. + +The host validates everything before the document changes: coordinates must be finite and within ±4,000,000 pt, at +most 100,000 objects and 4,000,000 anchors, 256 fills, strokes or gradient stops; colours, opacities and offsets are +clamped into range, stroke weights to 0–1,000 pt; a pattern must exist in the document. A paint returned unchanged +keeps its swatch link; any other paint is unlinked. + +For a **live effect**, the geometry of every returned object's `path` (their subpaths, in order) replaces the +object's; the rest is ignored (the object keeps its own appearance). An empty list removes the geometry. + +## Writing a plug-in in Rust + +[`crates/plugins/example`](../crates/plugins/example) is a complete object filter, **Desaturate**: it moves every +colour of the selection towards grey, in each colour's own model, using `serde_json` (no imports, no WASI). Build it +with: + +```sh +rustup target add wasm32-unknown-unknown +crates/plugins/example/build.sh # cargo build --release --target wasm32-unknown-unknown +``` + +The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. +The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, +WAT…), as long as the module has no imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never +touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests +build their plug-ins from. + +## Performance + +The interpreter costs roughly 10–100× native speed per instruction. Filters handle a selection in one call, so a +plug-in that parses and writes JSON with `serde_json` takes milliseconds for hundreds of paths. Live effects run once +per change of their input (results are cached), not per frame. + +## Not in v1 + +- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host + callbacks (progress, cancel), plug-in file formats. +- Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or + symbols. +- Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 5bfa0c1c..ad4168f4 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -59,15 +59,16 @@ pub const TABLE: &[(&str, Class)] = &[ ("cad", Class::Layer(3)), ("eps", Class::Layer(3)), ("metafile", Class::Layer(3)), + ("plugins", Class::Layer(2)), ]; /// Explicit orderings *within* a layer (earlier may be used by later). /// The L0 foundation is a small chain: `raster` builds on `color` and /// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`) /// reuses the CPU reference (`compose`) for LUTs and parity tests. EPS previews use the renderer's -/// TIFF writer. +/// TIFF writer. Live effects run effect plug-ins. pub const INTRA_LAYER_ORDER: &[&[&str]] = - &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"], &["render", "eps"]]; + &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"], &["render", "eps"], &["plugins", "effects"]]; fn intra_layer_allowed(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to));