Plug-ins: importer plug-ins open file types VectorCraft does not read

An importer plug-in (kind "importer", permission import.read) lists 1 to 8
file extensions and turns a file's bytes into objects, which open as a new
document whose artboard is their bounds. File › Open lists its extensions,
File › Open With (file.openWith) opens a file with a chosen importer, and
document.open takes importer and params. A built-in format that reads the
file by its content or extension always opens it; among importers listing
the same extension, the one whose id sorts first opens it. An importer reads
at most 128 MiB, and Place still reads only the built-in formats.

Docs: docs/plugins.md, docs/mcp.md, docs/gaps.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-07 23:05:09 -08:00
parent eb32022204
commit fd94ebe86c
55 changed files with 1413 additions and 87 deletions
+50 -20
View File
@@ -5,7 +5,7 @@ arbitrary machine code and cannot work in the browser build. Instead, plug-ins a
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 three kinds of plug-in:
There are four 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
@@ -15,6 +15,9 @@ There are three kinds of plug-in:
- **Exporters** write the whole document in a file format of their own, for pen plotters, cutting machines,
laser cutters and the like. File › Export › Export As lists them after the built-in formats, and the host writes the
bytes they return: a plug-in still never touches a file.
- **Importers** read files in a format of their own, such as a plotter's or a cutting machine's, into a new document of
paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host
hands them the file's bytes.
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`).
@@ -38,6 +41,16 @@ An exporter adds a write-only format, `plugin.<id>`, which `document.formats` li
`document.export {"format": "plugin.<id>", "params"?: {…}, "path"?}` writes it, and so does a `path` ending in one of
its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats.
An importer adds a read-only format, `plugin.<id>`, which `document.formats` lists with its `plugin` id, its extensions
in `openExtensions`. `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format
reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the
one whose id sorts first opens it. `document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer,
which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg`
only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an
importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high);
the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are
VectorCraft's own.
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, text, lists
@@ -45,7 +58,10 @@ of numbers and colors, labelled, explained and sorted into sections as its manif
parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, which keeps its
heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use
Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the
same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs
same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File ›
Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the
installed importers by name (`file.openWith {"importer", "path"?}`), and each opens a file picked among its extensions
with that importer (on the web, File › Open's picker opens instead). **File › Open** of a `.wasm` file installs
a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the
background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed
([below](#permissions)).
@@ -71,20 +87,22 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem
| 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`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export 10 G + 4,000 per byte | `Limit("instruction budget")` |
| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run or export (desktop; checked every 20 M instructions) | `Limit("time budget")` |
| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` |
| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` |
| Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that |
| Call depth | 1,024 frames | trap |
| Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected |
An export runs where the export runs: with Background Export on, on the export's worker, whose Cancel stops the
plug-in between two of those 20 M-instruction slices; nothing is written then.
plug-in between two of those 20 M-instruction slices; nothing is written then. An import stops the same way when the
open it runs in is cancelled, and nothing opens then.
### Heavy filters
Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a
simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its
manifest (only a filter may: a live effect runs whenever the document is drawn, and an exporter has an export's
budget). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s
manifest (only a filter may: a live effect runs whenever the document is drawn, an exporter has an export's budget and
an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s
(`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how
much there is only once the run has ended.
@@ -115,12 +133,14 @@ capability model proposed for PhotoCraft plug-ins
| `state` | filter | Keep data of its own with the document (`documentData`). |
| `effect.render` | effect | Change how the objects its effect is applied to are drawn. |
| `export.write` | exporter | Write the document as a file in its own format. |
| `import.read` | importer | Read files you open in its format and make documents from them. |
- A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter
`doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and
`export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is
empty may do none of these, and also installs without a prompt.
- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three.
`export.write`; an importer `import.read`. Such a plug-in installs without a prompt. This rule is VectorCraft's own.
A filter whose list is empty may do none of these, and also installs without a prompt.
- An effect, an exporter or an importer that lists permissions lists all of its implied ones. A filter may list any of
its three.
An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, or one of the names
kept for a later plug-in API (`progress`, `ui.panel`, `ui.tool`) is refused when the module is installed, and the
error names it.
@@ -197,16 +217,17 @@ pointers are offsets into the module's memory.
- `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, or an exporter's format name in Export As (1–64 printable characters). `kind`: `"filter"`
(object filter), `"effect"` (live effect) or `"exporter"`. Exporters came after the first ABI v1 hosts, which refuse
the kind when the plug-in is installed.
- `name`: the menu label, or an exporter's or importer's format name in Export As, Open and Open With (1–64 printable
characters). `kind`: `"filter"` (object filter), `"effect"` (live effect), `"exporter"` or `"importer"`. Exporters
and importers came after the first ABI v1 hosts, which refuse the kind when the plug-in is installed.
- `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy
filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget.
- `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names
([permissions](#permissions)); without it, what its kind implies.
- `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot
(`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`):
`document.export` then writes the exporter's format only when asked for by its format id.
- `extensions` (exporters and importers only, and required): 1–8 file extensions, lower-case letters and digits
without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both
list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id, and
`document.open` reads a file with the importer only when `importer` names it.
- `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. Numeric defaults
@@ -255,6 +276,8 @@ appearance and effect record, artboard, swatch and symbol, so an exporter reads
`version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME
type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`.
An **importer** gets the bytes of the file being opened, as they are (not JSON), at most 128 MiB of them.
```json
{"objects": [
{"id": 12, "type": "path", "name": "Leaf",
@@ -299,14 +322,16 @@ type and `blob` but without their data, which follows the JSON in a saved file;
"_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792], "documentData": {"pens": 4}}}
```
`mode` is `"filter"`, `"effect"` or `"export"`; `bounds` the selection's geometric bounds (a live effect: the reference
`mode` is `"filter"`, `"effect"`, `"export"` or `"import"`; `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; `documentData` (filters) what the plug-in last kept with the document
(`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the
object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke,
counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt",
"name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the
extension (the document's name when the export is not given a path), which some formats keep in their header.
extension (the document's name when the export is not given a path), which some formats keep in their header. An
import's `_context` is `{"mode": "import", "extension": "plt", "name": "Poster"}`: the extension of the file being
opened and its name without the extension.
### Output
@@ -357,6 +382,11 @@ An **exporter** returns `{"dataBase64": "…", "warnings"?: ["…"]}`: the file,
is, and what the export left out or changed, for the user (at most 100 warnings of at most 500 characters each, shown
with the export's own). A decline (`{"error": …}`), a missing `dataBase64` or data that isn't base64 writes nothing.
An **importer** returns `{"objects": [...], "warnings"?: ["…"]}`: new paths and compound paths (no `id`) in the
filters' object form, checked as a filter's new objects are, with warnings capped as an exporter's are and shown with
the open's own. An output with no objects, with keys other than `objects`, `warnings` and `error`, or with an object
that fails the checks opens nothing, and neither does a decline (`{"error": …}`).
## Writing a plug-in in Rust
[`crates/plugins/example`](../crates/plugins/example) is a complete object filter, **Desaturate**: it moves every
@@ -386,8 +416,8 @@ per change of their input, parameters or paints (results are cached), not per fr
## Not in v1
- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host
callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open
files).
callbacks (a plug-in reporting progress or asking whether it was cancelled), and importers that place files
(File › Place reads only the built-in formats) or make type, images, layers or artboards.
- 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.