mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3171 from KaboomFox/p1-exporter-plugins
Plug-ins: exporters write file formats of their own (G17)
This commit is contained in:
@@ -137,3 +137,6 @@ sync_github_name = true
|
||||
[people.MiQ71]
|
||||
real_name = "Mirko Kerezovic"
|
||||
sync_github_name = false
|
||||
|
||||
[people.KaboomFox]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -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), ¶ms, 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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(¶ms).map_err(|e| Error::Params(e.to_string()))?;
|
||||
decode(&plugin.run_limited(document, ¶ms, &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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(¶ms, 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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+4
-1
@@ -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
@@ -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
Reference in New Issue
Block a user