Merge pull request #158 from storytold/fix/issue-121

Part of #121: sandboxed WebAssembly plug-ins (object filters and live effects)
This commit is contained in:
BFlat 2026-10-06 00:30:34 +07:00 committed by GitHub
commit 679191b4ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 3712 additions and 31 deletions

157
Cargo.lock generated
View File

@ -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"

View File

@ -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"

3
NOTICE
View File

@ -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.

View File

@ -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;

View File

@ -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 }

View File

@ -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.<plug-in id>`, 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<EffectInfo> {
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.<id>`).
fn plugin_info(id: &str) -> Option<EffectInfo> {
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<EffectInfo> {
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<Value> {
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<Effect> {
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)),

View File

@ -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 }

View File

@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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.<id>\" 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<CommandSpec> {
"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.<plug-in id>, 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<Value> {
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = 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}})

View File

@ -623,6 +623,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.

View File

@ -146,7 +146,8 @@ fn open_exts_cover_every_readable_format() {
}
let filters: Vec<_> = 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]

View File

@ -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
})
}

View File

@ -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.<id>"}`) 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<Arc<Plugin>> {
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<Value> {
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(&params).map_err(|e| bad(C, e.to_string()))?;
let out = objects::decode(&plugin.run(&input, &params).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::<Vec<_>>()}))
}
/// 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<Value> {
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(&registry::install(plugin)))
}
#[cfg(not(target_arch = "wasm32"))]
fn load_path(path: &str) -> Result<Plugin> {
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
}
#[cfg(target_arch = "wasm32")]
fn load_path(_: &str) -> Result<Plugin> {
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<Option<(String, Value)>> = 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::<Vec<_>>()})
}
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<Value> {
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<Value> {
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::<Vec<_>>(), "folder": folder}))
}
fn info(_: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<CommandSpec> {
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.<id>), 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
),
]
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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<NodeId> = 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<NodeId> = 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();
}

View File

@ -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<Value, String> {
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<Value = Value> {
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::from),
any::<i64>().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<Value = Value> {
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<Value = Value> {
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<Value = Value> {
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::<bool>()).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<Value = Value> {
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<Value = Value> {
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 = (Option<usize>, 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<usize>, Value)], inputs: &[u64], junk: Option<Value>) -> String {
if let Some(j) = junk {
return j.to_string();
}
let list: Vec<Value> = 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::<bool>(),
) {
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
let st = s.doc().unwrap();
let inputs: Vec<u64> = 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::<prop::sample::Index>(), any::<u8>()), 0..8),
cut in prop::option::of(any::<prop::sample::Index>()),
garbage in prop::collection::vec(any::<u8>(), 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")?;
}
}

21
crates/plugins/Cargo.toml Normal file
View File

@ -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 }

105
crates/plugins/example/Cargo.lock generated Normal file
View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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::<u8>::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::<Value>(input), serde_json::from_slice::<Value>(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<Vec<(&str, f64)>> = 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
}

View File

