mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00:00
A plug-in could filter objects or reshape them as a live effect, but
nothing it made could leave the app: pen plotters, cutting machines and
laser cutters read formats VectorCraft doesn't write. An exporter is a
third kind of plug-in ("kind": "exporter", with the file "extensions"
it writes), in the same sandbox and with the same ABI v1 exports.
- It adds a write-only format, plugin.<id>. document.formats lists it
with its plug-in id, and document.export writes it by that id, or by
a path ending in one of its extensions that no built-in format has;
"params" carries its parameters, checked against its manifest as a
filter's are.
- File › Export › Export As lists it after the built-in formats, with
Use Artboards greyed out, since the plug-in writes the whole
document. One with parameters asks for them in a dialog generated
from its schema (pluginExport) once the file is picked.
- The plug-in is given the native file's JSON (format::json_part:
images of 8 KiB or more without their data) and _context {mode:
"export", extension, name}. It returns {"dataBase64", "warnings"?}
or {"error"}. The host writes the file, and nothing when the plug-in
declines or returns bad data.
- An export gets 10 G instructions plus 4,000 per byte and 300 s.
Background Export's Cancel stops it between two slices of that
budget (Error::Cancelled), and a cancelled live-effect run is not
cached as a failure.
- plugin.run and Object › Plug-ins say where an exporter is instead of
calling it a live effect.
engine/tests/plugin_export.rs reads back what an exporter is handed
with wat::echo_exporter, an exporter whose file is its input or its
parameters (its base64 encoder is written in WAT). The document it is
given is document.save's JSON, without the large image's data, and its
parameters arrive defaulted, clamped and with the file's name and
extension. It also covers listing and removal, built-in extensions
winning, selectedOnly, declines, bad base64, missing data, warnings,
and Cancel stopping a plug-in that never returns. It is a test binary
of its own: installed plug-ins are process-wide, and the format-list
unit tests check exact lists. dialogs/tests_plugin_export.rs drives
Export As headlessly; plugins/tests/abi.rs covers the budget, the
parameters and the echo exporter; format/tests/native_v4.rs covers
json_part.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
127 lines
6.6 KiB
Rust
127 lines
6.6 KiB
Rust
//! 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}}}"#)
|
|
}
|
|
|
|
/// The manifest of a test exporter `id` writing `extensions` (a JSON list) with `params`.
|
|
pub fn exporter(id: &str, extensions: &str, params: &str) -> String {
|
|
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#)
|
|
}
|
|
|
|
/// What [`echo_exporter`] writes as its file.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Echo {
|
|
/// The input: the document, as the host hands it over.
|
|
Input,
|
|
/// The parameters JSON, `_context` included.
|
|
Params,
|
|
}
|
|
|
|
/// Where [`echo_exporter`] keeps the base64 alphabet, then its output's opening and closing text.
|
|
const ALPHABET_AT: usize = 0x8000;
|
|
|
|
/// An exporter (manifest `manifest`) whose file is exactly what it is given (`echo`), so a test
|
|
/// reads back what the host hands a plug-in: it returns `{"dataBase64": "…"}`, encoding the bytes
|
|
/// in base64 itself, three bytes to four characters, the last group padded with `=`.
|
|
pub fn echo_exporter(manifest: &str, echo: Echo) -> String {
|
|
let (src, len) = match echo {
|
|
Echo::Input => ("$in", "$il"),
|
|
Echo::Params => ("$pp", "$pl"),
|
|
};
|
|
let (open, close) = (r#"{"dataBase64":""#, r#""}"#);
|
|
let (open_at, close_at) = (ALPHABET_AT + 64, ALPHABET_AT + 96);
|
|
// One character of the alphabet: the six bits of `$b` from `shift` up.
|
|
let char_at = |shift: u32| {
|
|
format!("(i32.load8_u (i32.add (i32.const {ALPHABET_AT}) (i32.and (i32.shr_u (local.get $b) (i32.const {shift})) (i32.const 63))))")
|
|
};
|
|
let byte = |k: u32| format!("(i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (i32.const {k}))))");
|
|
let run = format!(
|
|
r#"(local $o i32) (local $n i32) (local $w i32) (local $i i32) (local $b i32) (local $rest i32)
|
|
(local.set $n (i32.add (i32.const {frame}) (i32.mul (i32.const 4) (i32.div_u (i32.add (local.get {len}) (i32.const 2)) (i32.const 3)))))
|
|
(local.set $o (memory.grow (i32.add (i32.shr_u (local.get $n) (i32.const 16)) (i32.const 1))))
|
|
(if (i32.eq (local.get $o) (i32.const -1)) (then (return (i64.const -1))))
|
|
(local.set $o (i32.mul (local.get $o) (i32.const 65536)))
|
|
(memory.copy (local.get $o) (i32.const {open_at}) (i32.const {open_len}))
|
|
(local.set $w (i32.add (local.get $o) (i32.const {open_len})))
|
|
(block $done (loop $triple
|
|
(br_if $done (i32.gt_u (i32.add (local.get $i) (i32.const 3)) (local.get {len})))
|
|
(local.set $b (i32.or (i32.or (i32.shl {b0} (i32.const 16)) (i32.shl {b1} (i32.const 8))) {b2}))
|
|
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
|
|
(i32.store8 offset=2 (local.get $w) {c6}) (i32.store8 offset=3 (local.get $w) {c0})
|
|
(local.set $w (i32.add (local.get $w) (i32.const 4)))
|
|
(local.set $i (i32.add (local.get $i) (i32.const 3)))
|
|
(br $triple)))
|
|
(local.set $rest (i32.sub (local.get {len}) (local.get $i)))
|
|
(if (i32.ne (local.get $rest) (i32.const 0)) (then
|
|
(local.set $b (i32.shl {b0} (i32.const 16)))
|
|
(if (i32.eq (local.get $rest) (i32.const 2)) (then (local.set $b (i32.or (local.get $b) (i32.shl {b1} (i32.const 8))))))
|
|
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
|
|
(i32.store8 offset=2 (local.get $w) (if (result i32) (i32.eq (local.get $rest) (i32.const 2)) (then {c6}) (else (i32.const 61))))
|
|
(i32.store8 offset=3 (local.get $w) (i32.const 61))
|
|
(local.set $w (i32.add (local.get $w) (i32.const 4)))))
|
|
(memory.copy (local.get $w) (i32.const {close_at}) (i32.const {close_len}))
|
|
(i64.or (i64.shl (i64.extend_i32_u (local.get $n)) (i64.const 32)) (i64.extend_i32_u (local.get $o)))"#,
|
|
frame = open.len() + close.len(),
|
|
open_len = open.len(),
|
|
close_len = close.len(),
|
|
b0 = byte(0),
|
|
b1 = byte(1),
|
|
b2 = byte(2),
|
|
c18 = char_at(18),
|
|
c12 = char_at(12),
|
|
c6 = char_at(6),
|
|
c0 = char_at(0),
|
|
);
|
|
let extra = format!(
|
|
r#"(data (i32.const {ALPHABET_AT}) "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
|
|
(data (i32.const {open_at}) "{}")
|
|
(data (i32.const {close_at}) "{}")"#,
|
|
string(open),
|
|
string(close)
|
|
);
|
|
module(manifest, &run, &extra, 1)
|
|
}
|