mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Plug-ins: exporters write file formats of their own (G17)
A plug-in could filter objects or reshape them as a live effect, but
nothing it made could leave the app: pen plotters, cutting machines and
laser cutters read formats VectorCraft doesn't write. An exporter is a
third kind of plug-in ("kind": "exporter", with the file "extensions"
it writes), in the same sandbox and with the same ABI v1 exports.
- It adds a write-only format, plugin.<id>. document.formats lists it
with its plug-in id, and document.export writes it by that id, or by
a path ending in one of its extensions that no built-in format has;
"params" carries its parameters, checked against its manifest as a
filter's are.
- File › Export › Export As lists it after the built-in formats, with
Use Artboards greyed out, since the plug-in writes the whole
document. One with parameters asks for them in a dialog generated
from its schema (pluginExport) once the file is picked.
- The plug-in is given the native file's JSON (format::json_part:
images of 8 KiB or more without their data) and _context {mode:
"export", extension, name}. It returns {"dataBase64", "warnings"?}
or {"error"}. The host writes the file, and nothing when the plug-in
declines or returns bad data.
- An export gets 10 G instructions plus 4,000 per byte and 300 s.
Background Export's Cancel stops it between two slices of that
budget (Error::Cancelled), and a cancelled live-effect run is not
cached as a failure.
- plugin.run and Object › Plug-ins say where an exporter is instead of
calling it a live effect.
engine/tests/plugin_export.rs reads back what an exporter is handed
with wat::echo_exporter, an exporter whose file is its input or its
parameters (its base64 encoder is written in WAT). The document it is
given is document.save's JSON, without the large image's data, and its
parameters arrive defaulted, clamped and with the file's name and
extension. It also covers listing and removal, built-in extensions
winning, selectedOnly, declines, bad base64, missing data, warnings,
and Cancel stopping a plug-in that never returns. It is a test binary
of its own: installed plug-ins are process-wide, and the format-list
unit tests check exact lists. dialogs/tests_plugin_export.rs drives
Export As headlessly; plugins/tests/abi.rs covers the budget, the
parameters and the echo exporter; format/tests/native_v4.rs covers
json_part.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
1dda8d9e67
commit
24936cd006
@@ -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
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user