@ -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.<plug-in id>`).
//!
//! 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.<plug-in id>`.
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.<id>`).
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<std::sync::Arc<crate::Plugin>> {
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<u64, (Key, Option<PathData>)>;
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
static ERRORS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
/// The last error of live-effect plug-in `id` (cleared when it next succeeds).
pub fn last_error(id: &str) -> Option<String> {
ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned()
}
fn note(id: &str, r: &Result<PathData>) {
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<PathData> {
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<PathData> {
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(&params));
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, &params, &limits)?)?))
}

59
crates/plugins/src/lib.rs Normal file
View File

@ -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<T> = std::result::Result<T, Error>;

View File

@ -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<String>,
#[serde(default)]
default: Option<String>,
},
}
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<Manifest> {
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<String> = 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<String> = 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<String, Value> {
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<Map<String, Value>> {
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<Vec<(String, ParamSpec)>, 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<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(Vec::new())
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
let mut out: Vec<(String, ParamSpec)> = Vec::new();
while let Some((k, v)) = map.next_entry::<String, ParamSpec>()? {
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<S: serde::Serializer>(p: &[(String, ParamSpec)], s: S) -> std::result::Result<S::Ok, S::Error> {
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::<Vec<_>>(), ["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());
}
}

View File

@ -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<NodeId>,
/// `"compound"` (true) or `"path"` (false).
pub compound: Option<bool>,
/// `Some("")` clears the name.
pub name: Option<String>,
pub path: Option<PathData>,
pub rule: Option<FillRule>,
/// Fill paints in paint order (bottom first).
pub fills: Option<Vec<Paint>>,
pub strokes: Option<Vec<OutStroke>>,
pub opacity: Option<f32>,
}
/// 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<f64>,
}
fn rule_name(r: FillRule) -> &'static str {
match r {
FillRule::NonZero => "nonzero",
FillRule::EvenOdd => "evenodd",
}
}
fn rect_json(r: Option<Rect>) -> 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<Value> {
let (path, rule, compound) = geometry(n)?;
let fills: Vec<Value> =
n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect();
let strokes: Vec<Value> = 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<u8> {
let objects: Vec<Value> = 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<PathData> {
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<Paint> {
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<String, Value>, key: &str) -> Option<&'a Value> {
o.get(key).filter(|v| !v.is_null())
}
fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject> {
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<Option<&Vec<Value>>> {
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::<Result<_>>()?);
}
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<Vec<OutObject>> {
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<Paint> {
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<f64>)>) {
let idx: Vec<usize> = 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<Node>], path: PathData) -> Vec<Arc<Node>> {
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::<Vec<_>>())),
(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::<Result<Vec<_>>>()?;
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<NodeId> {
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, out: &mut Vec<NodeId>) {
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<NodeId> = 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<NodeId, Node>,
delete: HashSet<NodeId>,
before: HashMap<NodeId, Vec<Node>>,
after: HashMap<NodeId, Vec<Node>>,
/// New objects that go on top of a container (`None`: the top-level list).
top: Option<(Option<NodeId>, Vec<Node>)>,
}
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<NodeId>, list: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
let mut out: Option<Vec<Arc<Node>>> = 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<Arc<Node>> = 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<OutObject>, parent: Option<NodeId>) -> Result<Vec<NodeId>> {
let wanted: HashSet<NodeId> = inputs.iter().copied().collect();
let mut originals: HashMap<NodeId, Node> = 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<Node> = vec![];
let mut prev: Option<NodeId> = 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<OutObject>) -> PathData {
PathData::new(objects.into_iter().filter_map(|o| o.path).flat_map(|p| p.subpaths).collect())
}
#[cfg(test)]
mod tests;

View File

@ -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>, 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<String> {
d.children(Some(layer)).unwrap().iter().map(|n| n.name.clone().unwrap_or_else(|| "?".into())).collect()
}
fn decode_json(v: Value) -> Result<Vec<OutObject>> {
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());
}

View File

@ -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<BTreeMap<String, Arc<Plugin>>> = 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> {
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<Arc<Plugin>> {
Ok(install(Plugin::load(bytes, Limits::default())?))
}
pub fn get(id: &str) -> Option<Arc<Plugin>> {
PLUGINS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
}
/// Installed plug-ins, sorted by id.
pub fn list() -> Vec<Arc<Plugin>> {
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<Option<HashSet<&'static str>>> = 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<Plugin> {
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<String>,
/// `(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<FolderReport> {
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
let mut files: Vec<std::path::PathBuf> =
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)
}

View File

@ -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<String>,
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<StoreLimits>,
instance: Instance,
memory: Memory,
}
impl Live {
fn func<P: WasmParams, R: WasmResults>(&self, name: &str) -> Result<TypedFunc<P, R>> {
self.instance.get_typed_func::<P, R>(&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<Vec<u8>> {
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<Plugin> {
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::<i32, i32>("vc_alloc")?;
live.func::<RunArgs, i64>("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<String>) -> 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<Live> {
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::<StoreLimits>::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<P: WasmParams, R: WasmResults>(&self, live: &mut Live, f: &TypedFunc<P, R>, params: P, fuel: u64, deadline: &Deadline) -> Result<R> {
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<Vec<u8>> {
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<Vec<u8>> {
// 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<Vec<u8>> {
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<i32, i32> = live.func("vc_alloc")?;
let run: TypedFunc<RunArgs, i64> = live.func("vc_run")?;
let place = |live: &mut Live, bytes: &[u8]| -> Result<usize> {
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)
}
}

47
crates/plugins/src/wat.rs Normal file
View File

@ -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}}}"#)
}

208
crates/plugins/tests/abi.rs Normal file
View File

@ -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<u8> {
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<u8> {
build(&tpl::module(manifest, run, extra, 1))
}
fn ok_module(run: &str) -> Vec<u8> {
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(&params, 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<u8>)> = 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();
}

Binary file not shown.

View File

@ -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 }

View File

@ -112,7 +112,11 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
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();

View File

@ -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();

View File

@ -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).

View File

@ -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<Item> {
let mut items: Vec<Item> = 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<Item> {
let items: Vec<Item> = 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<Value, String> {
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<String, Value> = 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<Value, String> {
form::commit_preview(app, CMD, params(d))
}

View File

@ -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<String>) -> 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<String, String
app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
}
/// Object › Plug-ins › Install Plug-in…: installs the `.wasm` at `path`, else a picked one (the
/// web's file picker hands the file to [`open_bytes`], which installs it).
pub fn install_plugin(app: &mut VectorcraftApp, path: Option<String>) -> Result<Value, String> {
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() {

View File

@ -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;

View File

@ -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<R
Ok(Value::Null)
}
"ui.printPresetDialog" => 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<Item>)> {
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<Item> {
})
.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"));

View File

@ -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<String>)] {
static C: OnceLock<Vec<(String, String, Vec<String>)>> = 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<String>) {
(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<String>)] {
static C: OnceLock<Vec<(String, String, Vec<String>)>> = 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<usize>, 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())),

View File

@ -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")));
}

View File

@ -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.<id>"}`). `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.

View File

@ -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.<id>`: `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}}}}
```

219
docs/plugins.md Normal file
View File

@ -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.<id>", "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.<id>", "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.<id>`.
- `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.

View File

@ -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));