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