Plug-ins: filters at API level 2 read type, images and symbol instances

A filter manifest at `api` 2 may list `inputs` (`type`, `image`,
`symbolInstance`). Such a filter is also given the selected objects of
those kinds, read only: type with its runs and paragraph attributes,
images with their pixel size, color mode and, when embedded and small
enough, their RGBA pixels in base64 (never a linked file's pixels or
path), and symbol instances with their symbol and transform.

The output must return each read-only object as {"id": ...} alone or
exactly as given. A change, an omission or a duplicate is refused
before the edit, so the document and undo history stay as they were,
including for output handed in from a heavy run. A filter without
`inputs` gets the same input bytes as before.

A manifest that lists `inputs` below `api` 2, outside a filter, with an
unknown or repeated kind, or with a permission list that lacks
`doc.read` does not install.

Part of #4333
This commit is contained in:
dotCooCoo
2026-11-07 23:34:20 -08:00
parent 0a702cc0a5
commit 68609fb43b
36 changed files with 956 additions and 35 deletions
Generated
+2
View File
@@ -6422,6 +6422,8 @@ dependencies = [
name = "vectorcraft-plugins"
version = "0.21.0"
dependencies = [
"craft-vectorcraft-format",
"image",
"log",
"serde",
"serde_json",
+23 -9
View File
@@ -25,7 +25,12 @@
//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its
//! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log
//! under its id.
//!
//! A filter whose manifest lists `inputs` (plug-in API level 2) is also given the selected type,
//! images or symbol instances of those kinds, to read only: its output must return each of them
//! unchanged ([`objects::decode_output_with`]).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
@@ -64,6 +69,9 @@ pub fn describe(p: &Plugin) -> Value {
if m.budget == Budget::Heavy {
v["budget"] = json!("heavy");
}
if !m.inputs.is_empty() {
v["inputs"] = json!(m.inputs);
}
v
}
@@ -98,6 +106,9 @@ pub struct FilterRun {
params: Vec<u8>,
/// The objects the plug-in is given, and the container new objects go in.
inputs: Vec<NodeId>,
/// The type, images and symbol instances among them (a filter that asks for them), as given:
/// its output must return each one unchanged.
read_only: HashMap<NodeId, Value>,
parent: Option<NodeId>,
/// The document it was made from, and that document's revision then.
document: (u64, u64),
@@ -140,8 +151,10 @@ impl FilterRun {
let may = m.permissions();
let st = s.doc()?;
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
// Without `doc.read` a filter is given no objects: it can only add new ones.
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
// Without `doc.read` a filter is given no objects: it can only add new ones. Type, images
// and symbol instances only when its manifest asks for them (`inputs`).
let kinds = m.input_kinds();
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets_with(&st.doc, &selected, &kinds) } else { Vec::new() };
let parent = st.insertion_parent();
let bounds = st.doc.bounds_of(&inputs, false);
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
@@ -149,10 +162,10 @@ impl FilterRun {
// What the plug-in keeps with the document, as it last wrote it.
let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null);
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
let input = objects::encode_input(&st.doc, &inputs);
let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds);
let params = serde_json::to_vec(&params).map_err(|e| bad(C, e.to_string()))?;
let document = (st.uid, st.revision);
Ok(FilterRun { plugin, input, params, inputs, parent, document })
Ok(FilterRun { plugin, input, params, inputs, read_only, parent, document })
}
/// The plug-in, for its name and budget.
@@ -192,12 +205,13 @@ impl FilterRun {
}
/// Applies the plug-in's `output` to the active document as one undo step: its objects, and what
/// it keeps with the document from now on. An output its permissions don't allow is refused,
/// and the document stays as it was.
/// it keeps with the document from now on. An output its permissions don't allow, and one that
/// changes or leaves out a read-only object it was given, is refused, and the document stays as
/// it was.
fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> {
let m = self.plugin.manifest();
let may = m.permissions();
let out = objects::decode_output(output).map_err(err)?;
let out = objects::decode_output_with(output, &self.read_only).map_err(err)?;
if out.document_data.is_some() && !may.contains(&Permission::State) {
return Err(not_granted(&m.name, Permission::State));
}
@@ -499,7 +513,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {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), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], 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), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
always,
list
),
@@ -517,7 +531,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Plug-in",
[],
None,
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance) is also given the selected objects of those kinds to read: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
has_doc,
run
),
+50
View File
@@ -660,6 +660,56 @@ fn a_filter_does_only_what_its_permissions_allow() {
assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1})));
}
/// A filter whose manifest lists `inputs` is also given the selected type, read-only: an echo
/// changes nothing, and an output that leaves the type out or changes it is refused with the
/// document and undo history as they were, handed in from a run made elsewhere too. A filter
/// without `inputs` is not given the type and so never deletes it.
#[test]
fn read_only_kind_field_and_omission_refuse_without_undo() {
use cmd::plugin::FilterRun;
let mut s = session();
let r = rect(&mut s, 10.0, "#ff0000");
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap());
let reads = |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"inputs":["type"]}}"#);
let echo = "test.engine.inputs.echo";
let listed = install_wat(&mut s, &tpl::module(&reads(echo), tpl::ECHO, "", 1));
assert_eq!(listed["inputs"], json!(["type"]));
let p = |id: &str| json!({"id": id, "ids": [r.0, t.0]});
let before = s.doc().unwrap().doc.clone();
assert_eq!(run(&mut s, "plugin.run", p(echo))["ids"], json!([r.0, t.0]));
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "an echo changes nothing");
for (output, says) in [
(json!({"objects": [{"id": r.0}]}), format!("objects (id {}): a read-only object was left out", t.0)),
(json!({"objects": [{"id": r.0}, {"id": t.0, "name": "Renamed"}]}), format!("objects[1] (id {}): read-only field 'name' changed", t.0)),
(json!({"objects": [{"id": r.0}, {"id": t.0}, {"type": "type", "path": triangle()}]}), "`type` must be \"path\" or \"compound\"".into()),
] {
let id = "test.engine.inputs.bad";
install_wat(&mut s, &tpl::returning(&reads(id), &output.to_string()));
let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string();
assert!(e.contains(&says), "{says}: {e}");
assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "{output}");
// Handed in from a run made away from plugin.run, it is refused the same way.
let made = FilterRun::prepare(&s, &p(id)).unwrap();
let out = made.run().unwrap();
made.hand_in(&mut s, out);
let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string();
assert!(e.contains(&says), "handed in: {e}");
assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "handed in: {output}");
}
// Without `inputs` the type isn't given, so leaving it out keeps it.
let legacy = "test.engine.inputs.legacy";
install_wat(&mut s, &tpl::returning(&tpl::manifest(legacy, "filter", "{}"), &json!({"objects": [{"id": r.0, "name": "Kept"}]}).to_string()));
assert_eq!(run(&mut s, "plugin.run", p(legacy))["ids"], json!([r.0]));
assert_eq!(node(&s, r).name.as_deref(), Some("Kept"));
assert!(matches!(node(&s, t).kind, vectorcraft_doc::NodeKind::Text(_)), "the type stays");
// A manifest that lists `inputs` below `api` 2 doesn't install.
let old = format!(r#"{{"id":"{echo}","name":"Old","kind":"filter","inputs":["type"]}}"#);
let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::module(&old, tpl::ECHO, "", 1)).unwrap());
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
assert!(e.contains("`inputs` needs `api` 2"), "{e}");
}
/// What the user granted is kept in the plug-in store with the module: the next session installs
/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is
/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder
+55
View File
@@ -362,4 +362,59 @@ proptest! {
prop_assert!(total == 0 || done <= total);
}
}
/// Manifests with hostile `inputs` (unknown, repeated and oddly cased kinds, long lists, junk of
/// every type) with any `api`, kind and permissions parse to an error or to a filter at `api` 2
/// or later asking for each kind once. One that installs and echoes its input on a document
/// with type, images and symbols leaves the document as it was, and it still renders.
#[test]
fn hostile_manifest_inputs_never_crash(
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
names in prop::collection::vec(prop::sample::select(vec!["type", "image", "symbolInstance", "Type", "text", "path", ""]), 0..6),
long in prop::option::weighted(0.1, 4usize..300),
inputs in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]),
api in prop_oneof![2 => Just(json!(2)), 1 => Just(json!(1)), 1 => Just(json!(3)), 1 => arb_junk()],
permissions in prop_oneof![Just(Value::Null), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))],
) {
let mut m = json!({"id": "fuzz.inputs", "name": "Inputs", "kind": kind, "api": api});
if kind == "exporter" {
m["extensions"] = json!(["fz"]);
}
if !permissions.is_null() {
m["permissions"] = permissions;
}
m["inputs"] = match (inputs, long) {
(_, Some(n)) => json!(vec!["image"; n]),
(Some(Value::Null) | None, None) => json!(names),
(Some(junk), None) => junk,
};
let text = m.to_string();
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
if let Ok(man) = &parsed {
let kinds = man.input_kinds();
prop_assert_eq!(kinds.len(), man.inputs.len(), "{}", text);
prop_assert!(kinds.iter().enumerate().all(|(i, k)| !kinds[..i].contains(k)), "{}", text);
prop_assert!(kinds.is_empty() || (man.kind == vectorcraft_plugins::Kind::Filter && man.api >= 2), "{}", text);
}
let mut s = rich_session();
let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap();
let data = vectorcraft_format::base64_encode(&module);
let grant = json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]);
let before = s.doc().unwrap().doc.clone();
let r = catch_quiet(|| {
let installed = s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok();
let _ = s.execute("select.all", &json!({}));
let ran = installed && s.execute("plugin.run", &json!({"id": "fuzz.inputs"})).is_ok();
(installed, ran)
});
let (installed, ran) = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?;
prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text);
if ran {
prop_assert!(s.doc().unwrap().doc.layers == before.layers, "an echo changed the document: {}", text);
}
renders(&s, &text)?;
vectorcraft_plugins::registry::remove("fuzz.inputs");
vectorcraft_plugins::registry::revoke("fuzz.inputs");
}
}
+3
View File
@@ -15,6 +15,9 @@ vectorcraft-doc = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
# The pixels of a small image a filter asks to read, as RGBA in base64.
image = { workspace = true }
craft-vectorcraft-format = { workspace = true }
# The SHA-256 of a module, which the install prompt shows and a grant records.
sha2 = { version = "0.10", default-features = false }
thiserror = { workspace = true }
+3 -2
View File
@@ -16,7 +16,8 @@
//! a module does can panic or hang the host.
//!
//! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with
//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns
//! their geometry, fills, strokes (gradients included) and opacity, and, for a filter that asks
//! for them ([`InputKind`]), type, images and symbol instances to read. 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; an **exporter** ([`export`]) writes the whole
@@ -39,7 +40,7 @@ pub mod wat;
pub use dialog::ParamUi;
pub use host::{LogLevel, LogLine, Report};
pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec};
pub use manifest::{Budget, InputKind, Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
/// What the host puts before a plug-in's id to name what it adds: its live effect
+126 -1
View File
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::dialog::ParamUi;
use crate::permissions::{self, API_LEVEL, PROGRESS_API, Permission};
use crate::permissions::{self, API_LEVEL, INPUTS_API, PROGRESS_API, Permission};
use crate::{Error, Result};
/// Longest manifest the host reads.
@@ -46,6 +46,39 @@ impl Kind {
}
}
/// A kind of object a filter may ask to be given besides paths and compound paths (its manifest's
/// `inputs`, plug-in API level 2). The filter reads these objects and returns each one as it was
/// given: it can't change or delete them ([`crate::objects::decode_output_with`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InputKind {
/// Point, area and path type: its text runs with their character attributes, and its
/// paragraph attributes.
Type,
/// An embedded or linked image: its size, color mode and, when small, its pixels.
Image,
/// A symbol instance: the symbol it shows and its transform.
SymbolInstance,
}
impl InputKind {
/// Every kind, in the order the docs list them.
pub const ALL: [InputKind; 3] = [InputKind::Type, InputKind::Image, InputKind::SymbolInstance];
/// Its name in `inputs`, which is also the `type` of such an object in a filter's input.
pub fn name(self) -> &'static str {
match self {
InputKind::Type => "type",
InputKind::Image => "image",
InputKind::SymbolInstance => "symbolInstance",
}
}
/// The kind named `name`.
pub fn parse(name: &str) -> Option<InputKind> {
InputKind::ALL.into_iter().find(|k| k.name() == name)
}
}
/// How much one run of a plug-in may compute.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -250,6 +283,11 @@ pub struct Manifest {
/// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<Vec<String>>,
/// The kinds of object a filter asks to be given besides paths and compound paths, by
/// [`InputKind::name`]: only a filter at `api` 2 or later lists them. Left out or empty, it is
/// given paths and compound paths alone.
#[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "input_names")]
pub inputs: Vec<String>,
/// 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, Param)>,
@@ -285,6 +323,7 @@ impl Manifest {
author: String::new(),
api: 1,
permissions: None,
inputs: Vec::new(),
params: Vec::new(),
extensions: Vec::new(),
budget: Budget::Light,
@@ -313,6 +352,7 @@ impl Manifest {
{
return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}")));
}
self.validate_inputs()?;
self.validate_extensions()?;
match (self.budget, self.kind) {
(Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {}
@@ -336,6 +376,38 @@ impl Manifest {
Ok(())
}
/// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists
/// any, with `doc.read` among its permissions when it lists them.
fn validate_inputs(&self) -> Result<()> {
if self.inputs.is_empty() {
return Ok(());
}
if self.kind != Kind::Filter {
return Err(Error::Manifest("only a filter declares `inputs`".into()));
}
for (i, name) in self.inputs.iter().enumerate() {
let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD);
if InputKind::parse(name).is_none() {
return Err(Error::Manifest(format!("`inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance")));
}
if self.inputs.get(..i).is_some_and(|before| before.contains(name)) {
return Err(Error::Manifest(format!("`inputs` lists `{shown}` twice")));
}
}
if self.api < INPUTS_API {
return Err(Error::Manifest("`inputs` needs `api` 2 or later".into()));
}
if self.permissions.as_ref().is_some_and(|p| !p.iter().any(|n| n == Permission::DocRead.name())) {
return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into()));
}
Ok(())
}
/// The kinds of object besides paths and compound paths it asks to be given (`inputs`).
pub fn input_kinds(&self) -> Vec<InputKind> {
self.inputs.iter().filter_map(|n| InputKind::parse(n)).collect()
}
/// 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<()> {
@@ -491,6 +563,17 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
d.deserialize_any(V)
}
/// Reads `inputs`: a list of names (`null`: none). Anything else is an error that names the key, as
/// [`Manifest::validate_inputs`] names a wrong entry.
fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<String>, D::Error> {
let wrong = || serde::de::Error::custom("`inputs` must be a list of object kinds: type, image or symbolInstance");
match Value::deserialize(d)? {
Value::Null => Ok(Vec::new()),
Value::Array(names) => names.into_iter().map(|n| if let Value::String(s) = n { Ok(s) } else { Err(wrong()) }).collect(),
_ => Err(wrong()),
}
}
fn params_as_object<S: serde::Serializer>(p: &[(String, Param)], s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(p.len()))?;
@@ -674,6 +757,48 @@ mod tests {
}
}
/// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once,
/// with `doc.read` when it lists permissions. Left out or empty, it asks for none and isn't
/// written back. Anything else is refused naming `inputs`.
#[test]
fn a_filter_at_api_2_lists_the_inputs_it_reads() {
let m =
Manifest::parse(br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"inputs":["type","image","symbolInstance"]}"#)
.unwrap();
assert_eq!(m.input_kinds(), InputKind::ALL);
assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"]));
let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap();
assert_eq!(some.input_kinds(), [InputKind::Image]);
for none in [
r#"{"id":"a","name":"x","kind":"filter"}"#,
r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#,
r#"{"id":"a","name":"x","kind":"filter","inputs":null}"#,
r#"{"id":"a","name":"x","kind":"effect","inputs":[]}"#,
] {
let m = Manifest::parse(none.as_bytes()).unwrap();
assert!(m.input_kinds().is_empty(), "{none}");
assert!(serde_json::to_value(&m).unwrap().get("inputs").is_none(), "{none}");
}
for (bad, says) in [
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["text"]}"#, "`inputs` names an unknown object kind `text`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["Type"]}"#, "unknown object kind `Type`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["image","image"]}"#, "`inputs` lists `image` twice"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":"type"}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":[1]}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":{"type":true}}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"),
(r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"),
(r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"),
(r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"),
] {
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
assert!(e.contains(says), "{bad}: {e}");
}
let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"inputs":[{}]}}"#, vec![r#""type""#; 1000].join(","));
assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("twice"));
}
#[test]
fn strings_and_number_lists_resolve_like_the_other_types() {
let m = Manifest::parse(
+280 -17
View File
@@ -10,10 +10,12 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
use vectorcraft_doc::{
Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
use crate::{Error, Result};
use crate::{Error, InputKind, Result};
/// Coordinates a plug-in may return, in points (the document's own canvas limit).
pub const MAX_COORD: f64 = 4.0e6;
@@ -32,6 +34,14 @@ pub const MITER_LIMIT: std::ops::RangeInclusive<f64> = 1.0..=500.0;
pub const MAX_EFFECT_ID: usize = 128;
/// Largest data a plug-in may keep with a document (`documentData`), in bytes of JSON.
pub const MAX_DOCUMENT_DATA: usize = 1 << 20;
/// Most text a filter is given of one type object, in bytes of UTF-8 over all its runs: longer
/// type comes with `truncated` and no runs or paragraphs.
pub const MAX_INPUT_TEXT: usize = 256 * 1024;
/// Most runs, and most paragraphs, a filter is given of one type object (as [`MAX_INPUT_TEXT`]).
pub const MAX_INPUT_RUNS: usize = 4_096;
/// Largest pixels a filter is given of one embedded image, in characters of base64: a larger
/// image comes with `pixelsOmitted`.
pub const MAX_INPUT_PIXELS: usize = 1 << 20;
/// One object returned by a plug-in. `None` fields keep the object's value (new objects take the
/// defaults: a path, non-zero rule, no fills or strokes, full opacity).
@@ -56,6 +66,9 @@ pub struct OutObject {
pub guide: Option<bool>,
/// Locks or unlocks the object (as Object › Lock and Unlock do).
pub locked: Option<bool>,
/// A read-only input object (type, an image or a symbol instance) returned as it was given:
/// it stays as it is, and new objects listed after it go above it.
pub kept: bool,
}
/// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's
@@ -158,8 +171,153 @@ pub fn encode(n: &Node) -> Option<Value> {
/// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths;
/// others are left out).
pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec<u8> {
let objects: Vec<Value> = ids.iter().filter_map(|id| doc.node(*id)).filter_map(encode).collect();
serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default()
encode_input_with(doc, ids, &[]).0
}
/// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are
/// in it as [`encode_read_only`] gives them. Also returns those objects by id, which the filter must
/// return as they were ([`decode_output_with`]).
pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind]) -> (Vec<u8>, HashMap<NodeId, Value>) {
let mut read_only = HashMap::new();
let objects: Vec<Value> = ids
.iter()
.filter_map(|id| doc.node(*id))
.filter_map(|n| match input_kind(n).filter(|k| kinds.contains(k)) {
Some(_) => {
let o = encode_read_only(doc, n)?;
read_only.insert(n.id, o.clone());
Some(o)
}
None => encode(n),
})
.collect();
(serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default(), read_only)
}
/// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others).
pub fn input_kind(n: &Node) -> Option<InputKind> {
match &n.kind {
NodeKind::Text(_) => Some(InputKind::Type),
NodeKind::Image(_) => Some(InputKind::Image),
NodeKind::SymbolInstance { .. } => Some(InputKind::SymbolInstance),
_ => None,
}
}
fn affine_json(xf: Affine) -> Value {
json!(xf.as_coeffs())
}
/// Type, an image or a symbol instance of `doc` as a filter that asks for its kind reads it (`None`
/// for other objects). Each has `{id, type, name?, bounds, transform}`, `transform` mapping its own
/// space (text space, pixel space, the symbol's art) into the document, and:
/// - type (`"type"`): `text: {kind: "point"|"area"|"path", vertical, runs: [{text, style, inline?}],
/// paragraphs: [style]}` with the native character and paragraph attributes, one paragraph style
/// for each paragraph, and `truncated`: true, with no runs or paragraphs, past [`MAX_INPUT_TEXT`]
/// or [`MAX_INPUT_RUNS`];
/// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`,
/// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`]
/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top. A linked image's file
/// and path are never read or given;
/// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when
/// the document has no such symbol, and `overrides: {}`.
pub fn encode_read_only(doc: &Document, n: &Node) -> Option<Value> {
let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of(&[n.id], false))});
match &n.kind {
NodeKind::Text(t) => {
o["transform"] = affine_json(t.xf);
let (text, truncated) = text_json(t);
o["text"] = text;
o["truncated"] = json!(truncated);
}
NodeKind::Image(im) => {
o["transform"] = affine_json(im.xf);
let linked = im.link.is_some();
let blob = doc.image_blob(im);
// A linked image's pixels are its file's: never given (nor its path).
let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height));
o["pixelSize"] = json!([im.width, im.height]);
o["colorMode"] = json!(blob.map_or("unknown", color_mode));
o["linked"] = json!(linked);
o["pixelsOmitted"] = json!(pixels.is_none());
if let Some(p) = pixels {
o["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p));
}
}
NodeKind::SymbolInstance { symbol, xf } => {
o["transform"] = affine_json(*xf);
o["symbolId"] = json!(symbol);
if !doc.symbols.iter().any(|s| s.name == *symbol) {
o["missing"] = json!(true);
}
o["overrides"] = json!({});
}
_ => return None,
}
if let Some(name) = &n.name {
o["name"] = json!(name);
}
Some(o)
}
/// The text of type `t` as a filter reads it, and whether it was too long to give
/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`]).
fn text_json(t: &TextObject) -> (Value, bool) {
let kind = match t.kind {
TextKind::Point => "point",
TextKind::Area { .. } => "area",
TextKind::OnPath { .. } => "path",
};
let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len()));
let paragraphs = t.paragraph_count();
let truncated = bytes > MAX_INPUT_TEXT || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS;
let (runs, paras): (Vec<Value>, Vec<Value>) = if truncated {
(Vec::new(), Vec::new())
} else {
let value = |v: std::result::Result<Value, serde_json::Error>| v.unwrap_or(Value::Null);
(
t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(),
(0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(),
)
};
(json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated)
}
/// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or
/// `"unknown"` when it can't be read or only a linked image's preview is there.
fn color_mode(b: &ImageBlob) -> &'static str {
if b.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &b.bytes) || **p == *b.bytes) {
return "unknown";
}
if b.is_cmyk() {
return "cmyk";
}
if b.gray_source().is_some() {
return "gray";
}
let readable =
image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok());
if readable.is_some() { "rgb" } else { "unknown" }
}
/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit
/// [`MAX_INPUT_PIXELS`] in base64; the size is checked from the header before anything is decoded.
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32) -> Option<Vec<u8>> {
let bytes = u64::from(w) * u64::from(h) * 4;
if bytes == 0 || bytes.div_ceil(3) * 4 > MAX_INPUT_PIXELS as u64 {
return None;
}
let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok();
if reader()?.into_dimensions().ok()? != (w, h) {
return None;
}
let mut r = reader()?;
let mut limits = image::Limits::default();
// The decoder's own buffers besides the RGBA it gives.
limits.max_alloc = Some(64 << 20);
r.limits(limits);
let img = r.decode().ok()?.into_rgba8();
(img.dimensions() == (w, h)).then(|| img.into_raw())
}
fn out_err(at: &str, msg: impl std::fmt::Display) -> Error {
@@ -397,8 +555,16 @@ pub struct Output {
/// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}`
/// when the plug-in declined (the message reaches the user).
pub fn decode_output(bytes: &[u8]) -> Result<Output> {
decode_output_with(bytes, &HashMap::new())
}
/// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list
/// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by
/// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left
/// out, is refused naming the object and the first field that differs, before anything changes.
pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> Result<Output> {
let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
let objects = decode_objects(&v)?;
let objects = decode_objects(&v, read_only)?;
let document_data = match v.get_mut("documentData").map(Value::take) {
None => None,
Some(Value::Null) => Some(None),
@@ -417,11 +583,12 @@ pub fn decode_output(bytes: &[u8]) -> Result<Output> {
/// declined (the message reaches the user). A live effect's other keys don't matter.
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}")))?;
decode_objects(&v)
decode_objects(&v, &HashMap::new())
}
/// The objects of output `v`, or its decline.
fn decode_objects(v: &Value) -> Result<Vec<OutObject>> {
/// The objects of output `v`, or its decline; entries for `read_only` objects as
/// [`decode_output_with`] reads them.
fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<OutObject>> {
if let Some(e) = declined(v) {
return Err(e);
}
@@ -430,7 +597,83 @@ fn decode_objects(v: &Value) -> Result<Vec<OutObject>> {
return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len())));
}
let mut anchors = 0;
objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect()
let mut returned: HashSet<NodeId> = HashSet::new();
let out = objects
.iter()
.enumerate()
.map(|(i, o)| {
let id = o.get("id").and_then(Value::as_u64).map(NodeId);
match id.and_then(|id| Some((id, read_only.get(&id)?))) {
Some((id, given)) => {
let at = format!("objects[{i}] (id {})", id.0);
if !returned.insert(id) {
return Err(out_err(&at, "is listed twice"));
}
check_unchanged(o, given, &at)?;
Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() })
}
None => decode_object(o, &format!("objects[{i}]"), &mut anchors),
}
})
.collect::<Result<Vec<_>>>()?;
let mut missing: Vec<NodeId> = read_only.keys().filter(|id| !returned.contains(id)).copied().collect();
missing.sort();
if let Some(id) = missing.first() {
return Err(out_err(
&format!("objects (id {})", id.0),
"a read-only object was left out: return it, as {\"id\": …} alone or as it was given",
));
}
Ok(out)
}
/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone,
/// or `given` itself, numbers compared by value. Otherwise the error names the first field that
/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out.
fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> {
if o.as_object().is_some_and(|m| m.len() == 1) {
return Ok(());
}
let changed = match (o, given) {
(Value::Object(x), Value::Object(y)) => {
let mut keys: Vec<&String> = x.keys().collect();
keys.sort();
keys.into_iter().find_map(|k| match y.get(k) {
Some(q) => first_difference(&x[k], q, k),
None => Some(k.clone()),
})
}
_ => None,
};
match changed.or_else(|| first_difference(o, given, "")) {
None => Ok(()),
Some(field) => Err(out_err(at, format!("read-only field '{field}' changed"))),
}
}
/// The path of the first place `a` and `b` differ (`""`: they differ as a whole), `None` when they
/// are the same JSON with numbers compared by value.
fn first_difference(a: &Value, b: &Value, at: &str) -> Option<String> {
let key = |k: &str| if at.is_empty() { k.to_string() } else { format!("{at}.{k}") };
match (a, b) {
(Value::Number(x), Value::Number(y)) => (x.as_f64() != y.as_f64()).then(|| at.into()),
(Value::Object(x), Value::Object(y)) => {
let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect();
keys.sort();
keys.dedup();
keys.into_iter().find_map(|k| match (x.get(k), y.get(k)) {
(Some(p), Some(q)) => first_difference(p, q, &key(k)),
_ => Some(key(k)),
})
}
(Value::Array(x), Value::Array(y)) => {
if x.len() != y.len() {
return Some(at.into());
}
x.iter().zip(y).enumerate().find_map(|(i, (p, q))| first_difference(p, q, &format!("{at}[{i}]")))
}
(a, b) => (a != b).then(|| at.into()),
}
}
/// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it,
@@ -625,22 +868,30 @@ pub fn selectable(n: &Node) -> bool {
/// guides too) and clipping paths are left out (so are paths inside compound paths, blends and
/// other live objects).
pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec<NodeId> {
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, out: &mut Vec<NodeId>) {
for n in list {
filter_targets_with(doc, selected, &[])
}
/// [`filter_targets`] for a filter that asks for `kinds` too: type, images and symbol instances of
/// those kinds join the paths in paint order, under the same rules (type that clips a clip group is
/// left out, and so is anything inside a symbol, a placed document or a live object).
pub fn filter_targets_with(doc: &Document, selected: &[NodeId], kinds: &[InputKind]) -> Vec<NodeId> {
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, clips: bool, kinds: &[InputKind], out: &mut Vec<NodeId>) {
for (i, n) in list.iter().enumerate() {
if !n.visible || n.locked {
continue;
}
let picked = inside || sel.contains(&n.id);
match &n.kind {
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), kinds, out),
_ if picked && !(clips && i == 0) && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id),
_ => {}
}
}
}
let sel: HashSet<NodeId> = selected.iter().copied().collect();
let mut out = vec![];
visit(&doc.layers, &sel, false, &mut out);
visit(&doc.layers, &sel, false, false, kinds, &mut out);
out
}
@@ -740,7 +991,14 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" };
out_err(&at, format!("{} {why}", id.0))
})?;
write(doc, &mut n, o, &at, admit)?;
// Type, images and symbol instances are only read: writing one would make it a path.
if input_kind(&n).is_some() && !o.kept {
return Err(out_err(&at, format!("{} is read-only", id.0)));
}
let kept = o.kept;
if !kept {
write(doc, &mut n, o, &at, admit)?;
}
if !run.is_empty() {
let nodes = std::mem::take(&mut run);
match prev {
@@ -748,7 +1006,9 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
None => plan.before.insert(id, nodes),
};
}
plan.replace.insert(id, n);
if !kept {
plan.replace.insert(id, n);
}
prev = Some(id);
result.push(id);
}
@@ -774,7 +1034,10 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
None => plan.top = Some((parent, run)),
}
}
// Input objects the output left out are deleted.
// Input objects the output left out are deleted, except read-only ones, which it must return.
if let Some(n) = originals.values().filter(|n| input_kind(n).is_some()).min_by_key(|n| n.id) {
return Err(out_err(&format!("objects (id {})", n.id.0), "a read-only object was left out"));
}
plan.delete = originals.into_keys().collect();
if let Some(list) = plan.list(None, &doc.layers) {
doc.layers = list;
+238
View File
@@ -310,6 +310,244 @@ fn bad_stroke_styles_and_effect_records_are_refused() {
}
}
/// A `w` × `h` PNG whose pixels vary with their column, half transparent.
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([(x * 100) as u8, (y * 7) as u8, 30, 128]));
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// An image object `w` × `h` pixels showing blob `key`, linked to `link`.
fn image_node(d: &mut Document, key: &str, (w, h): (u32, u32), link: Option<&str>) -> Node {
let im = vectorcraft_doc::ImageObject {
key: key.into(),
width: w,
height: h,
xf: Affine::new([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]),
link: link.map(vectorcraft_doc::LinkInfo::new),
placement: Default::default(),
pixels: None,
};
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
}
/// [`doc`] with, above its paths: point type "T" in two paragraphs, an embedded 2 × 1 image "E", a
/// linked image "L", an instance "S" of symbol "Star" and an instance "G" of a symbol the document
/// doesn't have. Returns those five too.
fn mixed() -> (Document, Vec<NodeId>, NodeId, [NodeId; 5]) {
use vectorcraft_doc::{CharStyle, ParaStyle, Symbol, TextObject};
let (mut d, ids, layer) = doc();
let mut t = TextObject::point(Point::new(5.0, 50.0), "Hi\nthere", CharStyle::default());
t.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { space_before: 6.0, ..Default::default() }]);
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
d.images.insert("e".into(), ImageBlob::png(png(2, 1)));
d.images.insert("l".into(), ImageBlob::png(png(4, 4)));
let embedded = image_node(&mut d, "e", (2, 1), None);
let linked = image_node(&mut d, "l", (4, 4), Some("C:/private/photo.png"));
let art = Arc::new(Node::path(d.alloc_id(), square(0.0), Appearance::default()));
d.symbols.push(Symbol { name: "Star".into(), art, dynamic: false });
let star = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::translate((100.0, 0.0)) });
let ghost = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
let mut extra = [NodeId(0); 5];
for (slot, (mut n, name)) in extra.iter_mut().zip([text, embedded, linked, star, ghost].into_iter().zip(["T", "E", "L", "S", "G"])) {
n.name = Some(name.into());
*slot = d.insert(Some(layer), usize::MAX, n).unwrap();
}
(d, ids, layer, extra)
}
/// A filter that doesn't list `inputs` is given what it was before: the selected paths alone, in
/// the same bytes. One that lists kinds is given those objects too, in paint order, under the same
/// rules (hidden and locked ones left out, selected ones or ones in a selected layer).
#[test]
fn legacy_filter_input_bytes_are_unchanged() {
let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed();
assert_eq!(filter_targets(&d, &[layer]), ids);
assert_eq!(filter_targets_with(&d, &[layer], &[]), ids);
let every: Vec<NodeId> = ids.iter().copied().chain([text, embedded, linked, star, ghost]).collect();
let (bytes, read_only) = encode_input_with(&d, &every, &[]);
assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out");
assert!(read_only.is_empty());
let all = filter_targets_with(&d, &[layer], &InputKind::ALL);
assert_eq!(all, every);
let images: Vec<NodeId> = ids.iter().copied().chain([embedded, linked]).collect();
assert_eq!(filter_targets_with(&d, &[layer], &[InputKind::Image]), images);
assert_eq!(filter_targets_with(&d, &[star, ids[0]], &InputKind::ALL), [ids[0], star], "selected directly");
d.node_mut(text).unwrap().visible = false;
d.node_mut(star).unwrap().locked = true;
assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat());
let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance]);
let v: Value = serde_json::from_slice(&bytes).unwrap();
let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect();
assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]);
assert_eq!(read_only.len(), 2);
}
/// Type comes with its runs and their character attributes, one paragraph style per paragraph, its
/// kind and transform; type past [`MAX_INPUT_TEXT`] or [`MAX_INPUT_RUNS`] comes without text.
#[test]
fn declared_type_input_has_bounded_runs_and_paragraph_styles() {
use vectorcraft_doc::{CharStyle, ParaStyle, TextRun};
let (mut d, _, _, [text, ..]) = mixed();
let o = encode_read_only(&d, d.node(text).unwrap()).unwrap();
assert_eq!((&o["type"], &o["name"], &o["truncated"]), (&json!("type"), &json!("T"), &json!(false)));
assert_eq!(o["transform"], json!([1.0, 0.0, 0.0, 1.0, 5.0, 50.0]));
assert_eq!((&o["text"]["kind"], &o["text"]["vertical"]), (&json!("point"), &json!(false)));
assert_eq!(o["text"]["runs"], json!([{"text": "Hi\nthere", "style": serde_json::to_value(CharStyle::default()).unwrap()}]));
let spaced = ParaStyle { space_before: 6.0, ..Default::default() };
assert_eq!(o["text"]["paragraphs"], json!([serde_json::to_value(ParaStyle::default()).unwrap(), serde_json::to_value(spaced).unwrap()]));
assert!(o.get("path").is_none() && o.get("fills").is_none(), "not made into outlines");
let set_runs = |d: &mut Document, runs: Vec<TextRun>| {
if let NodeKind::Text(t) = &mut d.node_mut(text).unwrap().kind {
t.runs = runs;
t.paras.clear();
}
};
let run = |s: String| TextRun::new(s, CharStyle::default());
for (runs, truncated) in [
(vec![run("x".repeat(MAX_INPUT_TEXT))], false),
(vec![run("x".repeat(MAX_INPUT_TEXT - 1)), run("é".into())], true),
(vec![run("\n".repeat(MAX_INPUT_RUNS - 1))], false),
(vec![run("\n".repeat(MAX_INPUT_RUNS))], true),
((0..MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), false),
((0..=MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), true),
] {
let n = runs.len();
set_runs(&mut d, runs);
let o = encode_read_only(&d, d.node(text).unwrap()).unwrap();
assert_eq!(o["truncated"], json!(truncated), "{n} runs");
let given = o["text"]["runs"].as_array().unwrap().len();
assert_eq!((given, o["text"]["paragraphs"].as_array().unwrap().is_empty()), (if truncated { 0 } else { n }, truncated), "{n} runs");
}
}
/// An embedded image comes with its pixels when they are small; a linked image never does, and
/// nothing of its file's path is given. The color mode is read from the image.
#[test]
fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission() {
let (mut d, _, layer, [_, embedded, linked, ..]) = mixed();
let e = encode_read_only(&d, d.node(embedded).unwrap()).unwrap();
assert_eq!((&e["type"], &e["pixelSize"], &e["colorMode"]), (&json!("image"), &json!([2, 1]), &json!("rgb")));
assert_eq!((&e["linked"], &e["pixelsOmitted"], &e["transform"]), (&json!(false), &json!(false), &json!([2.0, 0.0, 0.0, 2.0, 40.0, 60.0])));
let pixels = craft_vectorcraft_format::base64::decode(e["pixelsBase64"].as_str().unwrap()).unwrap();
assert_eq!(pixels, [0, 0, 30, 128, 100, 0, 30, 128], "straight RGBA, row by row");
let l = encode_read_only(&d, d.node(linked).unwrap()).unwrap();
assert_eq!((&l["linked"], &l["pixelsOmitted"], &l["pixelSize"]), (&json!(true), &json!(true), &json!([4, 4])));
assert!(l.get("pixelsBase64").is_none());
assert!(!l.to_string().contains("private"), "the link's path isn't given: {l}");
// Past the limit (443 × 443 fits it; 444 × 443 doesn't), and pixels of another size, are omitted.
for ((w, h), omitted) in [((443, 443), false), ((444, 443), true)] {
d.images.insert("big".into(), ImageBlob::png(png(w, h)));
let n = image_node(&mut d, "big", (w, h), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
assert_eq!((&o["pixelsOmitted"], o.get("pixelsBase64").is_some()), (&json!(omitted), !omitted), "{w} × {h}");
}
let n = image_node(&mut d, "e", (3, 1), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "not the size it says");
// Gray pixels, and data that isn't an image.
let mut gray = vec![];
image::GrayImage::from_pixel(2, 2, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap();
for (blob, mode) in [(ImageBlob::png(gray), "gray"), (ImageBlob::png(b"not an image".to_vec()), "unknown")] {
d.images.insert("x".into(), blob);
let n = image_node(&mut d, "x", (2, 2), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["colorMode"], json!(mode));
}
}
/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose
/// symbol the document lacks says so.
#[test]
fn declared_symbol_instance_keeps_identity_and_transform() {
let (d, _, _, [.., star, ghost]) = mixed();
let s = encode_read_only(&d, d.node(star).unwrap()).unwrap();
assert_eq!((&s["type"], &s["symbolId"], &s["overrides"]), (&json!("symbolInstance"), &json!("Star"), &json!({})));
assert_eq!(s["transform"], json!([1.0, 0.0, 0.0, 1.0, 100.0, 0.0]));
assert!(s.get("missing").is_none() && s.get("path").is_none());
let g = encode_read_only(&d, d.node(ghost).unwrap()).unwrap();
assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true)));
}
/// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by
/// value), and they stay as they are, new objects listed after one going just above it. Any change,
/// an object left out or listed twice, and a new object of such a kind are refused before anything
/// changes.
#[test]
fn read_only_kind_field_and_omission_refuse() {
let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed();
let targets = filter_targets_with(&d, &[layer], &InputKind::ALL);
let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL);
assert_eq!(read_only.len(), 5);
let input: Value = serde_json::from_slice(&bytes).unwrap();
let before = d.clone();
let out = decode_output_with(&bytes, &read_only).unwrap();
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
apply_output(&mut d, &targets, out.objects, None, &keep).unwrap();
assert_eq!(d, before, "an exact echo changes nothing");
// A filter that doesn't list `inputs` could never hand them back.
assert!(decode_output(&bytes).is_err());
// Numbers written another way are the same numbers.
let mut echo = input.clone();
echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]);
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok());
// `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type.
let ro = |id: NodeId| json!({"id": id.0});
let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]});
let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap();
assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]);
assert_eq!(made.len(), 6);
assert!(ids.iter().all(|id| d.node(*id).is_none()));
assert_eq!(d.node(text), before.node(text), "the type is as it was");
// Refused, naming the object and the field.
let t = text.0;
let changed = |f: &dyn Fn(&mut Value)| {
let mut v = input.clone();
f(&mut v);
v
};
for (out, says) in [
(
changed(&|v| v["objects"][3]["text"]["runs"][0]["text"] = json!("Bye")),
format!("objects[3] (id {t}): read-only field 'text.runs[0].text' changed"),
),
(changed(&|v| v["objects"][3]["type"] = json!("path")), format!("(id {t}): read-only field 'type' changed")),
(changed(&|v| v["objects"][3]["opacity"] = json!(0.5)), format!("(id {t}): read-only field 'opacity' changed")),
(changed(&|v| v["objects"][3]["transform"][4] = json!(6)), format!("(id {t}): read-only field 'transform[4]' changed")),
(
changed(&|v| {
v["objects"][4].as_object_mut().unwrap().remove("pixelsBase64");
}),
format!("(id {}): read-only field 'pixelsBase64' changed", embedded.0),
),
(changed(&|v| v["objects"][6]["symbolId"] = json!("Moon")), format!("(id {}): read-only field 'symbolId' changed", star.0)),
(
changed(&|v| {
v["objects"].as_array_mut().unwrap().remove(3);
}),
format!("objects (id {t}): a read-only object was left out"),
),
(changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"id": ghost.0}))), format!("objects[8] (id {}): is listed twice", ghost.0)),
(
changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"type": "type", "path": square(0.0)}))),
"`type` must be \"path\" or \"compound\"".to_string(),
),
] {
let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string();
assert!(e.contains(&says), "{says}: {e}");
}
// Writing one or leaving one out is refused when applying too, whatever decoded the output.
let mut d = before.clone();
let write = vec![OutObject { id: Some(text), name: Some("Renamed".into()), ..OutObject::default() }];
let e = apply_output(&mut d, &[text], write, None, &keep).unwrap_err().to_string();
assert!(e.contains("is read-only"), "{e}");
let e = apply_output(&mut d, &[text], vec![], None, &keep).unwrap_err().to_string();
assert!(e.contains("a read-only object was left out"), "{e}");
assert_eq!(d, before);
}
/// A filter's `documentData`: absent keeps what the plug-in keeps, `null` forgets it, any other JSON
/// of at most [`MAX_DOCUMENT_DATA`] bytes is kept, and more refuses the whole output.
#[test]
+3
View File
@@ -20,6 +20,9 @@ pub const API_LEVEL: u32 = 2;
/// permission they need.
pub const PROGRESS_API: u32 = 2;
/// The API level that adds a filter's `inputs` ([`crate::InputKind`]).
pub const INPUTS_API: u32 = 2;
/// Most permissions a manifest may list.
pub const MAX_PERMISSIONS: usize = 16;
+1
View File
@@ -627,6 +627,7 @@ impl PluginIntField {
author: String::new(),
api: 1,
permissions: None,
inputs: Vec::new(),
params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())],
extensions: Vec::new(),
budget: Default::default(),
+5
View File
@@ -5864,6 +5864,11 @@ digits Malayalam Malaiàlam
@msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}`
@msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades
@msg only a filter declares `inputs` només un filtre declara `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anomena un tipus d’objecte desconegut `{shown}`: feu servir type, image o symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` inclou `{shown}` dues vegades
@msg `inputs` needs `api` 2 or later `inputs` requereix `api` 2 o posterior
@msg `inputs` needs the permission `doc.read` `inputs` requereix el permís `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix
@msg unknown permission `{shown}` permís desconegut `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5844,6 +5844,11 @@ axis Y Axis osa Y
@msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}`
@msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát
@msg only a filter declares `inputs` `inputs` deklaruje pouze filtr
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` uvádí neznámý druh objektu `{shown}`: použijte type, image nebo symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` uvádí `{shown}` dvakrát
@msg `inputs` needs `api` 2 or later `inputs` vyžaduje `api` 2 nebo vyšší
@msg `inputs` needs the permission `doc.read` `inputs` vyžaduje oprávnění `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje
@msg unknown permission `{shown}` neznámé oprávnění `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5426,6 +5426,11 @@ axis Y Axis Y-Achse
@msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}`
@msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt
@msg only a filter declares `inputs` nur ein Filter deklariert `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nennt eine unbekannte Objektart `{shown}`: verwenden Sie type, image oder symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` führt `{shown}` doppelt auf
@msg `inputs` needs `api` 2 or later `inputs` erfordert `api` 2 oder höher
@msg `inputs` needs the permission `doc.read` `inputs` erfordert die Berechtigung `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht
@msg unknown permission `{shown}` unbekannte Berechtigung `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5795,6 +5795,11 @@ axis Y Axis Άξονας Y
@msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}`
@msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές
@msg only a filter declares `inputs` μόνο ένα φίλτρο δηλώνει `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance το `inputs` αναφέρει άγνωστο είδος αντικειμένου `{shown}`: χρησιμοποιήστε type, image ή symbolInstance
@msg `inputs` lists `{shown}` twice το `inputs` αναφέρει το `{shown}` δύο φορές
@msg `inputs` needs `api` 2 or later το `inputs` απαιτεί `api` 2 ή νεότερο
@msg `inputs` needs the permission `doc.read` το `inputs` απαιτεί το δικαίωμα `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί
@msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης
Can't render this file because it contains an unexpected character in line 168 and column 5.
+5
View File
@@ -5785,6 +5785,11 @@ axis Y Axis Eje Y
@msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}`
@msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces
@msg only a filter declares `inputs` solo un filtro declara `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nombra un tipo de objeto desconocido `{shown}`: use type, image o symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` incluye `{shown}` dos veces
@msg `inputs` needs `api` 2 or later `inputs` requiere `api` 2 o posterior
@msg `inputs` needs the permission `doc.read` `inputs` requiere el permiso `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede
@msg unknown permission `{shown}` permiso desconocido `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5874,6 +5874,11 @@ axis Y Axis Y-akseli
@msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta
@msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti
@msg only a filter declares `inputs` vain suodatin voi määrittää `inputs`-avaimen
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nimeää tuntemattoman objektilajin `{shown}`: käytä arvoa type, image tai symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` luettelee kohteen `{shown}` kahdesti
@msg `inputs` needs `api` 2 or later `inputs` edellyttää `api`-tasoa 2 tai uudempaa
@msg `inputs` needs the permission `doc.read` `inputs` edellyttää käyttöoikeutta `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä
@msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5840,6 +5840,11 @@ axis Y Axis Axe Y
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}`
@msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas
@msg only a filter declares `inputs` seul un filtre déclare `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` désigne un type d’objet inconnu `{shown}` : utilisez type, image ou symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` mentionne `{shown}` deux fois
@msg `inputs` needs `api` 2 or later `inputs` nécessite `api` 2 ou plus
@msg `inputs` needs the permission `doc.read` `inputs` nécessite l’autorisation `doc.read`
@msg unknown permission `{shown}` autorisation inconnue `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5236,6 +5236,11 @@
@msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती
@msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है
@msg only a filter declares `inputs` केवल फ़िल्टर `inputs` घोषित करता है
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` में अज्ञात ऑब्जेक्ट प्रकार `{shown}` है: type, image या symbolInstance का उपयोग करें
@msg `inputs` lists `{shown}` twice `inputs` में `{shown}` दो बार सूचीबद्ध है
@msg `inputs` needs `api` 2 or later `inputs` के लिए `api` 2 या बाद का स्तर आवश्यक है
@msg `inputs` needs the permission `doc.read` `inputs` के लिए अनुमति `doc.read` आवश्यक है
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता
@msg unknown permission `{shown}` अज्ञात अनुमति `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें
Can't render this file because it is too large.
+5
View File
@@ -5827,6 +5827,11 @@ axis Y Axis Y tengely
@msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre
@msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel
@msg only a filter declares `inputs` `inputs` kulcsot csak szűrő adhat meg
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance az `inputs` ismeretlen objektumfajtát nevez meg: `{shown}`; használja a type, image vagy symbolInstance értéket
@msg `inputs` lists `{shown}` twice az `inputs` kétszer sorolja fel ezt: `{shown}`
@msg `inputs` needs `api` 2 or later az `inputs` használatához `api` 2 vagy újabb szükséges
@msg `inputs` needs the permission `doc.read` az `inputs` használatához a(z) `doc.read` engedély szükséges
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg
@msg unknown permission `{shown}` ismeretlen engedély: `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5460,6 +5460,11 @@ axis Y Axis Asse Y
@msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}`
@msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte
@msg only a filter declares `inputs` solo un filtro dichiara `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` indica un tipo di oggetto sconosciuto `{shown}`: usa type, image o symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` elenca `{shown}` due volte
@msg `inputs` needs `api` 2 or later `inputs` richiede `api` 2 o successivo
@msg `inputs` needs the permission `doc.read` `inputs` richiede l’autorizzazione `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede
@msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5525,6 +5525,11 @@ axis Y Axis Y 軸
@msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません
@msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています
@msg only a filter declares `inputs` `inputs` を宣言できるのはフィルターだけです
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` に不明なオブジェクトの種類 `{shown}` があります。type、image、symbolInstance のいずれかを使ってください
@msg `inputs` lists `{shown}` twice `inputs` に `{shown}` が 2 回指定されています
@msg `inputs` needs `api` 2 or later `inputs` には `api` 2 以上が必要です
@msg `inputs` needs the permission `doc.read` `inputs` には権限 `doc.read` が必要です
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません
@msg unknown permission `{shown}` 不明な権限 `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5767,6 +5767,11 @@ axis Y Axis Y ღერძი
@msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს
@msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი
@msg only a filter declares `inputs` `inputs`-ის გამოცხადება მხოლოდ ფილტრს შეუძლია
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` უცნობ ობიექტის სახეობას ასახელებს `{shown}`: გამოიყენეთ type, image ან symbolInstance
@msg `inputs` lists `{shown}` twice `inputs`-ში `{shown}` ორჯერაა ჩამოთვლილი
@msg `inputs` needs `api` 2 or later `inputs`-ს სჭირდება `api` 2 ან უფრო ახალი
@msg `inputs` needs the permission `doc.read` `inputs`-ს სჭირდება ნებართვა `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს
@msg unknown permission `{shown}` უცნობი ნებართვა `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია
Can't render this file because it is too large.
+5
View File
@@ -5421,6 +5421,11 @@ axis Y Axis Y축
@msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다
@msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다
@msg only a filter declares `inputs` 필터만 `inputs`를 선언할 수 있습니다
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs`에 알 수 없는 개체 종류 `{shown}`이(가) 있습니다. type, image 또는 symbolInstance를 사용하세요
@msg `inputs` lists `{shown}` twice `inputs`에 `{shown}`이(가) 두 번 나열되어 있습니다
@msg `inputs` needs `api` 2 or later `inputs`에는 `api` 2 이상이 필요합니다
@msg `inputs` needs the permission `doc.read` `inputs`에는 `doc.read` 권한이 필요합니다
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다
@msg unknown permission `{shown}` 알 수 없는 권한 `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5697,6 +5697,11 @@ axis Y Axis Oś Y
@msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}`
@msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy
@msg only a filter declares `inputs` tylko filtr deklaruje `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` wymienia nieznany rodzaj obiektu `{shown}`: użyj type, image lub symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` wymienia `{shown}` dwa razy
@msg `inputs` needs `api` 2 or later `inputs` wymaga `api` 2 lub nowszego
@msg `inputs` needs the permission `doc.read` `inputs` wymaga uprawnienia `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje
@msg unknown permission `{shown}` nieznane uprawnienie `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5759,6 +5759,11 @@ axis Y Axis Eixo Y
@msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}`
@msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes
@msg only a filter declares `inputs` somente um filtro declara `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` cita um tipo de objeto desconhecido `{shown}`: use type, image ou symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` lista `{shown}` duas vezes
@msg `inputs` needs `api` 2 or later `inputs` exige `api` 2 ou posterior
@msg `inputs` needs the permission `doc.read` `inputs` exige a permissão `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede
@msg unknown permission `{shown}` permissão desconhecida `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -5455,6 +5455,11 @@ axis Y Axis Ось Y
@msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}`
@msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды
@msg only a filter declares `inputs` `inputs` может объявлять только фильтр
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указан неизвестный вид объекта `{shown}`: используйте type, image или symbolInstance
@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано дважды
@msg `inputs` needs `api` 2 or later для `inputs` нужен `api` 2 или выше
@msg `inputs` needs the permission `doc.read` для `inputs` нужно разрешение `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт
@msg unknown permission `{shown}` неизвестное разрешение `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5867,6 +5867,11 @@ axis Y Axis Y-axel
@msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}`
@msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger
@msg only a filter declares `inputs` bara ett filter anger `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anger en okänd objekttyp `{shown}`: använd type, image eller symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` anger `{shown}` två gånger
@msg `inputs` needs `api` 2 or later `inputs` kräver `api` 2 eller senare
@msg `inputs` needs the permission `doc.read` `inputs` kräver behörigheten `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte
@msg unknown permission `{shown}` okänd behörighet `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -5801,6 +5801,11 @@ axis Y Axis Y ekseni
@msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz
@msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş
@msg only a filter declares `inputs` `inputs` yalnızca bir filtrede bildirilebilir
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` bilinmeyen bir nesne türü belirtiyor: `{shown}`; type, image veya symbolInstance kullanın
@msg `inputs` lists `{shown}` twice `inputs` `{shown}` değerini iki kez listeliyor
@msg `inputs` needs `api` 2 or later `inputs` için `api` 2 veya üstü gerekir
@msg `inputs` needs the permission `doc.read` `inputs` için `doc.read` izni gerekir
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez
@msg unknown permission `{shown}` bilinmeyen izin `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -5440,6 +5440,11 @@ axis Y Axis Вісь Y
@msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}`
@msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі
@msg only a filter declares `inputs` `inputs` може оголошувати лише фільтр
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указано невідомий вид об’єкта `{shown}`: використовуйте type, image або symbolInstance
@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано двічі
@msg `inputs` needs `api` 2 or later для `inputs` потрібен `api` 2 або новіший
@msg `inputs` needs the permission `doc.read` для `inputs` потрібен дозвіл `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає
@msg unknown permission `{shown}` невідомий дозвіл `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться
Can't render this file because it contains an unexpected character in line 182 and column 5.
+5
View File
@@ -5441,6 +5441,11 @@ axis Y Axis Trục Y
@msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}`
@msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần
@msg only a filter declares `inputs` chỉ bộ lọc mới khai báo `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nêu một loại đối tượng không xác định `{shown}`: dùng type, image hoặc symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` liệt kê `{shown}` hai lần
@msg `inputs` needs `api` 2 or later `inputs` cần `api` 2 trở lên
@msg `inputs` needs the permission `doc.read` `inputs` cần quyền `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó
@msg unknown permission `{shown}` quyền không xác định `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý
Can't render this file because it contains an unexpected character in line 204 and column 5.
+5
View File
@@ -5682,6 +5682,11 @@ axis Y Axis Y 轴
@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具
@msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次
@msg only a filter declares `inputs` 只有滤镜才能声明 `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的对象类型 `{shown}`:请使用 type、image 或 symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` 列出了两次 `{shown}`
@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高
@msg `inputs` needs the permission `doc.read` `inputs` 需要权限 `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限
@msg unknown permission `{shown}` 未知权限 `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5478,6 +5478,11 @@ axis Y Axis Y 軸
@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions`
@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組
@msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次
@msg only a filter declares `inputs` 只有濾鏡才能宣告 `inputs`
@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的物件類型 `{shown}`:請使用 type、image 或 symbolInstance
@msg `inputs` lists `{shown}` twice `inputs` 列出了兩次 `{shown}`
@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高
@msg `inputs` needs the permission `doc.read` `inputs` 需要權限 `doc.read`
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限
@msg unknown permission `{shown}` 未知權限 `{shown}`
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4 -1
View File
@@ -6086,7 +6086,10 @@ WebAssembly plug-ins ([plugins.md](plugins.md)) are reached through commands; th
them again at startup (`plugin.remove` deletes them there):
`plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters,
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
undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs`
(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) is also given the selected objects of those
kinds to read: its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an
error naming the object and the field, and the document and undo history are unchanged. 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. 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
+57 -5
View File
@@ -145,7 +145,10 @@ capability model proposed for PhotoCraft plug-ins
`documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the
document is refused. A refused output leaves the document as it was.
- `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host
imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`.
imports](#host-imports-api-level-2) and a filter's `inputs` ([reading type, images and
symbols](#reading-type-images-and-symbols)). A higher one is refused. `plugin.list` and `plugin.info` give it as
`api`. `inputs` is not a permission: the objects it asks for are read under `doc.read`, which a filter that lists
`permissions` and `inputs` must list.
- Parameter declarations are unchanged by permissions (`params`, below).
Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed
@@ -237,6 +240,11 @@ still returns `1`.
filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget.
- `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names
([permissions](#permissions)); without it, what its kind implies.
- `inputs` (filters at `api` 2 only): the kinds of object besides paths and compound paths the filter reads, any of
`"type"`, `"image"` and `"symbolInstance"`, each at most once (`"inputs": ["type", "image"]`). Left out or `[]`, the
filter gets paths and compound paths alone, as before. An unknown, repeated or wrongly typed entry, `inputs` on
another kind, below `api` 2, or with `permissions` that leave out `doc.read`, is refused when the plug-in is
installed, and so is `api` 2 on hosts from before it.
- `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.
@@ -276,8 +284,9 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet
`input` is UTF-8 JSON: `{"objects": [...]}`. An object filter gets the selected paths and compound paths in paint
order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound
guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never
touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one
guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…),
which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and
symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one
object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts
from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a
fill's colour to decide its shape, but it changes only the geometry.
@@ -323,6 +332,49 @@ type and `blob` but without their data, which follows the JSON in a saved file;
`plugin.<id>` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed.
- `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only).
### Reading type, images and symbols
A filter whose manifest lists `inputs` also gets the selected objects of those kinds, in paint order among the paths
and under the same rules (hidden and locked ones, type that clips a clip group and anything inside a symbol, a placed
document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each
has `id`, `type`, `name` (when it has one), `bounds` (`null` until the object has been laid out or drawn) and
`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document.
In this example the character and paragraph styles are cut short; a filter gets every attribute:
```json
{"objects": [
{"id": 17, "type": "type", "bounds": [10, 38, 52, 52], "transform": [1, 0, 0, 1, 10, 50],
"text": {"kind": "point", "vertical": false,
"runs": [{"text": "Hello\nthere", "style": {"font_family": "Source Sans 3", "size": 12}}],
"paragraphs": [{"justify": "Left"}, {"justify": "Center"}]},
"truncated": false},
{"id": 18, "type": "image", "bounds": [0, 0, 4, 2], "transform": [2, 0, 0, 2, 0, 0], "pixelSize": [2, 1],
"colorMode": "rgb", "linked": false, "pixelsOmitted": false, "pixelsBase64": "AAAegGQAHoA="},
{"id": 19, "type": "symbolInstance", "bounds": [100, 0, 110, 10], "transform": [1, 0, 0, 1, 100, 0],
"symbolId": "Star", "overrides": {}}
]}
```
- Type (`"type"`): `text.kind` is `"point"`, `"area"` or `"path"`; `runs` are its text runs with their character
attributes as the native file stores them (`inline` when a run is an inline graphic), and `paragraphs` holds one
paragraph style per paragraph (the paragraphs are split at `\n`). No font data or glyph outlines are given. Type
whose runs hold more than 256 KiB of UTF-8 text, or more than 4,096 runs or paragraphs, comes with `truncated:
true` and empty `runs` and `paragraphs`.
- An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or
`"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443
pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and
`pixelsOmitted: false`. A larger one, and a linked image (`linked: true`), has `pixelsOmitted: true` and no pixels.
The host never reads a linked image's file for a filter and never gives its path.
- A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the
document has no symbol of that name. `overrides` is `{}`.
These objects are read only. The output must list each of them exactly once, as `{"id": 17}` alone or exactly as it
was given (numbers compare by value, so `1` and `1.0` are the same). A changed or added field, one left out, an object
listed twice or left out, and a new object with one of these types are refused, and the document and undo history
stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only
object was left out`. New objects listed after one of them go just above it. Their text and pixels count towards the
input's half of the memory cap, and an echo of them towards the 64 MiB output cap.
### Parameters
`params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_context`:
@@ -420,6 +472,6 @@ per change of their input, parameters or paints (results are cached), not per fr
- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, 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.
- Live effects that change paint (they reshape geometry; an object filter recolours), and filters that change type,
images or symbol instances (a filter at `api` 2 reads them, see [`inputs`](#reading-type-images-and-symbols)).
- Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet.
+1
View File
@@ -1488,6 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged.
- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs), images with their size, color mode and, when embedded and at most 1 MiB in base64, their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.