Merge pull request #3171 from KaboomFox/p1-exporter-plugins

Plug-ins: exporters write file formats of their own (G17)
This commit is contained in:
BFlat
2026-10-20 08:27:26 +07:00
committed by GitHub
48 changed files with 1054 additions and 68 deletions
+3
View File
@@ -137,3 +137,6 @@ sync_github_name = true
[people.MiQ71]
real_name = "Mirko Kerezovic"
sync_github_name = false
[people.KaboomFox]
sync_github_name = true
+29
View File
@@ -411,6 +411,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
/// [`encode_all`] of `doc` as it is.
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let f = super::writable(C, Some(format), None)?;
if let Some(plugin) = vectorcraft_plugins::export::by_format(f.id) {
return plugin_file(doc, &plugin, f, p);
}
let failed = vectorcraft_render::decode_failures();
let mut enc = encode_files(doc, f, p)?;
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
@@ -432,6 +435,32 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
Ok(enc)
}
/// The file exporter plug-in `plugin` writes of `doc` in its format `f`
/// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON
/// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else
/// one named after the document) with that path's extension when the plug-in lists it, else its
/// first. Its warnings come back with the file.
fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result<Encoded> {
use vectorcraft_plugins::{Error, export};
let doc = &*doc.without_edit_modes();
let file = vectorcraft_format::save_with(doc, &vectorcraft_format::SaveOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
let path = p.get("path").and_then(Value::as_str);
let listed = path.map(super::extension).and_then(|e| f.extensions.iter().copied().find(|x| *x == e));
let extension = listed.or(f.extensions.first().copied()).unwrap_or_default();
let name = super::file_stem(path.unwrap_or(&super::default_name(doc, extension)));
let params = p.get("params").cloned().unwrap_or(Value::Null);
let target = export::Target { extension, name: &name };
let out = export::run(plugin, vectorcraft_format::json_part(&file), &params, target).map_err(|e| match e {
Error::Cancelled => EngineError::Cancelled,
Error::Params(why) => bad(C, why),
e => EngineError::Other(e.to_string()),
})?;
// The plug-in's output, so reported as its other output errors are.
let invalid = || EngineError::Other(Error::Output("`dataBase64` is not valid base64".into()).to_string());
let bytes = vectorcraft_format::base64_decode(&out.data_base64).ok_or_else(invalid)?;
Ok(Encoded { warnings: out.warnings, ..Encoded::one(bytes) })
}
/// [`encode_all`] without the font substitution warning.
fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
+54 -7
View File
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -198,7 +198,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File Formats",
[],
None,
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in that adds the format (its id is plugin.<plug-in id>; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
always,
formats
),
@@ -713,7 +713,8 @@ pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
pub fn export_extensions() -> Vec<&'static str> {
let mut v: Vec<&'static str> = Vec::new();
for e in FORMATS.iter().filter(|f| f.write).filter_map(|f| f.extensions.first()) {
let added = plugin_formats();
for e in FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write).filter_map(|f| f.extensions.first()) {
if !v.contains(e) {
v.push(e);
}
@@ -800,10 +801,47 @@ pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Word", DOCX_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in
/// one, else one an exporter plug-in adds ([`plugin_formats`]). A built-in format keeps its
/// extension, so an exporter that lists it too is found by its format id.
pub fn format(id_or_ext: &str) -> Option<&'static Format> {
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str())))
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str()))).or_else(|| {
let added = plugin_formats();
added.iter().find(|f| f.id.eq_ignore_ascii_case(&k)).or_else(|| added.iter().find(|f| f.extensions.contains(&k.as_str()))).copied()
})
}
/// The formats the installed exporter plug-ins add ([`vectorcraft_plugins::export`]), by plug-in
/// id: write only, with the plug-in's name as the label and its extensions. Each is made once for
/// each installation of its plug-in, its strings interned, so formats live as long as the catalogues
/// that hold them.
pub fn plugin_formats() -> Vec<&'static Format> {
use vectorcraft_plugins::{export, registry};
static MADE: std::sync::Mutex<Vec<(u64, &'static Format)>> = std::sync::Mutex::new(Vec::new());
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
export::installed()
.iter()
.map(|p| {
if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) {
return *f;
}
let m = p.manifest();
let extensions: Vec<&'static str> = m.extensions.iter().map(|e| registry::intern(e)).collect();
let f: &'static Format = Box::leak(Box::new(Format {
id: registry::intern(&export::format_id(&m.id)),
label: registry::intern(&m.name),
extensions: Box::leak(extensions.into_boxed_slice()),
mime: "application/octet-stream",
read: false,
write: true,
raster: false,
options: &[],
}));
made.push((p.serial(), f));
f
})
.collect()
}
/// The lower-case extension of a file name or path (empty when it has none).
@@ -858,9 +896,18 @@ fn writable(cmd: &str, format_param: Option<&str>, path: Option<&str>) -> Result
}
fn formats(_: &mut Session, _: &Value) -> Result<Value> {
let ids = |pick: fn(&Format) -> bool| FORMATS.iter().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
let added = plugin_formats();
let all = || FORMATS.iter().chain(added.iter().copied());
let ids = |pick: fn(&Format) -> bool| all().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
let listed = |f: &Format| {
let mut v = f.to_json();
if let Some(id) = vectorcraft_plugins::export::plugin_id(f.id) {
v["plugin"] = json!(id);
}
v
};
Ok(json!({
"formats": FORMATS.iter().map(Format::to_json).collect::<Vec<_>>(),
"formats": all().map(listed).collect::<Vec<_>>(),
"readable": ids(|f| f.read),
"writable": ids(|f| f.write),
"openExtensions": OPEN_EXTS,
+19 -7
View File
@@ -5,12 +5,13 @@
//! 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`.
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`; exporters
//! add a file format that `document.export` writes (`fileio::plugin_formats`).
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
use vectorcraft_plugins::{Kind, Plugin, effect, objects, registry};
use vectorcraft_plugins::{Kind, Plugin, effect, export, objects, registry};
use super::*;
@@ -27,8 +28,13 @@ pub fn describe(p: &Plugin) -> Value {
"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));
match m.kind {
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
Kind::Exporter => {
v["format"] = json!(export::format_id(&m.id));
v["extensions"] = json!(m.extensions);
}
Kind::Filter => {}
}
v
}
@@ -56,8 +62,14 @@ 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))));
match m.kind {
Kind::Filter => {}
Kind::Effect => {
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
}
Kind::Exporter => {
return Err(bad(C, format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_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()))?);
@@ -206,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
always,
list
),
+217
View File
@@ -0,0 +1,217 @@
//! Exporter plug-ins (`"kind": "exporter"`, docs/plugins.md › Exporters) through the engine: the
//! formats they add to `document.formats` and `document.export`, the document and parameters they
//! are given, and what is written when they succeed, decline, misbehave or are cancelled.
//!
//! A test binary of its own: installed plug-ins are process-wide, and the unit tests that check the
//! exact list of formats would see these ones.
// 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 std::path::PathBuf;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::{EngineError, Session};
use vectorcraft_plugins::wat::{self as tpl, Echo};
/// A document with one rectangle.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
s
}
/// Installs the plug-in written in WAT `src` → its `plugin.list` entry.
fn install(s: &mut Session, src: &str) -> Value {
let wasm = wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}"));
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap()
}
/// The file a `document.export` without a path gives back.
fn exported(r: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(r["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// A fresh folder for files this test writes.
fn tmp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-plugin-export-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn path_of(p: &std::path::Path) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn an_exporter_adds_a_write_only_format_while_it_is_installed() {
let mut s = session();
let entry = install(&mut s, &tpl::returning(&tpl::exporter("test.list", r#"["tsl","tsl2"]"#, "{}"), r#"{"dataBase64":""}"#));
assert_eq!((&entry["kind"], &entry["format"], &entry["extensions"]), (&json!("exporter"), &json!("plugin.test.list"), &json!(["tsl", "tsl2"])));
let formats = s.execute("document.formats", &json!({})).unwrap();
let listed = formats["formats"].as_array().unwrap().iter().find(|f| f["id"] == "plugin.test.list").expect("listed");
assert_eq!((&listed["label"], &listed["extensions"]), (&json!("Test test.list"), &json!(["tsl", "tsl2"])));
assert_eq!((&listed["read"], &listed["write"], &listed["plugin"]), (&json!(false), &json!(true), &json!("test.list")));
assert!(formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(!formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(fileio::export_extensions().contains(&"tsl"));
assert_eq!(fileio::format(".TSL2").map(|f| f.id), Some("plugin.test.list"), "by any of its extensions, in any case");
assert_eq!(fileio::format("plugin.test.list").map(|f| f.label), Some("Test test.list"));
s.execute("plugin.remove", &json!({"id": "test.list"})).unwrap();
let formats = s.execute("document.formats", &json!({})).unwrap();
assert!(!formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(fileio::format("tsl").is_none());
let e = s.execute("document.export", &json!({"format": "plugin.test.list"})).unwrap_err();
assert!(e.to_string().contains("unknown format"), "{e}");
}
/// The exporter is given the document as a native file's JSON: what `document.save` writes before
/// the data of large images, which stays behind (compact, where a save of a small document is
/// indented).
#[test]
fn the_exporter_is_given_the_native_file_s_json() {
let mut s = session();
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.input", r#"["tsi"]"#, "{}"), Echo::Input));
// Noise, so the PNG is large enough to go after the JSON in a native file.
let mut seed = 1_u32;
let noise = image::RgbaImage::from_fn(64, 64, |_, _| {
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
let [r, g, b, _] = seed.to_be_bytes();
image::Rgba([r, g, b, 255])
});
let mut png = vec![];
noise.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(png.len() >= vectorcraft_format::INLINE_MAX, "{} bytes", png.len());
s.execute("file.place", &json!({"name": "noise.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
let r = s.execute("document.export", &json!({"format": "plugin.test.input"})).unwrap();
assert_eq!(r["format"], "plugin.test.input");
let given = exported(&r);
let saved = exported(&s.execute("document.save", &json!({})).unwrap());
assert!(saved.len() > given.len() + png.len(), "the image data follows the JSON in the saved file");
let doc: Value = serde_json::from_slice(&given).unwrap();
let saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap();
assert_eq!(doc, saved_json, "the saved file's JSON");
assert!(!given.contains(&b'\n'), "without indentation");
assert_eq!((&doc["format"], &doc["version"]), (&json!("vectorcraft"), &json!(vectorcraft_format::VERSION)));
let images = doc["images"].as_object().unwrap();
let image = images.values().next().unwrap();
assert_eq!(images.len(), 1);
assert!(image.get("blob").is_some() && image.get("data").is_none(), "{image}");
}
#[test]
fn the_exporter_is_given_its_parameters_and_the_file_it_writes() {
let dir = tmp_dir("params");
let mut s = session();
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4},"mode":{"type":"choice","options":["fast","fine"],"default":"fast"}}"#;
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.params", r#"["tsp","tsq"]"#, params), Echo::Params));
let sent = |r: &Value| -> Value { serde_json::from_slice(&exported(r)).unwrap() };
// The defaults, and without a path its first extension and the document's name.
let r = s.execute("document.export", &json!({"format": "plugin.test.params"})).unwrap();
let got = sent(&r);
assert_eq!((&got["density"], &got["mode"]), (&json!(4.0), &json!("fast")));
assert_eq!((&got["_context"]["mode"], &got["_context"]["extension"]), (&json!("export"), &json!("tsp")));
let title = s.doc().unwrap().doc.title.clone();
assert_eq!(got["_context"]["name"], json!(title));
// Given ones, clamped as a filter's are, and the path's extension when the exporter lists it.
let path = dir.join("Poster.tsq");
let r = s.execute("document.export", &json!({"path": path_of(&path), "params": {"density": 12, "mode": "fine"}})).unwrap();
assert_eq!(r["format"], "plugin.test.params");
let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(written, json!({"density": 10.0, "mode": "fine", "_context": {"mode": "export", "extension": "tsq", "name": "Poster"}}));
// Bad parameters fail before the plug-in runs, and nothing is written.
let bad = dir.join("Bad.tsp");
let e = s.execute("document.export", &json!({"path": path_of(&bad), "params": {"mode": "slow"}})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
assert!(!bad.exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// Only the selected objects, with `selectedOnly`, as for the built-in formats.
#[test]
fn selected_only_hands_over_the_selection() {
let mut s = session();
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.selection", r#"["tse"]"#, "{}"), Echo::Input));
let ellipse = s.execute("shape.ellipse", &json!({"x": 200, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [ellipse]})).unwrap();
let count = |r: &Value| String::from_utf8(exported(r)).unwrap().matches("\"anchors\"").count();
let all = count(&s.execute("document.export", &json!({"format": "plugin.test.selection"})).unwrap());
let selected = count(&s.execute("document.export", &json!({"format": "plugin.test.selection", "selectedOnly": true})).unwrap());
assert!(selected >= 1 && selected < all, "{selected} of {all} paths");
}
/// A built-in format keeps its extension: an exporter that lists `svg` too writes it only when
/// asked for by its format id.
#[test]
fn built_in_formats_keep_their_extensions() {
let dir = tmp_dir("builtin");
let mut s = session();
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.svgtoo", r#"["tsv","svg"]"#, "{}"), Echo::Params));
assert_eq!(fileio::format("svg").map(|f| f.id), Some("svg"));
let svg = dir.join("a.svg");
s.execute("document.export", &json!({"path": path_of(&svg)})).unwrap();
assert!(String::from_utf8(std::fs::read(&svg).unwrap()).unwrap().contains("<svg"), "the built-in SVG");
s.execute("document.export", &json!({"path": path_of(&svg), "format": "plugin.test.svgtoo"})).unwrap();
let sent: Value = serde_json::from_slice(&std::fs::read(&svg).unwrap()).unwrap();
assert_eq!(sent["_context"]["extension"], "svg", "an extension it lists, asked for by its format id");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn declines_and_bad_output_write_nothing_and_warnings_come_back() {
let dir = tmp_dir("output");
let mut s = session();
let cases = [
("test.declines", r#"{"error":"Nothing to plot."}"#, "Nothing to plot."),
("test.badbase64", r#"{"dataBase64":"@@@"}"#, "`dataBase64` is not valid base64"),
("test.nodata", r#"{"warnings":[]}"#, "dataBase64"),
];
for (id, output, why) in cases {
install(&mut s, &tpl::returning(&tpl::exporter(id, r#"["tsd"]"#, "{}"), output));
let path = dir.join(format!("{id}.tsd"));
let e = s.execute("document.export", &json!({"path": path_of(&path), "format": format!("plugin.{id}")})).unwrap_err();
assert!(e.to_string().contains(why), "{id}: {e}");
assert!(!path.exists(), "{id}: nothing written");
}
install(
&mut s,
&tpl::returning(&tpl::exporter("test.warns", r#"["tsw"]"#, "{}"), r#"{"dataBase64":"SGk=","warnings":["3 colours over the limit"]}"#),
);
let path = dir.join("w.tsw");
let r = s.execute("document.export", &json!({"path": path_of(&path)})).unwrap();
assert_eq!((r["bytes"].as_u64(), &r["warnings"]), (Some(2), &json!(["3 colours over the limit"])));
assert_eq!(std::fs::read(&path).unwrap(), b"Hi");
// An exporter is not a filter.
let e = s.execute("plugin.run", &json!({"id": "test.warns"})).unwrap_err();
assert!(e.to_string().contains("document.export"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Background Export's Cancel stops an exporter between two slices of its budget: here one that
/// never returns, cancelled from another thread once it is running.
#[test]
fn a_cancelled_export_stops_the_exporter() {
let mut s = session();
install(&mut s, &tpl::module(&tpl::exporter("test.spins", r#"["tss"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1));
let token = vectorcraft_doc::cancel::CancelToken::new();
let cancel = token.clone();
let wait = Duration::from_millis(300);
let canceller = std::thread::spawn(move || {
std::thread::sleep(wait);
cancel.cancel();
});
let t = Instant::now();
let r = vectorcraft_doc::cancel::scope(&token, None, || s.execute("document.export", &json!({"format": "plugin.test.spins"})));
let took = t.elapsed();
canceller.join().unwrap();
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert!(took >= wait && took < Duration::from_secs(10), "stopped while running, not before: {took:?}");
}
+7
View File
@@ -163,6 +163,13 @@ fn preview_mime(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME }
}
/// The JSON of a native file without the data after it (version 4 keeps images there): a
/// complete JSON value whose `images` give each image's MIME type and where its data was, without
/// the data.
pub fn json_part(file: &[u8]) -> &[u8] {
split_tail(file).0
}
/// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON
/// value.
fn split_tail(text: &[u8]) -> (&[u8], &[u8]) {
+13
View File
@@ -58,6 +58,19 @@ fn small_files_stay_plain_json_and_older_versions_inline() {
assert_eq!(load(&v3).unwrap().images["big"].bytes, big.images["big"].bytes);
}
/// `json_part` (what exporter plug-ins are given): the whole JSON value, without the data after
/// it; a file without such data is its JSON already.
#[test]
fn json_part_is_the_json_without_the_data_after_it() {
let big = save_with(&doc_with_image("big", INLINE_MAX + 10), &SaveOptions::default()).unwrap();
let json = vectorcraft_format::json_part(&big);
assert!(json.len() < big.len() - INLINE_MAX && !has_tail(json));
let v: Value = serde_json::from_slice(json).unwrap();
assert!(v["images"]["big"].get("blob").is_some() && v["images"]["big"].get("data").is_none(), "{}", v["images"]);
let small = save_with(&doc_with_image("small", 100), &SaveOptions::default()).unwrap();
assert_eq!(vectorcraft_format::json_part(&small), &small[..]);
}
#[test]
fn damaged_offsets_are_refused_not_a_crash() {
let d = doc_with_image("big", INLINE_MAX + 10);
+6 -1
View File
@@ -17,7 +17,7 @@ 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.";
pub const PREFIX: &str = crate::PREFIX;
/// Wall-clock budget of one live-effect run (drawing waits for it), in milliseconds.
pub const WALL_TIME_MS: u64 = 5_000;
@@ -104,6 +104,11 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
return out.clone();
}
let r = run(&plugin, resolved, path, bounds, rule);
// A drawing that was cancelled (an export stopped half way) says nothing about the plug-in:
// the next drawing runs it again.
if matches!(r, Err(Error::Cancelled)) {
return None;
}
note(id, &r);
let out = r.ok();
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
+141
View File
@@ -0,0 +1,141 @@
//! Exporter plug-ins: a plug-in of kind `exporter` writes the document in a file format of its
//! own, such as a pen plotter's or a cutting machine's.
//!
//! File › Export › Export As lists each installed exporter after the built-in formats, under its
//! name, and `document.export` writes it by its format id (`plugin.<id>`) or by one of the
//! extensions its manifest lists. A built-in format keeps its extension: an exporter that lists
//! `svg` too is picked by its format id. The host hands the exporter the document as the native
//! file's JSON (`vectorcraft_format::json_part`: images of `INLINE_MAX` bytes or more without
//! their data) and writes the bytes it returns.
//!
//! An export is one run the user asked for, often of a whole document, so it gets a larger budget
//! than a filter: [`FUEL`] instructions, plus [`Limits::fuel_per_byte`] for each byte of input,
//! and [`WALL_TIME_MS`]. It runs where the export runs: with Background Export on the export's
//! worker, whose Cancel stops it between two slices of the budget ([`Error::Cancelled`]).
use std::sync::Arc;
use serde_json::{Value, json};
use crate::objects::{declined, printable};
use crate::{Error, Kind, Limits, Plugin, Result, registry};
/// Instructions an export may use, besides [`Limits::fuel_per_byte`] for each byte of the
/// document: 200 times a filter's base budget.
pub const FUEL: u64 = 10_000_000_000;
/// Wall-clock budget of one export, in milliseconds (native builds; the web build has no clock,
/// and the instruction budget alone bounds it there).
pub const WALL_TIME_MS: u64 = 300_000;
/// Most warnings an export reports.
pub const MAX_WARNINGS: usize = 100;
/// Longest warning, in characters.
pub const MAX_WARNING_CHARS: usize = 500;
/// The format id of exporter `plugin_id`: `plugin.<id>`.
pub fn format_id(plugin_id: &str) -> String {
format!("{}{plugin_id}", crate::PREFIX)
}
/// The plug-in id in format id `format_id`.
pub fn plugin_id(format_id: &str) -> Option<&str> {
format_id.strip_prefix(crate::PREFIX)
}
/// The installed exporters, by plug-in id.
pub fn installed() -> Vec<Arc<Plugin>> {
registry::list().into_iter().filter(|p| p.manifest().kind == Kind::Exporter).collect()
}
/// The installed exporter of format id `format_id`.
pub fn by_format(format_id: &str) -> Option<Arc<Plugin>> {
registry::get(plugin_id(format_id)?).filter(|p| p.manifest().kind == Kind::Exporter)
}
/// What an exporter returned.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Exported {
/// The file, base64 encoded, as the plug-in returned it (the host decodes it: this crate has
/// no base64 of its own).
pub data_base64: String,
/// What the export left out or changed, for the user (at most [`MAX_WARNINGS`]).
pub warnings: Vec<String>,
}
/// The file an export writes, as the exporter is told in `_context`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Target<'a> {
/// Its extension, without the dot: one the exporter lists.
pub extension: &'a str,
/// Its name without the extension (some formats keep a name in their header).
pub name: &'a str,
}
/// The limits of an export by `plugin`: its own, with the budgets of an export.
pub fn limits(plugin: &Plugin) -> Limits {
Limits { fuel_base: FUEL, wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }
}
/// Runs exporter `plugin` on `document`, the native file's JSON, with `params` (checked against
/// its manifest: missing ones take their defaults) for the file `target`.
pub fn run(plugin: &Plugin, document: &[u8], params: &Value, target: Target) -> Result<Exported> {
let m = plugin.manifest();
if m.kind != Kind::Exporter {
return Err(Error::Params(format!("`{}` is a {}, not an exporter", m.id, m.kind.name())));
}
let mut params = Value::Object(m.resolve_params(params)?);
params["_context"] = json!({"mode": "export", "extension": target.extension, "name": target.name});
let params = serde_json::to_vec(&params).map_err(|e| Error::Params(e.to_string()))?;
decode(&plugin.run_limited(document, &params, &limits(plugin))?)
}
/// An exporter's output: `{"dataBase64": "…", "warnings"?: ["…"]}`, or `{"error": "…"}` to decline
/// with a message for the user.
pub fn decode(bytes: &[u8]) -> Result<Exported> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
if let Some(e) = declined(&v) {
return Err(e);
}
let data = v.get("dataBase64").and_then(Value::as_str).ok_or_else(|| Error::Output("expected {\"dataBase64\": \"…\"}".into()))?;
let warnings = match v.get("warnings") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(w)) => w.iter().take(MAX_WARNINGS).map(|w| printable(w, MAX_WARNING_CHARS)).collect(),
Some(_) => return Err(Error::Output("`warnings` must be a list of strings".into())),
};
Ok(Exported { data_base64: data.to_string(), warnings })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_ids_name_the_plug_in() {
assert_eq!(format_id("org.example.plotter"), "plugin.org.example.plotter");
assert_eq!(plugin_id("plugin.org.example.plotter"), Some("org.example.plotter"));
assert_eq!(plugin_id("png"), None);
}
#[test]
fn output_is_the_data_and_the_warnings_or_a_decline() {
let out = decode(br#"{"dataBase64": "SGk=", "warnings": ["a", 2]}"#).unwrap();
assert_eq!(out, Exported { data_base64: "SGk=".into(), warnings: vec!["a".into(), "2".into()] });
assert_eq!(decode(br#"{"dataBase64": ""}"#).unwrap().warnings, Vec::<String>::new());
assert_eq!(decode(br#"{"error": "Nothing to\nwrite."}"#), Err(Error::Failed("Nothing towrite.".into())));
for bad in [&b"[]"[..], b"{}", b"not json", br#"{"dataBase64": 1}"#, br#"{"dataBase64": "", "warnings": "x"}"#] {
assert!(matches!(decode(bad), Err(Error::Output(_))), "{}", String::from_utf8_lossy(bad));
}
}
#[test]
fn warnings_are_capped_in_number_and_length() {
let many: Vec<String> = (0..MAX_WARNINGS + 5).map(|i| format!("w{i}")).collect();
let long = "x".repeat(MAX_WARNING_CHARS + 1);
let out = decode(json!({"dataBase64": "", "warnings": many}).to_string().as_bytes()).unwrap();
assert_eq!(out.warnings.len(), MAX_WARNINGS);
let out = decode(json!({"dataBase64": "", "warnings": [long]}).to_string().as_bytes()).unwrap();
assert_eq!(out.warnings.first().map(|w| w.chars().count()), Some(MAX_WARNING_CHARS));
}
}
+11 -1
View File
@@ -17,11 +17,13 @@
//! 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
//! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole
//! document in a file format of its own. The process-wide [`registry`] holds the installed
//! plug-ins.
#![forbid(unsafe_code)]
pub mod effect;
pub mod export;
pub mod manifest;
pub mod objects;
pub mod registry;
@@ -31,6 +33,10 @@ pub mod wat;
pub use manifest::{Kind, Manifest, ParamSpec};
pub use runtime::{ABI_VERSION, Limits, Plugin};
/// What the host puts before a plug-in's id to name what it adds: its live effect
/// (`plugin.<id>`, [`effect`]) or its file format ([`export`]).
pub const PREFIX: &str = "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 {
@@ -52,6 +58,10 @@ pub enum Error {
Failed(String),
#[error("no plug-in {0:?} is installed")]
NotFound(String),
/// The caller asked the run to stop ([`vectorcraft_doc::cancel`]), between two slices of
/// its instruction budget.
#[error("cancelled")]
Cancelled,
#[error("{0}")]
Io(String),
}
+58
View File
@@ -9,6 +9,8 @@ use crate::{Error, Result};
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// Most parameters a plug-in may declare.
pub const MAX_PARAMS: usize = 64;
/// Most file extensions an exporter may declare.
pub const MAX_EXTENSIONS: usize = 8;
/// What a plug-in is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -20,6 +22,9 @@ pub enum Kind {
/// A live effect: rewrites one object's geometry each time it is drawn, from its appearance
/// stack (Effect › Plug-ins, `effect.apply`).
Effect,
/// An exporter: writes the document in a file format of its own, whose extensions the manifest
/// lists (File › Export › Export As, `document.export`; see [`crate::export`]).
Exporter,
}
impl Kind {
@@ -27,6 +32,7 @@ impl Kind {
match self {
Kind::Filter => "filter",
Kind::Effect => "effect",
Kind::Exporter => "exporter",
}
}
}
@@ -96,6 +102,10 @@ pub struct Manifest {
/// 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)>,
/// An exporter's file extensions, lower case and without the dot; the first is the one its
/// files get. Only exporters declare them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
}
impl Manifest {
@@ -120,6 +130,7 @@ impl Manifest {
description: String::new(),
author: String::new(),
params: Vec::new(),
extensions: Vec::new(),
}
}
@@ -134,6 +145,7 @@ impl Manifest {
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()));
}
self.validate_extensions()?;
for (k, spec) in &self.params {
if !valid_key(k) {
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
@@ -160,6 +172,29 @@ impl Manifest {
Ok(())
}
/// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case
/// letters and digits; other kinds list none.
fn validate_extensions(&self) -> Result<()> {
let exts = &self.extensions;
match self.kind {
Kind::Exporter => {}
_ if exts.is_empty() => return Ok(()),
_ => return Err(Error::Manifest("only an exporter declares `extensions`".into())),
}
if exts.is_empty() || exts.len() > MAX_EXTENSIONS {
return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`")));
}
for (i, e) in exts.iter().enumerate() {
if !(1..=16).contains(&e.len()) || !e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) {
return Err(Error::Manifest(format!("extension {e:?}: use 1-16 lower-case letters and digits, without the dot")));
}
if exts.get(..i).is_some_and(|before| before.contains(e)) {
return Err(Error::Manifest(format!("extension {e:?} is listed twice")));
}
}
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 {
@@ -355,6 +390,29 @@ mod tests {
}
}
#[test]
fn an_exporter_lists_its_extensions_and_other_kinds_list_none() {
let m = Manifest::parse(br#"{"id":"org.example.plotter","name":"Plotter","kind":"exporter","extensions":["plt","plot"]}"#).unwrap();
assert_eq!((m.kind, m.extensions.as_slice()), (Kind::Exporter, &["plt".to_string(), "plot".to_string()][..]));
assert_eq!(serde_json::to_value(&m).unwrap()["extensions"], json!(["plt", "plot"]));
let filter = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap();
assert!(serde_json::to_value(&filter).unwrap().get("extensions").is_none());
for bad in [
r#"{"id":"a","name":"x","kind":"exporter"}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":[]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["PLT"]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":[".plt"]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":[""]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["abcdefghijklmnopq"]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt","plt"]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["a","b","c","d","e","f","g","h","i"]}"#,
r#"{"id":"a","name":"x","kind":"filter","extensions":["plt"]}"#,
r#"{"id":"a","name":"x","kind":"effect","extensions":["plt"]}"#,
] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
}
#[test]
fn rejects_bad_manifests() {
for bad in [
+16 -3
View File
@@ -286,9 +286,8 @@ fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject>
/// 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));
if let Some(e) = declined(&v) {
return Err(e);
}
let objects = v.get("objects").and_then(Value::as_array).ok_or_else(|| Error::Output("expected {\"objects\": [...]}".into()))?;
if objects.len() > MAX_OBJECTS {
@@ -298,6 +297,20 @@ pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect()
}
/// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it,
/// without control characters and at most 300 of them.
pub(crate) fn declined(v: &Value) -> Option<Error> {
let e = v.get("error").filter(|e| !e.is_null())?;
Some(Error::Failed(printable(e, 300)))
}
/// A plug-in's text the user reads: `v` as a string (any other JSON as its text), without control
/// characters, at most `max` characters of it.
pub(crate) fn printable(v: &Value, max: usize) -> String {
let s = v.as_str().map_or_else(|| v.to_string(), str::to_string);
s.chars().filter(|c| !c.is_control()).take(max).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()
+3 -1
View File
@@ -206,7 +206,8 @@ impl Plugin {
Ok(Live { store, instance, memory })
}
/// Calls `f` with at most `fuel` instructions, in slices, checking the deadline between them.
/// Calls `f` with at most `fuel` instructions, in slices, checking the deadline and the
/// caller's cancellation ([`vectorcraft_doc::cancel`]) 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);
@@ -223,6 +224,7 @@ impl Plugin {
return Err(Error::Limit("instruction budget".into()));
}
deadline.check()?;
vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?;
let give = left.min(FUEL_SLICE.max(need));
left -= give;
live.store.set_fuel(give).map_err(wasm_err)?;
+79
View File
@@ -45,3 +45,82 @@ pub fn returning(manifest: &str, output: &str) -> String {
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)
}
+61
View File
@@ -164,6 +164,67 @@ fn wall_clock_budget_stops_runaway_plugins() {
assert!(t < Duration::from_secs(3), "{t:?}");
}
#[test]
fn cancelling_stops_a_run_between_two_slices_of_its_budget() {
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
let p = Plugin::load(&spin, Limits { fuel_base: u64::MAX / 4, ..Limits::default() }).unwrap();
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let t = Instant::now();
let r = vectorcraft_doc::cancel::scope(&token, None, || p.run(INPUT.as_bytes(), b"{}"));
assert_eq!(r, Err(Error::Cancelled));
assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed());
// Outside a cancelled scope the same module still runs to its budget.
let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, ..Limits::default() });
assert_eq!(e, Error::Limit("instruction budget".into()));
}
#[test]
fn an_exporter_returns_its_file_with_an_export_s_budget() {
use vectorcraft_plugins::export::{self, Target};
let target = Target { extension: "tst", name: "Poster" };
let manifest = tpl::exporter("test.exporter", r#"["tst"]"#, r#"{"level":{"type":"int","min":1,"max":3,"default":2}}"#);
let p = Plugin::load(&build(&tpl::returning(&manifest, r#"{"dataBase64":"SGk=","warnings":["note"]}"#)), Limits::default()).unwrap();
assert_eq!((p.manifest().kind, p.manifest().extensions.clone()), (Kind::Exporter, vec!["tst".to_string()]));
let out = export::run(&p, br#"{"format":"vectorcraft"}"#, &json!({"level": 9}), target).unwrap();
assert_eq!((out.data_base64.as_str(), out.warnings), ("SGk=", vec!["note".to_string()]));
let limits = export::limits(&p);
assert_eq!((limits.fuel_base, limits.wall_time_ms), (export::FUEL, export::WALL_TIME_MS));
// Its parameters are checked, and a filter is not an exporter.
assert!(matches!(export::run(&p, b"{}", &json!({"level": "high"}), target), Err(Error::Params(_))));
let filter = Plugin::load(&ok_module("(i64.const 0)"), Limits::default()).unwrap();
assert!(matches!(export::run(&filter, b"{}", &Value::Null, target), Err(Error::Params(_))));
}
/// The echo exporter the engine and UI tests read back what they hand a plug-in with: its file is
/// its input, or its parameters, byte for byte, whatever their length modulo three.
#[test]
fn the_echo_exporter_writes_what_it_is_given() {
use vectorcraft_plugins::export::{self, Target};
let manifest = tpl::exporter("test.echo", r#"["txt"]"#, "{}");
let input = Plugin::load(&build(&tpl::echo_exporter(&manifest, tpl::Echo::Input)), Limits::default()).unwrap();
let params = Plugin::load(&build(&tpl::echo_exporter(&manifest, tpl::Echo::Params)), Limits::default()).unwrap();
let target = Target { extension: "txt", name: "a" };
let decoded = |out: export::Exported| String::from_utf8(base64_decode(&out.data_base64)).unwrap();
for doc in ["{}", r#"{"a":1}"#, r#"{"ab":12}"#, r#"{"format":"vectorcraft","é":"\u0000"}"#] {
assert_eq!(decoded(export::run(&input, doc.as_bytes(), &Value::Null, target).unwrap()), doc);
}
let sent: Value = serde_json::from_str(&decoded(export::run(&params, b"{}", &Value::Null, target).unwrap())).unwrap();
assert_eq!(sent, json!({"_context": {"mode": "export", "extension": "txt", "name": "a"}}));
}
/// Standard base64 (RFC 4648, padded), decoded here so the test needs no other crate.
fn base64_decode(s: &str) -> Vec<u8> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let sextets: Vec<u32> = s.bytes().filter(|b| *b != b'=').map(|b| ALPHABET.iter().position(|a| *a == b).unwrap() as u32).collect();
let mut out = Vec::new();
for group in sextets.chunks(4) {
let bits = group.iter().enumerate().fold(0u32, |acc, (i, v)| acc | v << (18 - 6 * i));
out.extend(bits.to_be_bytes()[1..].iter().take(group.len() - 1));
}
out
}
#[test]
fn memory_cap_limits_input_and_growth() {
let echo = build(&tpl::module(MANIFEST, tpl::ECHO, "", 1));
+36 -13
View File
@@ -2,14 +2,16 @@
//! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG
//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF for artboards as pages),
//! else the file is written. DWG is listed, greyed out, with what to use instead.
//! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of
//! installed exporter plug-ins come after the built-in ones: they write the whole document, and one
//! with parameters asks for them before writing (`plugin::open_export`).
use std::sync::LazyLock;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
use super::{DialogSpec, dxf_options, eps_options, form, png_options, save_pdf, svg_options, text_export};
use super::{DialogSpec, dxf_options, eps_options, form, plugin, png_options, save_pdf, svg_options, text_export};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
@@ -17,12 +19,21 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Export As").into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
/// The formats Export As writes (the native format and `.ai` are Save As's).
static FORMATS: LazyLock<Vec<&'static Format>> =
/// The built-in formats Export As writes (the native format and `.ai` are Save As's).
static BUILT_IN: LazyLock<Vec<&'static Format>> =
LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && !matches!(f.id, "vectorcraft" | "ai")).collect());
/// Formats listed after them that can't be written (`fileio::unsupported` ids), greyed out.
const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")];
static LABELS: LazyLock<Vec<&'static str>> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect());
/// The formats Export As writes: the built-in ones, then those of the installed exporter plug-ins.
pub(super) fn formats() -> Vec<&'static Format> {
BUILT_IN.iter().copied().chain(fileio::plugin_formats()).collect()
}
/// Is `f` an exporter plug-in's format?
fn from_plugin(f: &Format) -> bool {
vectorcraft_plugins::export::by_format(f.id).is_some()
}
/// What to use instead when the field names a format that can't be written (DWG).
fn unavailable(d: &Dialog) -> Option<(&'static str, &'static str)> {
@@ -38,7 +49,7 @@ pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) {
let missing = format.and_then(|f| UNAVAILABLE.iter().find(|u| u.0.eq_ignore_ascii_case(f.trim_start_matches('.'))));
let format = match missing {
Some((id, _)) => id,
None => format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id),
None => format.and_then(fileio::format).filter(|f| formats().iter().any(|g| g.id == f.id)).map_or("png", |f| f.id),
};
let range = p.get("range").and_then(Value::as_str);
app.ui.dialog = Some(Dialog::new(
@@ -55,7 +66,8 @@ pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) {
/// The chosen format (PNG when the field names none Export As writes).
fn chosen(d: &Dialog) -> Option<&'static Format> {
let id = d.str("format");
let find = |id: &str| FORMATS.iter().copied().find(|f| f.id == id);
let all = formats();
let find = |id: &str| all.iter().copied().find(|f| f.id == id);
find(&id).or_else(|| find("png"))
}
@@ -66,7 +78,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
ui.label(egui::RichText::new(tl!("Format:")).color(t.text_dim));
let shown = missing.map_or(f.label, |(label, _)| label);
if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &LABELS, 160.0, |i| i < FORMATS.len()).and_then(|i| FORMATS.get(i)) {
let all = formats();
let labels: Vec<&str> = all.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect();
if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &labels, 160.0, |i| i < all.len()).and_then(|i| all.get(i)) {
d.fields.insert("format".into(), json!(g.id));
}
ui.end_row();
@@ -76,8 +90,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::caption(ui, hint);
}
ui.add_space(10.0);
form::check(ui, d, "useArtboards", tl!("Use Artboards"));
let use_artboards = d.bool("useArtboards");
// An exporter plug-in writes the whole document: Use Artboards is not its to apply.
let whole = from_plugin(f);
ui.add_enabled_ui(!whole, |ui| form::check(ui, d, "useArtboards", tl!("Use Artboards")));
let use_artboards = d.bool("useArtboards") && !whole;
ui.add_enabled_ui(use_artboards, |ui| {
ui.horizontal(|ui| {
ui.add_space(22.0);
@@ -94,6 +110,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(8.0);
let ext = f.extensions[0];
let hint = match (use_artboards, f.id) {
_ if whole => tl!("The plug-in writes the whole document.").to_string(),
(true, "pdf") => tl!("One page per artboard.").to_string(),
(true, _) => crate::i18n::fmt(tl!("One file per artboard: <name>-<artboard>.{ext}"), &[("ext", ext)]),
(false, _) => tl!("The bounds of the visible art.").to_string(),
@@ -108,7 +125,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
return Err(hint.into());
}
let f = chosen(d).ok_or("no format to export to")?;
let use_artboards = d.bool("useArtboards");
let plugin = vectorcraft_plugins::export::by_format(f.id);
let use_artboards = d.bool("useArtboards") && plugin.is_none();
let all = d.bool("all");
let range = d.str("range");
if use_artboards && !all {
@@ -116,8 +134,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?;
}
// Nothing to export keeps the dialog open too (before the save dialog, not after it).
if !use_artboards && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) {
// Nothing to export keeps the dialog open too (before the save dialog, not after it). An
// exporter plug-in decides for itself what it can write.
if !use_artboards && plugin.is_none() && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) {
return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into());
}
app.ui.dialog = None;
@@ -142,6 +161,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if use_artboards {
params["range"] = json!(range);
}
if let Some(plugin) = plugin.filter(|p| !p.manifest().params.is_empty()) {
plugin::open_export(app, &plugin, params);
return Ok(Value::Null);
}
if f.raster {
png_options::open(app, f, params);
return Ok(Value::Null);
+3
View File
@@ -324,6 +324,7 @@ registry! {
PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC,
PrintPresets: [print_presets::KIND] => print_presets::SPEC,
Plugin: [plugin::KIND] => plugin::SPEC,
PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC,
ScriptCheck: [script_check::KIND] => script_check::SPEC,
VectorHalftone: [halftone::KIND] => halftone::SPEC,
SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC,
@@ -577,6 +578,8 @@ mod tests_import_pdf;
#[cfg(test)]
mod tests_package;
#[cfg(test)]
mod tests_plugin_export;
#[cfg(test)]
mod tests_raster_formats;
#[cfg(test)]
mod tests_svg_options_range;
+70 -15
View File
@@ -5,10 +5,13 @@
//! 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.
//! at once. Exporters are formats of File › Export › Export As; one with parameters opens dialog
//! `pluginExport` once the file is picked, and its Export button writes the file.
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_plugins::{Kind, registry};
use vectorcraft_plugins::{Kind, Manifest, Plugin, registry};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
@@ -70,34 +73,49 @@ 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));
match m.kind {
Kind::Filter => {}
Kind::Effect => return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)),
Kind::Exporter => return Err(format!("{} is an exporter (File › Export › Export As…)", 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('…')));
let mut fields = fields_for(m);
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(Value::Null)
}
/// The fields of plug-in `m`'s dialog: its parameters at their defaults, `__plugin` (its id) and
/// `__label` (its name, the heading).
fn fields_for(m: &Manifest) -> Map<String, Value> {
let mut fields = m.defaults();
fields.insert("__plugin".into(), json!(m.id));
fields.insert("__label".into(), json!(m.name.trim_end_matches('…')));
fields
}
/// The plug-in dialog `d` is for, its description drawn above the fields. `None` once it was
/// uninstalled, which closes the dialog.
fn described(ui: &mut egui::Ui, d: &Dialog) -> Option<Arc<Plugin>> {
let plugin = registry::get(&d.str("__plugin"))?;
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);
}
Some(plugin)
}
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 Some(plugin) = described(ui, d) else { return true };
form::schema_fields(ui, d, &plugin.manifest().params);
let (label, p) = (d.str("__label"), params(d));
form::preview(app, ui, d, &label, CMD, p);
false
@@ -106,3 +124,40 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
form::commit_preview(app, CMD, params(d))
}
/// The dialog kind of an exporter's parameters, after Export As picked the file.
pub const EXPORT_KIND: &str = "pluginExport";
/// Headed by the plug-in's name, as it is; Export writes the file.
pub(super) const EXPORT_SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
body: export_body,
confirm: export_confirm,
ok: Some("Export"),
max_width: Some(420.0),
..DialogSpec::FORM
};
/// Export As with exporter `plugin`, which has parameters: its fields at their defaults, and
/// `export`, the export's own parameters (`format`, `path`, …), kept for Export.
pub fn open_export(app: &mut VectorcraftApp, plugin: &Plugin, export: Value) {
let mut fields = fields_for(plugin.manifest());
fields.insert("__export".into(), export);
app.ui.dialog = Some(Dialog { kind: EXPORT_KIND.into(), fields });
}
fn export_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let Some(plugin) = described(ui, d) else { return true };
form::schema_fields(ui, d, &plugin.manifest().params);
false
}
/// Writes the file with the export's parameters and the plug-in's, as `params`.
fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = d.fields.get("__export").cloned().filter(Value::is_object).ok_or("no format to export to")?;
p["params"] = form::params(d);
let format = p.get("format").and_then(Value::as_str).map(str::to_string);
let path = p.get("path").and_then(Value::as_str).map(str::to_string);
app.ui.dialog = None;
crate::io::export(app, format.as_deref(), path, &p)
}
@@ -0,0 +1,116 @@
//! Exporter plug-ins in File › Export › Export As: listed after the built-in formats, written
//! whole (Use Artboards is not theirs to apply), and their parameters asked for once the file is
//! picked. Each test installs exporters of its own: plug-ins are process-wide.
use serde_json::{Value, json};
use vectorcraft_plugins::wat::{self as tpl, Echo};
use super::tests_export::{app, frame, kind, names, set};
use super::*;
fn install(app: &mut VectorcraftApp, src: &str) {
let wasm = wat::parse_str(src).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
}
#[test]
fn export_as_lists_exporters_and_writes_their_file_whole() {
let (mut app, written) = app(2);
install(&mut app, &tpl::returning(&tpl::exporter("test.ui.whole", r#"["tuw"]"#, "{}"), r#"{"dataBase64":"SGk=","warnings":["a note"]}"#));
// After the built-in formats, with the other exporters installed.
let listed: Vec<&str> = export_as::formats().iter().map(|f| f.id).collect();
let first = listed.iter().position(|id| id.starts_with("plugin.")).expect("an exporter listed");
let added = listed.get(first..).unwrap_or_default();
assert!(added.iter().all(|id| id.starts_with("plugin.")) && added.contains(&"plugin.test.ui.whole"), "{listed:?}");
// It isn't a filter: Object › Plug-ins' command says where it is.
let e = app.run("plugin.dialog", json!({"id": "test.ui.whole"})).unwrap_err();
assert!(e.contains("is an exporter (File › Export › Export As…)"), "{e}");
app.run("file.exportAs", json!({"format": "plugin.test.ui.whole"})).unwrap();
assert_eq!(kind(&app), Some("exportAs"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "plugin.test.ui.whole");
// Use Artboards is greyed out and doesn't apply: one file of the whole document.
set(&mut app, "useArtboards", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "an exporter without parameters writes at once");
assert_eq!(names(&written), ["/out/Untitled-1.tuw"]);
assert_eq!(written.borrow()[0].1, b"Hi");
assert!(app.ui.status.contains("a note"), "{}", app.ui.status);
}
#[test]
fn an_exporter_with_parameters_asks_for_them_once_the_file_is_picked() {
let (mut app, written) = app(1);
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
install(&mut app, &tpl::echo_exporter(&tpl::exporter("test.ui.params", r#"["tup"]"#, params), Echo::Params));
// Asked for by its extension, as agents may.
app.run("file.exportAs", json!({"format": "tup"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "plugin.test.ui.params");
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some(plugin::EXPORT_KIND));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.params".to_string(), Some(&json!(4.0))));
assert!(written.borrow().is_empty(), "nothing written before Export");
frame(&mut app);
set(&mut app, "density", json!(7));
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(names(&written), ["/out/Untitled-1.tup"]);
let sent: Value = serde_json::from_slice(&written.borrow()[0].1).unwrap();
assert_eq!(sent, json!({"density": 7.0, "_context": {"mode": "export", "extension": "tup", "name": "Untitled-1"}}));
}
/// `CRAFT_UI_PLUGIN_EXPORT_SHOT=<folder>` saves the pictures of the dialogs to that folder: Export As
/// with an exporter's format chosen, then the exporter's own dialog. The plug-in is a test one with
/// parameters a plotter's exporter could have, saved beside them (`plotter-test-exporter.wasm`) to
/// install in the app.
#[test]
fn export_as_and_exporter_dialog_pictures() {
let Some(dir) = std::env::var_os("CRAFT_UI_PLUGIN_EXPORT_SHOT") else { return };
let dir = std::path::PathBuf::from(dir);
let (mut app, _) = app(1);
let params = r#"{"speed":{"type":"number","min":1,"max":100,"default":40},"passes":{"type":"int","min":1,"max":10,"default":1},
"optimizeTravel":{"type":"bool","default":true},"paper":{"type":"choice","options":["A4","A3","Letter"],"default":"A4"}}"#;
let manifest = r#"{"id":"org.example.plotter","name":"Plotter (test exporter)","version":"1","kind":"exporter","extensions":["plt"],
"description":"Writes the document as pen plotter commands.","params":PARAMS}"#
.replace("PARAMS", params);
let src = tpl::echo_exporter(&manifest, Echo::Params);
std::fs::write(dir.join("plotter-test-exporter.wasm"), wat::parse_str(&src).unwrap()).unwrap();
install(&mut app, &src);
app.run("file.exportAs", json!({"format": "plugin.org.example.plotter"})).unwrap();
let shot = |app: VectorcraftApp, name: &str| -> VectorcraftApp {
let mut ready = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(720.0, 520.0)).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
ready = true;
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(6);
h.render().unwrap().save(dir.join(name)).unwrap();
h.into_state()
};
app = shot(app, "export-as-exporter.png");
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some(plugin::EXPORT_KIND));
shot(app, "exporter-options.png");
}
/// A plug-in that declines keeps nothing written and says why.
#[test]
fn a_declining_exporter_writes_nothing() {
let (mut app, written) = app(1);
install(&mut app, &tpl::returning(&tpl::exporter("test.ui.declines", r#"["tud"]"#, "{}"), r#"{"error":"Nothing to plot."}"#));
app.run("file.exportAs", json!({"format": "plugin.test.ui.declines"})).unwrap();
let r = confirm(&mut app);
assert!(written.borrow().is_empty());
let said = r.err().unwrap_or_default() + &app.ui.status;
assert!(said.contains("Nothing to plot."), "{said}");
}
+1
View File
@@ -3153,6 +3153,7 @@
Text Wrap Options Опции за обтичане на текст
Text Wrap Options… Опции за обтичане на текст…
The bounds of the visible art. Границите на видимия макет.
The plug-in writes the whole document. Приставката експортира целия документ.
The cell shows the type object's text as HTML. Клетката показва текста на текстовия обект като HTML.
The document has no colour swatches. Документът няма цветови образци.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Краят на хартията, до който принтерът не може да стигне: макетът навлиза в него, а прегледът го показва с пунктир.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2168,6 +2168,7 @@ lighting Light Světlo
One file per artboard: <name>-<artboard>.{ext} Jeden soubor na pracovní plochu: <name>-<artboard>.{ext}
One page per artboard. Jedna stránka na pracovní plochu.
The bounds of the visible art. Hranice viditelné kresby.
The plug-in writes the whole document. Zásuvný modul exportuje celý dokument.
Use Artboards Použít pracovní plochy
Choose a folder Vyberte složku
Create Sub-folders Vytvořit podsložky
@@ -4543,6 +4544,7 @@ imageTrace Outline Obrys
@msg {_1} files are written by file.saveAsTemplate soubory {_1} zapisuje file.saveAsTemplate
@msg {_1} font families available Dostupné rodiny písem: {_1}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} je živý efekt (Efekt › Zásuvné moduly)
@msg {_1} is an exporter (File › Export › Export As…) {_1} je exportní zásuvný modul (Soubor › Export › Exportovat jako…)
@msg {_1} is already used by {_2} {_1} už používá {_2}
@msg {_1} linked file(s) can't be read here: their previews show propojené soubory ({_1}) zde nelze přečíst: zobrazují se jejich náhledy
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions propojené obrázky ({_1}) se na disku změnily: nové verze zobrazí Aktualizovat propojení
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1980,6 +1980,7 @@ lighting Light Lys
One file per artboard: <name>-<artboard>.{ext} Én fil pr. tegnebræt: <name>-<artboard>.{ext}
One page per artboard. Én side pr. tegnebræt.
The bounds of the visible art. Den synlige grafiks grænser.
The plug-in writes the whole document. Plug-ins eksporterer hele dokumentet.
Use Artboards Brug tegnebrætter
Choose a folder Vælg en mappe
Create Sub-folders Opret undermapper
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3178,6 +3178,7 @@ effect Points Punkte
Text Wrap Options Textumflussoptionen
Text Wrap Options… Textumflussoptionen…
The bounds of the visible art. Die Begrenzung der sichtbaren Grafik.
The plug-in writes the whole document. Das Plug-in exportiert das ganze Dokument.
The cell shows the type object's text as HTML. Die Zelle zeigt den Text des Textobjekts als HTML.
The document has no colour swatches. Das Dokument enthält keine Farbfelder.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Der Papierrand, den der Drucker nicht erreicht: Die Grafik liegt innerhalb davon, und die Vorschau zeigt ihn gestrichelt.
@@ -4180,6 +4181,7 @@ theme Appearance Erscheinungsbild
@msg {_1} files are written by file.saveAsTemplate {_1}-Dateien werden von file.saveAsTemplate geschrieben
@msg {_1} font families available {_1} Schriftfamilien verfügbar
@msg {_1} is a live effect (Effect › Plug-ins) {_1} ist ein Live-Effekt (Effekt › Plug-ins)
@msg {_1} is an exporter (File › Export › Export As…) {_1} ist ein Export-Plug-in (Datei › Exportieren › Exportieren als…)
@msg {_1} is already used by {_2} {_1} wird bereits von {_2} verwendet
@msg {_1} linked file(s) can't be read here: their previews show {_1} verknüpfte Datei(en) können hier nicht gelesen werden: Ihre Vorschauen werden angezeigt
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} verknüpfte(s) Bild(er) wurde(n) auf dem Datenträger geändert: „Verknüpfungen aktualisieren“ zeigt die neuen Versionen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3541,6 +3541,7 @@ theme Appearance Εμφάνιση
Text Wrap Options Επιλογές αναδίπλωσης κειμένου
Text Wrap Options… Επιλογές αναδίπλωσης κειμένου…
The bounds of the visible art. Τα όρια του ορατού σχεδίου.
The plug-in writes the whole document. Το πρόσθετο εξάγει ολόκληρο το έγγραφο.
The cell shows the type object's text as HTML. Το κελί εμφανίζει το κείμενο του αντικειμένου ως HTML.
The document has no colour swatches. Το έγγραφο δεν περιέχει δείγματα χρωμάτων.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Το περιθώριο του χαρτιού όπου δεν μπορεί να εκτυπώσει ο εκτυπωτής: το σχέδιο βρίσκεται εντός του και η προεπισκόπηση το δείχνει διακεκομμένο.
@@ -4216,6 +4217,7 @@ theme Appearance Εμφάνιση
@msg {_1} files are written by file.saveAsTemplate τα αρχεία {_1} αποθηκεύονται με το file.saveAsTemplate
@msg {_1} font families available {_1} διαθέσιμες οικογένειες γραμματοσειρών
@msg {_1} is a live effect (Effect › Plug-ins) το {_1} είναι δυναμικό εφέ (Εφέ › Πρόσθετα)
@msg {_1} is an exporter (File › Export › Export As…) το {_1} είναι πρόσθετο εξαγωγής (Αρχείο › Εξαγωγή › Εξαγωγή ως…)
@msg {_1} is already used by {_2} το {_1} χρησιμοποιείται ήδη από το {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} συνδεδεμένα αρχεία δεν μπορούν να διαβαστούν εδώ: εμφανίζονται οι προεπισκοπήσεις τους
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} συνδεδεμένες εικόνες άλλαξαν στον δίσκο: η Ενημέρωση συνδέσμων εμφανίζει τις νέες εκδόσεις
Can't render this file because it contains an unexpected character in line 2016 and column 19.
+2
View File
@@ -3212,6 +3212,7 @@
Text Wrap Options Opciones de ceñir texto
Text Wrap Options… Opciones de ceñir texto…
The bounds of the visible art. Los límites de la ilustración visible.
The plug-in writes the whole document. El plugin exporta el documento completo.
The cell shows the type object's text as HTML. La celda muestra el texto del objeto de texto como HTML.
The document has no colour swatches. El documento no tiene muestras de color.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. El borde del papel al que no llega la impresora: la ilustración queda dentro y la vista previa lo muestra discontinuo.
@@ -4242,6 +4243,7 @@ axis Both Ambos
@msg {_1} files are written by file.saveAsTemplate los archivos {_1} se escriben con file.saveAsTemplate
@msg {_1} font families available {_1} familias de fuentes disponibles
@msg {_1} is a live effect (Effect › Plug-ins) {_1} es un efecto interactivo (Efecto › Plugins)
@msg {_1} is an exporter (File › Export › Export As…) {_1} es un plugin de exportación (Archivo › Exportar › Exportar como…)
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2125,6 +2125,7 @@ lighting Light Valo
One file per artboard: <name>-<artboard>.{ext} Yksi tiedosto piirtoalustaa kohden: <name>-<artboard>.{ext}
One page per artboard. Yksi sivu piirtoalustaa kohden.
The bounds of the visible art. Näkyvän kuvituksen rajat.
The plug-in writes the whole document. Laajennus vie koko asiakirjan.
Use Artboards Käytä piirtoalustoja
Choose a folder Valitse kansio
Create Sub-folders Luo alikansiot
@@ -4463,6 +4464,7 @@ imageTrace Outline Ääriviiva
@msg {_1} files are written by file.saveAsTemplate {_1}-tiedostot kirjoitetaan komennolla file.saveAsTemplate
@msg {_1} font families available {_1} fonttiperhettä käytettävissä
@msg {_1} is a live effect (Effect › Plug-ins) {_1} on live-tehoste (Tehoste › Laajennukset)
@msg {_1} is an exporter (File › Export › Export As…) {_1} on vientilaajennus (Tiedosto › Vie › Vie muodossa…)
@msg {_1} is already used by {_2} {_1} on jo käytössä kohteella {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} linkitettyä tiedostoa ei voi lukea täällä: niiden esikatselut näytetään
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} linkitettyä kuvaa muuttui levyllä: Päivitä linkit näyttää uudet versiot
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3209,6 +3209,7 @@
Text Wrap Options Options d’habillage de texte
Text Wrap Options… Options d’habillage de texte…
The bounds of the visible art. Les limites de l’illustration visible.
The plug-in writes the whole document. Le module externe exporte le document entier.
The cell shows the type object's text as HTML. La cellule affiche le texte de l’objet texte au format HTML.
The document has no colour swatches. Le document ne contient aucune nuance de couleur.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Le bord du papier que l’imprimante ne peut pas atteindre : l’illustration reste à l’intérieur, et l’aperçu l’affiche en pointillés.
@@ -4239,6 +4240,7 @@ axis Both Les deux
@msg {_1} files are written by file.saveAsTemplate les fichiers {_1} sont écrits par file.saveAsTemplate
@msg {_1} font families available {_1} familles de polices disponibles
@msg {_1} is a live effect (Effect › Plug-ins) {_1} est un effet dynamique (Effet › Plug-ins)
@msg {_1} is an exporter (File › Export › Export As…) {_1} est un module externe d’exportation (Fichier › Exporter › Exporter sous…)
@msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3869,6 +3869,7 @@ mark-style Japanese Japán
Texturizer… Textúrázó…
Thai Thai
The bounds of the visible art. A látható grafika határai.
The plug-in writes the whole document. A bővítmény a teljes dokumentumot exportálja.
The cell shows the type object's text as HTML. A cella a szövegobjektum szövegét HTML-ként mutatja.
The dialog was closed before its file dialog answered A párbeszédablak bezárult, mielőtt a fájlválasztó válaszolt volna
The document has no colour swatches. A dokumentumban nincs színminta.
@@ -4978,6 +4979,7 @@ imageTrace Outline Körvonal
@msg {_1} font families available Elérhető betűcsaládok: {_1}
@msg {_1} has no {_2} style ennek nincs ilyen stílusa: {_1} ({_2})
@msg {_1} is a live effect (Effect › Plug-ins) élő effektus: {_1} (Effektus › Bővítmények)
@msg {_1} is an exporter (File › Export › Export As…) exportáló bővítmény: {_1} (Fájl › Exportálás › Exportálás másként…)
@msg {_1} is already used by {_2} már foglalt: {_1} (használja: {_2})
@msg {_1} linked file(s) can't be read here: their previews show Itt nem olvasható csatolt fájlok: {_1}; az előnézetük látszik
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions A lemezen módosult csatolt képek: {_1}; a Csatolások frissítése az új változatokat mutatja
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1980,6 +1980,7 @@ lighting Light Cahaya
One file per artboard: <name>-<artboard>.{ext} Satu file per artboard: <name>-<artboard>.{ext}
One page per artboard. Satu halaman per artboard.
The bounds of the visible art. Batas karya seni yang terlihat.
The plug-in writes the whole document. Plug-in mengekspor seluruh dokumen.
Use Artboards Gunakan artboard
Choose a folder Pilih folder
Create Sub-folders Buat subfolder
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1980
1981
1982
1983
1984
1985
1986
+2
View File
@@ -3245,6 +3245,7 @@ mark-style Japanese Giapponese
Text Wrap Options Opzioni testo attorno
Text Wrap Options… Opzioni testo attorno…
The bounds of the visible art. I limiti della grafica visibile.
The plug-in writes the whole document. Il plug-in esporta l'intero documento.
The cell shows the type object's text as HTML. La cella mostra il testo dell'oggetto testo come HTML.
The document has no colour swatches. Il documento non ha campioni di colore.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Il bordo del foglio che la stampante non riesce a raggiungere: la grafica resta al suo interno e l'anteprima lo mostra tratteggiato.
@@ -4281,6 +4282,7 @@ axis Both Entrambi
@msg {_1} files are written by file.saveAsTemplate i file {_1} vengono scritti da file.saveAsTemplate
@msg {_1} font families available {_1} famiglie di font disponibili
@msg {_1} is a live effect (Effect › Plug-ins) {_1} è un effetto dinamico (Effetto › Plug-in)
@msg {_1} is an exporter (File › Export › Export As…) {_1} è un plug-in di esportazione (File › Esporta › Esporta come…)
@msg {_1} is already used by {_2} {_1} è già usato da {_2}
@msg {_1} linked file(s) can't be read here: their previews show file collegati non leggibili qui: {_1}; vengono mostrate le loro anteprime
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions immagini collegate modificate su disco: {_1}; Aggiorna collegamenti mostra le nuove versioni
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2960,6 +2960,7 @@ mark-style Japanese 日本式
Text Import Options テキスト読み込みオプション
The Print Tiling tool placed the pages. プリント分割ツールでページが配置されています。
The bounds of the visible art. 表示されているアートの範囲。
The plug-in writes the whole document. プラグインがドキュメント全体を書き出します。
The cell shows the type object's text as HTML. セルには文字オブジェクトのテキストが HTML として表示されます。
The document has no colour swatches. ドキュメントにカラースウォッチがありません。
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. プリンターが印刷できない用紙の端の部分です。アートはこの内側に配置され、プレビューでは破線で表示されます。
@@ -4271,6 +4272,7 @@ swatch Bright Violet ビビッドバイオレット
@msg missing `id` (see plugin.list) `id` が指定されていません (plugin.list を参照)
@msg no plug-in {id} is installed プラグイン {id} はインストールされていません
@msg {_1} is a live effect (Effect › Plug-ins) {_1} はライブ効果です (「効果 › プラグイン」から使用してください)
@msg {_1} is an exporter (File › Export › Export As…) {_1} は書き出しプラグインです (「ファイル › 書き出し › 形式を指定して書き出し…」から使用してください)
@msg a preset named `{_1}` exists `{_1}` という名前のプリセットはすでに存在します
@msg a swatch or color group named `{n}` exists `{n}` という名前のスウォッチまたはカラーグループはすでに存在します
@msg no print preset named `{base}` `{base}` という名前のプリントプリセットが見つかりません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1879,6 +1879,7 @@ lighting Light სინათლე
One file per artboard: <name>-<artboard>.{ext} ერთი ფაილი თითო სამუშაო დაფაზე: <name>-<artboard>.{ext}
One page per artboard. ერთი გვერდი თითო სამუშაო დაფაზე.
The bounds of the visible art. ხილული ნახატის საზღვრები.
The plug-in writes the whole document. პლაგინი მთელ დოკუმენტს ჩაწერს.
Use Artboards სამუშაო დაფების გამოყენება
Choose a folder საქაღალდის არჩევა
Create Sub-folders ქვესაქაღალდეების შექმნა
@@ -4015,6 +4016,7 @@ effect Regular ჩვეულებრივი
@msg {_1} files can be opened but not written (see document.formats) {_1} ფაილების გახსნა შესაძლებელია, ჩაწერა — არა (იხ. document.formats)
@msg {_1} font families available ხელმისაწვდომი შრიფტების ოჯახები: {_1}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} ცოცხალი ეფექტია (ეფექტი › პლაგინები)
@msg {_1} is an exporter (File › Export › Export As…) {_1} ექსპორტის პლაგინია (ფაილი › ექსპორტირება › ექსპორტირება ფორმატში…)
@msg {_1} is already used by {_2} {_1} უკვე გამოიყენება აქ: {_2}
@msg {_1} linked file(s) can't be read here: their previews show მიბმული ფაილების წაკითხვა აქ ვერ ხერხდება ({_1}): ჩანს მათი გადახედვა
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions მიბმული გამოსახულებები შეიცვალა დისკზე ({_1}): ბმულების განახლება აჩვენებს ახალ ვერსიებს
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2954,6 +2954,7 @@ mark-style Japanese 일본식
Text Import Options 텍스트 가져오기 옵션
The Print Tiling tool placed the pages. 인쇄 타일링 도구가 페이지를 배치했습니다.
The bounds of the visible art. 보이는 아트워크의 경계입니다.
The plug-in writes the whole document. 플러그인이 문서 전체를 내보냅니다.
The cell shows the type object's text as HTML. 셀에 문자 개체의 텍스트가 HTML로 표시됩니다.
The document has no colour swatches. 문서에 색상 견본이 없습니다.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. 프린터가 닿지 않는 용지 가장자리입니다. 아트워크는 이 안쪽에 놓이며, 미리 보기에는 점선으로 표시됩니다.
@@ -4203,6 +4204,7 @@ theme Appearance 외관
@msg missing `id` (see plugin.list) `id`가 없습니다(plugin.list 참조)
@msg no plug-in {id} is installed 플러그인 {id}이(가) 설치되어 있지 않습니다
@msg {_1} is a live effect (Effect › Plug-ins) {_1}은(는) 라이브 효과입니다(효과 › 플러그인)
@msg {_1} is an exporter (File › Export › Export As…) {_1}은(는) 내보내기 플러그인입니다(파일 › 내보내기 › 다른 이름으로 내보내기…)
@msg a preset named `{_1}` exists `{_1}`이라는 프리셋이 이미 있습니다
@msg a swatch or color group named `{n}` exists `{n}`이라는 견본 또는 색상 그룹이 이미 있습니다
@msg no print preset named `{base}` `{base}`라는 인쇄 프리셋이 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1994,6 +1994,7 @@ lighting Light Licht
One file per artboard: <name>-<artboard>.{ext} Eén bestand per tekenbord: <name>-<artboard>.{ext}
One page per artboard. Eén pagina per tekenbord.
The bounds of the visible art. De grenzen van de zichtbare artwork.
The plug-in writes the whole document. De plug-in exporteert het hele document.
Use Artboards Tekenborden gebruiken
Choose a folder Kies een map
Create Sub-folders Submappen maken
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2083,6 +2083,7 @@ lighting Light Światło
One file per artboard: <name>-<artboard>.{ext} Jeden plik na obszar kompozycji: <nazwa>-<obszar>.{ext}
One page per artboard. Jedna strona na obszar kompozycji.
The bounds of the visible art. Granice widocznej kompozycji.
The plug-in writes the whole document. Wtyczka eksportuje cały dokument.
Use Artboards Użyj obszarów kompozycji
Choose a folder Wybierz folder
Create Sub-folders Utwórz podfoldery
@@ -4368,6 +4369,7 @@ imageTrace Outline Kontur
@msg {_1} files are written by file.saveAsTemplate pliki {_1} zapisuje file.saveAsTemplate
@msg {_1} font families available dostępne rodziny czcionek: {_1}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} to efekt na żywo (Efekt › Wtyczki)
@msg {_1} is an exporter (File › Export › Export As…) {_1} to wtyczka eksportu (Plik › Eksportuj › Eksportuj jako…)
@msg {_1} is already used by {_2} {_1} jest już używany przez {_2}
@msg {_1} linked file(s) can't be read here: their previews show połączonych plików nie można tu odczytać ({_1}): wyświetlane są ich podglądy
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions połączone obrazy zmienione na dysku: {_1} — Aktualizuj łącza pokaże nowe wersje
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2625,6 +2625,7 @@
Text Wrap Options… Opções de Quebra de Texto…
The Print Tiling tool placed the pages. A ferramenta Tesselação de Impressão colocou as páginas.
The bounds of the visible art. Os limites da arte visível.
The plug-in writes the whole document. O plug-in exporta o documento inteiro.
The cell shows the type object's text as HTML. A célula mostra o texto do objeto de tipo como HTML.
The document has no colour swatches. O documento não tem amostras de cor.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. A borda do papel que a impressora não alcança: a arte fica dentro dela, e a visualização a mostra tracejada.
@@ -3822,6 +3823,7 @@ axis Both Ambos
@msg {_1} files are written by file.saveAsTemplate arquivos {_1} são gravados por file.saveAsTemplate
@msg {_1} font families available {_1} famílias de fontes disponíveis
@msg {_1} is a live effect (Effect › Plug-ins) {_1} é um efeito dinâmico (Efeito › Plug-ins)
@msg {_1} is an exporter (File › Export › Export As…) {_1} é um plug-in de exportação (Arquivo › Exportar › Exportar Como…)
@msg {_1} is already used by {_2} {_1} já é usado por {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} arquivo(s) vinculado(s) não podem ser lidos aqui: suas visualizações são exibidas
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagem(ns) vinculada(s) mudou(aram) no disco: Atualizar links mostra as novas versões
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3241,6 +3241,7 @@ mark-style Japanese Японский
Text Wrap Options Параметры обтекания текстом
Text Wrap Options… Параметры обтекания текстом…
The bounds of the visible art. Границы видимого рисунка.
The plug-in writes the whole document. Модуль экспортирует весь документ.
The cell shows the type object's text as HTML. Ячейка показывает текст текстового объекта как HTML.
The document has no colour swatches. В документе нет цветовых образцов.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Край бумаги, до которого не доходит принтер: рисунок остаётся внутри него, а просмотр показывает его пунктиром.
@@ -4277,6 +4278,7 @@ axis Both Оба
@msg {_1} files are written by file.saveAsTemplate файлы {_1} записывает file.saveAsTemplate
@msg {_1} font families available доступно семейств шрифтов: {_1}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} — динамический эффект (Эффект › Модули)
@msg {_1} is an exporter (File › Export › Export As…) {_1} — модуль экспорта (Файл › Экспорт › Экспортировать как…)
@msg {_1} is already used by {_2} {_1} уже используется {_2}
@msg {_1} linked file(s) can't be read here: their previews show здесь невозможно прочитать связанных файлов: {_1}: отображаются их предварительные просмотры
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions связанных изображений ({_1}) изменились на диске: «Обновить связи» покажет новые версии
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3153,6 +3153,7 @@
Text Wrap Options Alternativ för textombrytning
Text Wrap Options… Alternativ för textombrytning…
The bounds of the visible art. Gränserna för den synliga grafiken.
The plug-in writes the whole document. Insticksprogrammet exporterar hela dokumentet.
The cell shows the type object's text as HTML. Cellen visar textobjektets text som HTML.
The document has no colour swatches. Dokumentet har inga färgrutor.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Den kant av papperet som skrivaren inte når: grafiken placeras innanför den, och förhandsgranskningen visar den streckad.
@@ -4164,6 +4165,7 @@ axis Both Båda
@msg {_1} files are written by file.saveAsTemplate {_1}-filer skrivs av file.saveAsTemplate
@msg {_1} font families available {_1} teckensnittsfamiljer tillgängliga
@msg {_1} is a live effect (Effect › Plug-ins) {_1} är en live-effekt (Effekt › Insticksprogram)
@msg {_1} is an exporter (File › Export › Export As…) {_1} är ett exportinsticksprogram (Fil › Exportera › Exportera som…)
@msg {_1} is already used by {_2} {_1} används redan av {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} länkade fil(er) kan inte läsas här: deras förhandsgranskningar visas
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} länkade bild(er) har ändrats på disken: Uppdatera länkar visar de nya versionerna
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -3238,6 +3238,7 @@ effect Points Точки
Text Wrap Options Параметри обтікання текстом
Text Wrap Options… Параметри обтікання текстом…
The bounds of the visible art. Кордони видимого малюнка.
The plug-in writes the whole document. Модуль експортує весь документ.
The cell shows the type object's text as HTML. Осередок показує текст текстового об’єкта як HTML.
The document has no colour swatches. У документі немає кольорових зразків.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Край паперу, до якого не приходить принтер: малюнок залишається всередині нього, а перегляд показує його пунктиром.
@@ -4265,6 +4266,7 @@ axis Both Обидві
@msg {_1} files are written by file.saveAsTemplate файли {_1} записує file.saveAsTemplate
@msg {_1} font families available доступно сімейства шрифтів: {_1}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} - динамічний ефект (Ефект › Модулі)
@msg {_1} is an exporter (File › Export › Export As…) {_1} - модуль експорту (Файл › Експорт › Експортувати як…)
@msg {_1} is already used by {_2} {_1} вже використовується {_2}
@msg {_1} linked file(s) can't be read here: their previews show тут неможливо прочитати пов’язані файли: {_1}: відображаються їх попередні перегляди
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions пов’язаних зображень ({_1}) змінилися на диску: «Оновити зв’язки» покаже нові версії
Can't render this file because it contains an unexpected character in line 725 and column 40.
+2
View File
@@ -3572,6 +3572,7 @@ mark-style Japanese Kiểu Nhật
Texturizer… Texturizer…
Thai Tiếng Thái
The bounds of the visible art. Vùng giới hạn của đối tượng hiển thị.
The plug-in writes the whole document. Plug-in xuất toàn bộ tài liệu.
The cell shows the type object's text as HTML. Ô hiển thị văn bản của đối tượng chữ dưới dạng HTML.
The dialog was closed before its file dialog answered Hộp thoại đã đóng trước khi hộp thoại tệp phản hồi
The document has no colour swatches. Tài liệu không có mẫu màu.
@@ -4636,6 +4637,7 @@ theme Appearance Giao diện
@msg {_1} font families available {_1} họ phông chữ khả dụng
@msg {_1} has no {_2} style {_1} không có kiểu {_2}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} là hiệu ứng trực tiếp (Hiệu ứng › Plug-ins)
@msg {_1} is an exporter (File › Export › Export As…) {_1} là plug-in xuất (Tệp › Xuất › Xuất thành…)
@msg {_1} is already used by {_2} {_1} đã được dùng bởi {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} tệp liên kết không thể đọc ở đây: bản xem trước được hiển thị
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} ảnh liên kết đã thay đổi trên đĩa: Cập nhật liên kết hiển thị phiên bản mới
Can't render this file because it contains an unexpected character in line 3963 and column 10.
+2
View File
@@ -3096,6 +3096,7 @@ font-script Japanese 汉字
Text Wrap Options 文字绕图选项
Text Wrap Options… 文本绕排选项…
The bounds of the visible art. 可见图稿的边界。
The plug-in writes the whole document. 增效工具会导出整个文档。
The cell shows the type object's text as HTML. 该单元格以 HTML 显示文字对象的内容。
The document has no colour swatches. 文档没有颜色色板。
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. 打印机无法触及的纸张边缘:图稿会放在其内侧,预览中会以虚线显示。
@@ -4014,6 +4015,7 @@ imageTrace Outline 轮廓
@msg missing `id` (see plugin.list) 缺少 `id` (请参阅 plugin.list)
@msg no plug-in {id} is installed 未安装增效工具 {id}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} 是实时效果 (效果 › 增效工具)
@msg {_1} is an exporter (File › Export › Export As…) {_1} 是导出增效工具 (文件 › 导出 › 导出为…)
@msg a preset named `{_1}` exists 名为 `{_1}` 的预设已存在
@msg a swatch or color group named `{n}` exists 名为 `{n}` 的色板或颜色组已存在
@msg no print preset named `{base}` 没有名为 `{base}` 的打印预设
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2019,6 +2019,7 @@ file-filter Templates 範本
@msg missing `id` (see plugin.list) 未提供 `id` (請參閱 plugin.list)
@msg no plug-in {id} is installed 未安裝增效模組 {id}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} 是即時效果 (效果 › 增效模組)
@msg {_1} is an exporter (File › Export › Export As…) {_1} 是匯出增效模組 (檔案 › 匯出 › 匯出為…)
@msg a preset named `{_1}` exists 名為 `{_1}` 的預設集已存在
@msg a swatch or color group named `{n}` exists 名為 `{n}` 的色票或顏色群組已存在
@msg no print preset named `{base}` 沒有名為 `{base}` 的列印預設集
@@ -3964,6 +3965,7 @@ font-script Japanese 漢字
Text Wrap Options 文字繞圖選項
Text Wrap Options… 文字繞圖選項…
The bounds of the visible art. 可見圖稿的邊界。
The plug-in writes the whole document. 增效模組會匯出整份文件。
The cell shows the type object's text as HTML. 儲存格以 HTML 顯示文字物件的文字。
The document has no colour swatches. 文件中沒有色票。
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. 印表機無法觸及的紙張邊緣:圖稿會放在其內側,預覽中會以虛線顯示。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6 -2
View File
@@ -725,9 +725,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
Some(z) if f.id == "svg" && z.id == "svgz" => z,
_ => f,
};
// Background Export: encoded and written from this snapshot while editing goes on.
// Background Export: encoded and written from this snapshot while editing goes on. The
// encoder is told the file it writes (an exporter plug-in gets its name and extension).
let label = format!("Exporting {}", fileio::file_name(&path));
let (params, target) = (params.clone(), path.clone());
let (mut params, target) = (params.clone(), path.clone());
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(path));
}
// The progress dialog's counts and Cancel: a cancelled export stops drawing and never starts
// writing.
let progress = Progress::new();
+3 -1
View File
@@ -198,7 +198,9 @@ Plug-in dialogs: `engine.execute {command: "plugin.dialog", params: {id}}` (Obje
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
the `effect` dialog (`effect.dialog {effect: "plugin.<id>"}`). An exporter plug-in with parameters opens the
`pluginExport` dialog once Export As has picked the file: one field per parameter, `__plugin`, and `__export` (the
export's own parameters); `ui.dialog.confirm` writes the file. `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
+10 -3
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -4005,7 +4005,10 @@ them again at startup (`plugin.remove` deletes them there):
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.
`effect.list` lists it. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
`document.export {format: "plugin.<id>", params?, path?}` writes it, as does a `path` ending in one of its extensions
that no built-in format has. A failing plug-in returns an error and leaves the document as it was (an export writes
no file).
```json
{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}}
+43 -12
View File
@@ -5,13 +5,16 @@ 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 two kinds of plug-in:
There are three 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.
- **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.
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`).
@@ -20,7 +23,7 @@ This page is the contract for plug-in authors and the reference for the host (`c
| Command | Params | What it does |
|---|---|---|
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, size, source}], "folder": report}` |
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, 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. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). |
@@ -30,12 +33,18 @@ This page is the contract for plug-in authors and the reference for the host (`c
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.
An exporter adds a write-only format, `plugin.<id>`, which `document.formats` lists with its `plugin` id.
`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.
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. Parameters too many for the window's height scroll inside the dialog,
which keeps its heading and buttons in view. **File › Open** of a `.wasm` file installs it too (on the web this is
how plug-ins get in).
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
a plug-in 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;
@@ -57,11 +66,14 @@ 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` | `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")` |
| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; 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 export (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 |
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.
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
@@ -109,7 +121,12 @@ 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 (1–64 printable characters). `kind`: `"filter"` (object filter) or `"effect"` (live effect).
- `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.
- `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.
- `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
@@ -123,6 +140,12 @@ paths are left out, and so are other kinds of object (type, images, symbols, liv
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`).
An **exporter** gets something else: the document as a native `.vectorcraft` file's JSON, as `document.save` writes it
but without indentation (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object,
appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks
`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`.
```json
{"objects": [
{"id": 12, "type": "path", "name": "Leaf",
@@ -161,9 +184,11 @@ object, holding only geometry (`type`, `path`, `fillRule`, `bounds`).
"_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.
`mode` is `"filter"`, `"effect"` or `"export"`; `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. 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.
### Output
@@ -189,6 +214,10 @@ 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.
An **exporter** returns `{"dataBase64": "…", "warnings"?: ["…"]}`: the file, in base64, which the host writes as it
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.
## Writing a plug-in in Rust
[`crates/plugins/example`](../crates/plugins/example) is a complete object filter, **Desaturate**: it moves every
@@ -204,7 +233,8 @@ The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the t
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.
build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a
base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with.
## Performance
@@ -215,7 +245,8 @@ 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.
callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open
files).
- 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.
File diff suppressed because one or more lines are too long