mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user