Merge pull request #6223 from dotCooCoo/feat/plugin-filter-inputs

Plug-ins: filters can ask to read type, images and symbol instances
This commit is contained in:
BFlat
2026-11-09 12:09:05 +08:00
committed by GitHub
37 changed files with 1507 additions and 41 deletions
Generated
+2
View File
@@ -6651,6 +6651,8 @@ dependencies = [
name = "vectorcraft-plugins"
version = "0.21.0"
dependencies = [
"craft-vectorcraft-format",
"image",
"log",
"serde",
"serde_json",
+25 -9
View File
@@ -36,7 +36,12 @@
//! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]):
//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size
//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them.
//!
//! 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, RwLock};
use serde_json::{Map, Value, json};
@@ -75,6 +80,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);
}
if m.paint_output {
v["paintOutput"] = json!(true);
}
@@ -126,6 +134,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),
@@ -192,8 +203,10 @@ impl FilterRun {
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());
@@ -206,10 +219,12 @@ impl FilterRun {
params["_context"]["mode"] = json!("command");
params["_context"]["command"] = json!(cmd.id);
}
let input = objects::encode_input(&st.doc, &inputs);
let params = serde_json::to_vec(&params).map_err(|e| bad(c, e.to_string()))?;
// The input and the parameters may use half the plug-in's memory together.
let capacity = (plugin.limits().max_memory_bytes / 2).min(i32::MAX as usize).saturating_sub(params.len());
let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds, capacity).map_err(err)?;
let document = (st.uid, st.revision);
Ok(FilterRun { plugin, command, input, params, inputs, parent, document })
Ok(FilterRun { plugin, command, input, params, inputs, read_only, parent, document })
}
/// The plug-in, for its name and budget.
@@ -249,12 +264,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));
}
@@ -874,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, 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; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, 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; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
always,
list
),
@@ -892,7 +908,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?: [object id] (left out or null: 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?: [object id] (left out or null: 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, compound paths that clip a clip group and paths inside a group that clips one 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; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: 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
),
+61
View File
@@ -1542,6 +1542,67 @@ 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. Installing one that lists
/// `inputs` needs the user's grant.
#[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,"permissions":["doc.read","doc.write"],"inputs":["type"]}}"#);
// Each case installs another module under the same id, in place of the last (`replace: true`).
let install_wat = |s: &mut Session, src: &str| {
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write"], "replace": true}))
};
let echo = "test.engine.inputs.echo";
let module = tpl::module(&reads(echo), tpl::ECHO, "", 1);
let data = vectorcraft_format::base64_encode(&wat::parse_str(&module).unwrap());
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
assert!(e.contains("asks for the permissions doc.read, doc.write"), "{e}");
let listed = install_wat(&mut s, &module);
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
+58
View File
@@ -824,6 +824,64 @@ proptest! {
}
}
/// 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, whose `permissions` list `doc.read` so that installing
/// it asks for approval. 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!([])), 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 reads = man.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == "doc.read"));
prop_assert!(kinds.is_empty() || (man.asks_permissions() && reads), "{}", 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");
}
/// A plug-in that calls the storage imports with hostile keys (`..`, separators, device names,
/// control characters, too long), values, lengths and pointers in and out of its memory runs to
/// an output, never a panic; every call returns a length or a storage code, and the settings and
+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
@@ -18,7 +18,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, and at `api` 2 may paint what it returns
@@ -49,7 +50,7 @@ pub mod wat;
pub use dialog::ParamUi;
pub use host::{LogLevel, LogLine, Report};
pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec};
pub use manifest::{Budget, Command, 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
+132 -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.
@@ -54,6 +54,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")]
@@ -282,6 +315,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 whose `permissions` list `doc.read`
/// 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)>,
@@ -327,6 +365,7 @@ impl Manifest {
author: String::new(),
api: 1,
permissions: None,
inputs: Vec::new(),
params: Vec::new(),
extensions: Vec::new(),
budget: Budget::Light,
@@ -370,6 +409,7 @@ impl Manifest {
let permission = host.name();
return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}")));
}
self.validate_inputs()?;
if self.paint_output && (self.kind != Kind::Effect || self.api < 2) {
return Err(Error::Manifest("`paintOutput` is only for a live effect (`kind` \"effect\") at `api` 2".into()));
}
@@ -405,6 +445,34 @@ impl Manifest {
Ok(())
}
/// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists
/// any, with a `permissions` list that holds `doc.read`: installing it then asks the user to
/// grant them ([`Manifest::asks_permissions`]).
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(())
}
/// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct
/// id, a printable label and parameters declared as the manifest's own are. A heavy filter
/// lists none. The error names the command: its id, or its place in the list.
@@ -446,6 +514,11 @@ impl Manifest {
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 or an importer 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<()> {
@@ -604,6 +677,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()),
}
}
/// Reads the `commands` list one entry at a time from the manifest itself, so each command's
/// `params` keep their declaration order and a repeated parameter is refused. A malformed entry
/// is an error that names it (its id when the id comes before the fault, otherwise its place in
@@ -940,6 +1024,53 @@ mod tests {
}
}
/// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once,
/// with a `permissions` list holding `doc.read`, so installing it asks for approval. 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,"permissions":["doc.read","doc.write"],"inputs":["type","image","symbolInstance"]}"#,
)
.unwrap();
assert_eq!(m.input_kinds(), InputKind::ALL);
assert!(m.asks_permissions());
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]);
assert!(some.asks_permissions());
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":"filter","api":2,"inputs":["type"]}"#, "`inputs` needs the permission `doc.read`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":[],"inputs":["image"]}"#, "`inputs` 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"));
}
/// A live effect at `api` 2 may declare `"paintOutput": true`, and only that value; any other
/// kind or level, and `false`, `null` or another type, is refused naming the key. Without the
/// key, at any level, the effect returns geometry only.
+504 -21
View File
@@ -6,14 +6,19 @@
//! in-range coordinates, capped counts, clamped colours) before it reaches the document.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Cms, CmykLut, lab};
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::cmyk::oversized_tiff;
use vectorcraft_doc::{
Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes,
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 +37,20 @@ 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;
/// Most pixels a filter is given over all the images of one input, in characters of base64: an
/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`.
pub const MAX_INPUT_PIXELS_TOTAL: usize = 16 << 20;
/// Most text a filter is given over all the type of one input, in bytes of the JSON of their runs
/// and paragraphs: type that doesn't fit what the type before it left comes with `truncated`.
pub const MAX_INPUT_TEXT_TOTAL: usize = 16 << 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 +75,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 +180,350 @@ 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, &[], usize::MAX).map(|(bytes, _)| bytes).unwrap_or_default()
}
/// [`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, within [`MAX_INPUT_PIXELS_TOTAL`] and
/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order, and within `capacity`, the most bytes the
/// input may be. Every object counts toward `capacity` in order, ids, bounds, transforms and names
/// included: pixels or text that don't fit what is left are omitted (`pixelsOmitted`,
/// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`]
/// naming it and its size, a path or compound path that doesn't fit is the limit the runtime gives
/// an input past its share, and more than [`MAX_OBJECTS`] read-only objects, more than an output
/// may list, is an [`Error::Limit`] too. 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], capacity: usize) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL })
}
/// The input with no objects; each object adds its JSON and a comma.
const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}";
/// What an input's objects may still be given of its totals, as they are encoded in order:
/// characters of base64 for pixels, bytes of JSON for runs and paragraphs, and bytes of the whole
/// input.
#[derive(Clone, Copy, Debug)]
struct Allowance {
pixels: usize,
text: usize,
input: usize,
}
impl Allowance {
const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX };
}
/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map
/// it returns, and written into the input from there.
fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
let mut read_only = HashMap::new();
let symbols = doc.symbol_boxes();
let mut out = b"{\"objects\":[".to_vec();
let mut first = true;
for n in ids.iter().filter_map(|id| doc.node(*id)) {
let path;
let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) {
// The output must return every read-only object, and lists at most `MAX_OBJECTS`.
Some(_) if read_only.len() >= MAX_OBJECTS => return Err(too_many_read_only()),
Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left)? {
Some(o) => (read_only.entry(n.id).insert_entry(o).into_mut(), true),
None => continue,
},
None => match encode(n) {
Some(o) => {
path = o;
(&path, false)
}
None => continue,
},
};
let start = out.len();
if !std::mem::take(&mut first) {
out.push(b',');
}
if serde_json::to_writer(&mut out, o).is_err() {
return Ok((Vec::new(), HashMap::new()));
}
let size = out.len() - start;
// A path or compound path past what is left fails the run as the runtime would fail it,
// before the rest of the selection is written.
if size > left.input {
return Err(if checked { too_large(n, size, left.input) } else { Error::Limit(crate::runtime::SELECTION_TOO_LARGE.into()) });
}
left.input -= size;
}
out.extend_from_slice(b"]}");
Ok((out, read_only))
}
/// The error for read-only object `n`, which needs `size` bytes of input where `left` are left.
fn too_large(n: &Node, size: usize, left: usize) -> Error {
Error::Limit(format!("memory budget (object {} needs {size} bytes of input, {left} left)", n.id.0))
}
/// The error for an input with more read-only objects than an output may list.
fn too_many_read_only() -> Error {
Error::Limit(format!("object budget (more than {MAX_OBJECTS} read-only objects)"))
}
/// 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 CMYK image's inks
/// through the working CMYK profile). 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 left = Allowance::FULL;
encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left).ok().flatten()
}
/// The bytes the `pixelsBase64` key adds to an image's JSON besides the base64 itself.
const PIXELS_KEY: usize = ",\"pixelsBase64\":\"\"".len();
/// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the
/// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that
/// don't fit what is left of their totals or of the input are omitted (`pixelsOmitted`,
/// `truncated`), and an image's size is checked against it before anything is decoded. The object's
/// other fields are measured first, with a comma before it: when they alone don't fit what is left
/// of the input, it is an [`Error::Limit`] naming the object and that size.
fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Result<Option<Value>> {
let Some(kind) = input_kind(n) else { return Ok(None) };
let mut o = json!({"id": n.id.0, "type": kind.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))});
// Each flag is measured as `false`, its longer value.
match &n.kind {
NodeKind::Text(t) => {
o["transform"] = affine_json(t.xf);
o["text"] = text_json(t, None).0;
o["truncated"] = json!(false);
}
NodeKind::Image(im) => {
o["transform"] = affine_json(im.xf);
o["pixelSize"] = json!([im.width, im.height]);
o["colorMode"] = json!(doc.image_blob(im).map_or("unknown", color_mode));
o["linked"] = json!(im.link.is_some());
o["pixelsOmitted"] = json!(false);
}
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 Ok(None),
}
if let Some(name) = &n.name {
o["name"] = json!(name);
}
let fields = serde_json::to_vec(&o).map_or(usize::MAX, |b| b.len()).saturating_add(1);
if fields > left.input {
return Err(too_large(n, fields, left.input));
}
let room = left.input - fields;
match &n.kind {
NodeKind::Text(t) => {
let mut budget = left.text.min(room);
let (text, truncated) = text_json(t, Some(&mut budget));
left.text -= left.text.min(room) - budget;
o["text"] = text;
o["truncated"] = json!(truncated);
}
NodeKind::Image(im) => {
let blob = doc.image_blob(im);
let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY));
// A linked image's pixels are its file's: never given (nor its path).
let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max));
o["pixelsOmitted"] = json!(pixels.is_none());
if let Some(p) = pixels {
let b64 = craft_vectorcraft_format::base64::encode(&p);
left.pixels = left.pixels.saturating_sub(b64.len());
o["pixelsBase64"] = json!(b64);
}
}
_ => {}
}
Ok(Some(o))
}
/// Counts the bytes written to it and fails past `left`, so nothing is held to find a size.
struct Measure {
bytes: usize,
left: usize,
}
impl std::io::Write for Measure {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.bytes = self.bytes.saturating_add(buf.len());
if self.bytes > self.left { Err(std::io::ErrorKind::OutOfMemory.into()) } else { Ok(buf.len()) }
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// Adds `item` as JSON to `list` when it fits what is left of `limit` after `used`, counting the
/// comma before it; `false`, with nothing built, when it doesn't.
fn push_within<T: serde::Serialize>(item: &T, list: &mut Vec<Value>, used: &mut usize, limit: usize) -> bool {
let comma = usize::from(!list.is_empty());
let Some(left) = limit.checked_sub(used.saturating_add(comma)) else { return false };
if serde_json::to_writer(Measure { bytes: 0, left }, item).is_err() {
return false;
}
let Ok(value) = serde_json::to_value(item) else { return false };
// A `Value` writes a number in its widest form, so the bytes given are counted from it.
let mut measure = Measure { bytes: 0, left };
if serde_json::to_writer(&mut measure, &value).is_err() {
return false;
}
*used += comma + measure.bytes;
list.push(value);
true
}
/// The text of type `t` as a filter reads it, and whether it was too long to give
/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives
/// is taken from, with the commas between them). With `left` `None`, its kind and direction with no
/// runs or paragraphs.
fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) {
let kind = match t.kind {
TextKind::Point => "point",
TextKind::Area { .. } => "area",
TextKind::OnPath { .. } => "path",
};
let (mut runs, mut paras): (Vec<Value>, Vec<Value>) = (Vec::new(), Vec::new());
let mut truncated = false;
if let Some(left) = left {
let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len()));
let paragraphs = t.paragraph_count();
// The text alone is never more bytes than its JSON, so type that can't fit isn't built.
truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS;
if !truncated {
// Each run and paragraph is measured before it is built, with the comma before it, so
// no more than `left` is held before the type is found too long.
let (limit, mut used) = (*left, 0usize);
let fits = t.runs.iter().all(|r| push_within(r, &mut runs, &mut used, limit))
&& (0..paragraphs).all(|i| push_within(&t.para_at(i), &mut paras, &mut used, limit));
if fits {
*left -= used;
} else {
(runs, paras, truncated) = (Vec::new(), Vec::new(), true);
}
}
}
(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, is a TIFF too large for the decoder to count, or only a
/// linked image's preview is there.
fn color_mode(b: &ImageBlob) -> &'static str {
if oversized_tiff(&b.bytes).is_some() {
return "unknown";
}
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`
/// characters of base64; the size is checked, and read from the header, before anything is decoded.
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> {
let bytes = u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)?;
if bytes == 0 || bytes.div_ceil(3).saturating_mul(4) > max as u64 || oversized_tiff(&b.bytes).is_some() {
return None;
}
if b.is_cmyk() {
return cmyk_rgba(b, w, h);
}
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())
}
/// The inks → XYZ table of [`cmyk_rgba`] and the color settings it was built with.
static CMYK_DISPLAY: Mutex<Option<(Arc<Cms>, Arc<CmykLut>)>> = Mutex::new(None);
/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels (checked from its
/// header first): its inks shown through the working CMYK profile, by a table built as
/// `vectorcraft-render`'s `InkLut` builds its own and kept for the active color settings.
fn cmyk_rgba(b: &ImageBlob, w: u32, h: u32) -> Option<Vec<u8>> {
if b.header_size()? != (w, h) {
return None;
}
let inks = b.cmyk().filter(|i| (i.width, i.height) == (w, h))?;
let lut = {
let cms = cms::active();
let mut slot = CMYK_DISPLAY.lock().unwrap_or_else(|e| e.into_inner());
match &*slot {
Some((owner, lut)) if Arc::ptr_eq(owner, &cms) => lut.clone(),
_ => {
let lut = Arc::new(CmykLut::build(|c| {
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(c));
[x, y, z, 0.0]
}));
*slot = Some((cms, lut.clone()));
lut
}
}
};
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let mut out = Vec::with_capacity(inks.data.len());
// Runs of one color convert once.
let mut last: Option<([u8; 4], [u8; 4])> = None;
for p in inks.data.as_chunks::<4>().0 {
let rgba = match last {
Some((k, v)) if k == *p => v,
_ => {
let [x, y, z, _] = lut.apply(p.map(|v| f32::from(v) / 255.0));
let [r, g, b] = lab::xyz_to_srgb([x, y, z]).map(q);
last = Some((*p, [r, g, b, 255]));
[r, g, b, 255]
}
};
out.extend_from_slice(&rgba);
}
Some(out)
}
pub(crate) fn out_err(at: &str, msg: impl std::fmt::Display) -> Error {
@@ -397,8 +761,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 +789,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 +803,91 @@ 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)| match output_id(o).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)
}
/// The id entry `o` gives, a whole number written either way (`17` or `17.0`).
fn output_id(o: &Value) -> Option<NodeId> {
let id = o.get("id")?;
id.as_u64().or_else(|| id.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=9_007_199_254_740_992.0).contains(f)).map(|f| f as u64)).map(NodeId)
}
/// 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, shown as
/// [`crate::permissions::display_text`] shows a manifest string, since the entry's keys are the
/// plug-in's.
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) => {
let field = crate::permissions::display_text(&field, crate::permissions::MAX_SHOWN_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,
@@ -622,25 +1079,39 @@ pub fn selectable(n: &Node) -> bool {
/// The paths and compound paths a filter reads from the selection `selected`, in paint order:
/// selected ones, and those inside selected groups and layers; hidden, locked, guide (compound
/// guides too) and clipping paths are left out (so are paths inside compound paths, blends and
/// other live objects).
/// guides too) and clipping paths are left out, and so are compound paths that clip a clip group
/// and paths inside a group that clips one (and 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 (objects that clip a clip
/// group are left out, inside a group that clips one too, 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> {
/// `source`: `list` is inside the clipping object of a clip group.
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, clips: bool, source: 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);
let clipping = source || (clips && i == 0);
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::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !clipping && !is_guide(n) => {
out.push(n.id)
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), clipping, kinds, out),
_ if picked && !clipping && 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, false, kinds, &mut out);
out
}
@@ -740,7 +1211,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 +1226,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 +1254,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;
+470
View File
@@ -310,6 +310,476 @@ 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, &[], usize::MAX).unwrap();
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], usize::MAX).unwrap();
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");
// A size whose pixel count overflows is omitted too.
let n = image_node(&mut d, "e", (u32::MAX, u32::MAX), 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), "too large to count");
// 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 CMYK image's pixels are its inks as the working CMYK profile shows them on the canvas: black
/// ink alone is the profile's black, not RGB black.
#[test]
fn cmyk_image_pixels_go_through_the_working_cmyk_profile() {
use vectorcraft_color::cms::{self, lab};
let (mut d, _, layer, _) = mixed();
let inks = vectorcraft_doc::cmyk::Inks::new(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 0]).unwrap();
d.images.insert("k".into(), ImageBlob::cmyk_tiff(&inks).unwrap());
let n = image_node(&mut d, "k", (2, 1), 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["colorMode"], &o["pixelsOmitted"]), (&json!("cmyk"), &json!(false)));
let pixels = craft_vectorcraft_format::base64::decode(o["pixelsBase64"].as_str().unwrap()).unwrap();
let shown = |c: [f32; 4]| lab::xyz_to_srgb(lab::lab_to_xyz(cms::active().cmyk_display_lab(c))).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
let ([r, g, b], [r2, g2, b2]) = (shown([0.0, 0.0, 0.0, 1.0]), shown([0.0; 4]));
assert_eq!(pixels, [r, g, b, 255, r2, g2, b2, 255]);
assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile");
}
/// 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)));
}
/// Pixels and type count against totals over the whole input, in order: an image whose pixels
/// don't fit what the images before it left comes with `pixelsOmitted` (its size checked before it
/// is decoded), and type whose runs and paragraphs don't fit comes with `truncated`. The read-only
/// objects returned are the ones in the input.
#[test]
fn input_totals_cover_images_and_type_in_order() {
let (mut d, _, layer, [text, embedded, ..]) = mixed();
let copy = |d: &mut Document, id: NodeId| {
let mut n = d.node(id).unwrap().clone();
n.id = d.alloc_id();
d.insert(Some(layer), usize::MAX, n).unwrap()
};
let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded));
let one = encode_read_only(&d, d.node(text).unwrap()).unwrap();
// The runs and paragraphs with the commas between them, without their brackets.
let size: usize = ["runs", "paragraphs"].iter().map(|k| one["text"][k].to_string().len() - 2).sum();
// A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't.
let ids = [text, text2, embedded, image2, image3];
let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size, input: usize::MAX }).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let objects = v["objects"].as_array().unwrap();
assert_eq!(objects.len(), 5);
let flags: Vec<&Value> = objects.iter().map(|o| if o["type"] == "type" { &o["truncated"] } else { &o["pixelsOmitted"] }).collect();
assert_eq!(flags, [&json!(false), &json!(true), &json!(false), &json!(false), &json!(true)]);
assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none());
assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]);
for o in objects {
assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap())));
}
// Within the full totals every one of them is given.
let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap();
let all = String::from_utf8(bytes).unwrap();
assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}");
}
/// Every object counts toward the input's capacity in order, its id, bounds, transform and name
/// included: a symbol instance before type leaves the type less room, so text that fits the exact
/// size of the whole input comes with `truncated` one byte under it, and an image's pixels are
/// omitted the same way.
#[test]
fn object_fields_count_toward_the_input_capacity() {
let (d, ids, _, [text, embedded, _, star, _]) = mixed();
for (order, flag) in [([ids[0], star, text], "truncated"), ([ids[0], star, embedded], "pixelsOmitted")] {
let (bytes, _) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap();
let (exact, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len()).unwrap();
assert_eq!(exact, bytes, "{flag}: the exact size fits");
let (under, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len() - 1).unwrap();
let v: Value = serde_json::from_slice(&under).unwrap();
assert_eq!(v["objects"][2][flag], json!(true), "{flag}: one byte under");
assert!(under.len() < bytes.len() - 1, "{flag}");
}
}
/// Type with fractional colors counts the bytes the input gives it: one byte under its exact size
/// it comes with `truncated`, not a limit.
#[test]
fn fractional_colors_count_as_the_input_writes_them() {
use vectorcraft_doc::{CharStyle, TextObject};
let (mut d, ids, layer, _) = mixed();
let style = CharStyle { fill: Paint::solid(Color::rgb(0.1, 0.2, 0.3)), ..CharStyle::default() };
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Hi", style))));
let text = d.insert(Some(layer), usize::MAX, t).unwrap();
let order = [ids[0], text];
let (bytes, _) = encode_input_with(&d, &order, &[InputKind::Type], usize::MAX).unwrap();
let (under, _) = encode_input_with(&d, &order, &[InputKind::Type], bytes.len() - 1).unwrap();
let v: Value = serde_json::from_slice(&under).unwrap();
assert_eq!(v["objects"][1]["truncated"], json!(true));
}
/// A read-only object whose fields other than its pixels and text alone don't fit what is left of
/// the input fails the run with a limit naming it and its size, rather than being left out.
#[test]
fn an_object_too_large_to_give_is_a_limit_naming_it() {
let (mut d, ids, _, [_, _, linked, star, _]) = mixed();
d.node_mut(linked).unwrap().name = Some("n".repeat(4096));
let (fits, _) = encode_input_with(&d, &[ids[0], star], &InputKind::ALL, usize::MAX).unwrap();
let order = [ids[0], star, linked];
let e = encode_input_with(&d, &order, &InputKind::ALL, fits.len() + 4096).unwrap_err();
assert!(matches!(e, Error::Limit(_)), "{e}");
// Measured with `pixelsOmitted` false and a comma before it.
let mut o = encode_read_only(&d, d.node(linked).unwrap()).unwrap();
o["pixelsOmitted"] = json!(false);
let size = o.to_string().len() + 1;
assert_eq!(e.to_string(), format!("plug-in exceeded its memory budget (object {} needs {size} bytes of input, 4096 left)", linked.0));
// A filter that doesn't list its kind is given the rest.
let (bytes, read_only) = encode_input_with(&d, &order, &[InputKind::SymbolInstance], fits.len() + 4096).unwrap();
assert_eq!((bytes, read_only.len()), (fits, 1));
}
/// A path or compound path that doesn't fit what is left of the input fails the run with the limit
/// the runtime gives an input past its share, before anything after it is written; the exact size
/// still fits.
#[test]
fn a_path_past_the_input_capacity_is_the_runtime_limit() {
let (d, ids, _, [text, ..]) = mixed();
let (bytes, _) = encode_input_with(&d, &ids, &[], usize::MAX).unwrap();
assert_eq!(encode_input_with(&d, &ids, &[], bytes.len()).unwrap().0, bytes, "the exact size fits");
let e = encode_input_with(&d, &[ids[0], ids[1], ids[2], text], &[InputKind::Type], bytes.len() - 1).unwrap_err();
assert_eq!(e.to_string(), "plug-in exceeded its memory budget (the selection is too large to hand over)");
}
/// An output lists at most [`MAX_OBJECTS`] objects and must return every read-only one, so an input
/// with more read-only objects than that is a limit; exactly that many are given.
#[test]
fn more_read_only_objects_than_an_output_lists_is_a_limit() {
let (mut d, _, layer, [.., ghost]) = mixed();
let mut ids = vec![ghost];
for _ in 1..MAX_OBJECTS {
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
}
let (_, read_only) = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap();
assert_eq!(read_only.len(), MAX_OBJECTS);
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
let e = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap_err();
assert_eq!(e.to_string(), format!("plug-in exceeded its object budget (more than {MAX_OBJECTS} read-only objects)"));
// A filter that doesn't list the kind is given none of them.
assert!(encode_input_with(&d, &ids, &[], usize::MAX).unwrap().1.is_empty());
}
/// Objects that clip a clip group are left out, and so are those inside a group that clips one;
/// those it clips are given. This covers a filter that lists no `inputs` too.
#[test]
fn objects_that_clip_a_clip_group_are_not_inputs() {
let (mut d, _, layer, [text, embedded, ..]) = mixed();
let (t, e) = (Arc::new(d.node(text).unwrap().clone()), Arc::new(d.node(embedded).unwrap().clone()));
let mut copy = |n: &Arc<Node>| {
let mut c = (**n).clone();
c.id = d.alloc_id();
Arc::new(c)
};
let (t_source, e_source, t_inner, e_inner, t_clipped) = (copy(&t), copy(&e), copy(&t), copy(&e), copy(&t));
let inner = Node::group(d.alloc_id(), vec![t_inner.clone(), e_inner.clone()]);
let mut by_group = Node::group(d.alloc_id(), vec![Arc::new(inner), t_clipped.clone()]);
by_group.set_clips(true);
let mut by_type = Node::group(d.alloc_id(), vec![t_source.clone(), e_source.clone()]);
by_type.set_clips(true);
let by_group = d.insert(Some(layer), usize::MAX, by_group).unwrap();
let by_type = d.insert(Some(layer), usize::MAX, by_type).unwrap();
let path = |d: &mut Document, x: f64| Arc::new(Node::path(d.alloc_id(), square(x), Appearance::default()));
let (part, clipped_by_compound, in_source, clipped_by_paths) = (path(&mut d, 0.0), path(&mut d, 10.0), path(&mut d, 20.0), path(&mut d, 30.0));
let compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![part], rule: FillRule::NonZero });
let mut by_compound = Node::group(d.alloc_id(), vec![Arc::new(compound), clipped_by_compound.clone()]);
by_compound.set_clips(true);
let source = Node::group(d.alloc_id(), vec![in_source]);
let mut by_paths = Node::group(d.alloc_id(), vec![Arc::new(source), clipped_by_paths.clone()]);
by_paths.set_clips(true);
let by_compound = d.insert(Some(layer), usize::MAX, by_compound).unwrap();
let by_paths = d.insert(Some(layer), usize::MAX, by_paths).unwrap();
let ids = |list: &[NodeId]| filter_targets_with(&d, list, &InputKind::ALL);
assert_eq!(ids(&[by_type]), [e_source.id], "the type clips; the image is clipped");
assert_eq!(ids(&[by_group]), [t_clipped.id], "the group's type and image clip");
assert_eq!(ids(&[by_compound]), [clipped_by_compound.id], "the compound path clips");
assert_eq!(ids(&[by_paths]), [clipped_by_paths.id], "the group's path clips");
assert_eq!(filter_targets(&d, &[by_compound]), [clipped_by_compound.id], "a filter without inputs");
assert_eq!(filter_targets(&d, &[by_paths]), [clipped_by_paths.id], "a filter without inputs");
}
/// A TIFF with more strips and planes than the decoder can count comes as `"unknown"`, without its
/// pixels, and never reaches the decoder.
#[test]
fn a_tiff_too_large_to_count_is_unknown_and_has_no_pixels() {
let entries: [(u16, u16, u32); 9] =
[(256, 4, 1), (257, 4, 1 << 31 | 1), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
tiff.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
for (tag, ty, v) in entries {
tiff.extend(tag.to_le_bytes());
tiff.extend(ty.to_le_bytes());
tiff.extend(1u32.to_le_bytes());
tiff.extend(v.to_le_bytes());
}
tiff.extend([0; 4]);
let (mut d, _, layer, _) = mixed();
d.images.insert("big".into(), ImageBlob::new("image/tiff", tiff));
let big = image_node(&mut d, "big", (1, 1), None);
let big = d.insert(Some(layer), usize::MAX, big).unwrap();
let (bytes, _) = encode_input_with(&d, &[big], &[InputKind::Image], usize::MAX).unwrap();
let input: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(input["objects"][0]["colorMode"], "unknown");
assert_eq!(input["objects"][0]["pixelsOmitted"], true);
}
/// A field a plug-in adds to a read-only object is named in the refusal with its control and
/// direction characters escaped, and cut short.
#[test]
fn a_changed_field_is_named_escaped_and_short() {
let (d, _, layer, [text, ..]) = mixed();
let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]);
let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type], usize::MAX).unwrap();
let mut v: Value = serde_json::from_slice(&bytes).unwrap();
let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap();
let key = format!("\u{202E}{}", "k".repeat(500));
v["objects"][at][key.as_str()] = json!(1);
let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string();
let shown = e.split('\'').nth(1).unwrap_or_default();
assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}");
assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}");
}
/// 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, usize::MAX).unwrap();
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());
// So is an id written as a float; a fractional one names no object.
let id = input["objects"][3]["id"].as_u64().unwrap();
let mut echo = input.clone();
echo["objects"][3]["id"] = json!(id as f64);
let out = decode_output_with(echo.to_string().as_bytes(), &read_only).unwrap();
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
echo["objects"][3]["id"] = json!(id as f64 + 0.5);
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_err());
// `{"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
@@ -22,6 +22,9 @@ pub const API_LEVEL: u32 = 2;
/// `settings` and `cache` permissions 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;
+4 -1
View File
@@ -32,6 +32,9 @@ pub const HEAVY_FUEL: u64 = 1_000_000_000;
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
/// The limit a run reports when its input and parameters together are past half its memory.
pub(crate) const SELECTION_TOO_LARGE: &str = "memory budget (the selection is too large to hand over)";
/// A heavy run's budget as people read it: (minutes it may take, times the instructions a light
/// run gets).
pub fn heavy_terms() -> (u64, u64) {
@@ -311,7 +314,7 @@ impl Plugin {
fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result<Vec<u8>> {
let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2);
total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?;
total.ok_or_else(|| Error::Limit(SELECTION_TOO_LARGE.into()))?;
let deadline = Deadline::new(limits.wall_time_ms);
let m = &self.manifest;
let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id));
+1
View File
@@ -654,6 +654,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(),
+7
View File
@@ -5955,6 +5955,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) pressupost de memòria (l’objecte {_1} necessita {size} bytes d’entrada, en queden {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) pressupost d’objectes (més de {MAX_OBJECTS} objectes de només lectura)
@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.
+7
View File
@@ -5947,6 +5947,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) rozpočet paměti (objekt {_1} potřebuje {size} bajtů vstupu, zbývá {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) rozpočet objektů (více než {MAX_OBJECTS} objektů jen pro čtení)
@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.
+7
View File
@@ -5530,6 +5530,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) Speicherbudget (Objekt {_1} benötigt {size} Byte Eingabe, {left} verbleiben)
@msg object budget (more than {MAX_OBJECTS} read-only objects) Objektbudget (mehr als {MAX_OBJECTS} schreibgeschützte Objekte)
@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.
+7
View File
@@ -5898,6 +5898,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) προϋπολογισμός μνήμης (το αντικείμενο {_1} χρειάζεται {size} byte εισόδου, απομένουν {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) προϋπολογισμός αντικειμένων (περισσότερα από {MAX_OBJECTS} αντικείμενα μόνο για ανάγνωση)
@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.
+7
View File
@@ -5889,6 +5889,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) presupuesto de memoria (el objeto {_1} necesita {size} bytes de entrada, quedan {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) presupuesto de objetos (más de {MAX_OBJECTS} objetos de solo lectura)
@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.
+7
View File
@@ -5977,6 +5977,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) muistibudjetti (objekti {_1} tarvitsee {size} tavua syötettä, jäljellä {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) objektibudjetti (yli {MAX_OBJECTS} vain luku -objektia)
@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.
+7
View File
@@ -5943,6 +5943,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) budget mémoire (l’objet {_1} nécessite {size} octets d’entrée, il en reste {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) budget d’objets (plus de {MAX_OBJECTS} objets en lecture seule)
@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.
+7
View File
@@ -5286,6 +5286,13 @@ antiAlias Sharp शार्प
@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 memory budget (object {_1} needs {size} bytes of input, {left} left) मेमोरी बजट (ऑब्जेक्ट {_1} को इनपुट के {size} बाइट चाहिए, {left} शेष हैं)
@msg object budget (more than {MAX_OBJECTS} read-only objects) ऑब्जेक्ट बजट ({MAX_OBJECTS} से अधिक केवल-पठन ऑब्जेक्ट)
@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.
+7
View File
@@ -5930,6 +5930,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) memóriakeret (a(z) {_1} objektumnak {size} bájt bemenet kell, {left} maradt)
@msg object budget (more than {MAX_OBJECTS} read-only objects) objektumkeret (több mint {MAX_OBJECTS} csak olvasható objektum)
@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.
+7
View File
@@ -5564,6 +5564,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) budget di memoria (l’oggetto {_1} richiede {size} byte di input, ne restano {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) budget di oggetti (più di {MAX_OBJECTS} oggetti di sola lettura)
@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.
+7
View File
@@ -5629,6 +5629,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) メモリ予算 (オブジェクト {_1} には {size} バイトの入力が必要ですが、残りは {left} です)
@msg object budget (more than {MAX_OBJECTS} read-only objects) オブジェクト予算 ({MAX_OBJECTS} 個を超える読み取り専用オブジェクト)
@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.
+7
View File
@@ -5870,6 +5870,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) მეხსიერების ბიუჯეტი (ობიექტს {_1} სჭირდება შეყვანის {size} ბაიტი, დარჩენილია {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) ობიექტების ბიუჯეტი ({MAX_OBJECTS}-ზე მეტი მხოლოდ წასაკითხი ობიექტი)
@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.
+7
View File
@@ -5525,6 +5525,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) 메모리 예산(개체 {_1}에 입력 {size}바이트가 필요하지만 {left}바이트 남음)
@msg object budget (more than {MAX_OBJECTS} read-only objects) 개체 예산(읽기 전용 개체 {MAX_OBJECTS}개 초과)
@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.
+7
View File
@@ -5801,6 +5801,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) budżet pamięci (obiekt {_1} wymaga {size} bajtów danych wejściowych, pozostało {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) budżet obiektów (ponad {MAX_OBJECTS} obiektów tylko do odczytu)
@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.
+7
View File
@@ -5862,6 +5862,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) orçamento de memória (o objeto {_1} precisa de {size} bytes de entrada, restam {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) orçamento de objetos (mais de {MAX_OBJECTS} objetos somente leitura)
@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.
+7
View File
@@ -5558,6 +5558,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) бюджет памяти (объекту {_1} нужно {size} байт входных данных, осталось {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет объектов (более {MAX_OBJECTS} объектов только для чтения)
@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.
+7
View File
@@ -5970,6 +5970,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) minnesbudget (objekt {_1} behöver {size} byte indata, {left} återstår)
@msg object budget (more than {MAX_OBJECTS} read-only objects) objektbudget (fler än {MAX_OBJECTS} skrivskyddade objekt)
@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.
+7
View File
@@ -5903,6 +5903,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) bellek bütçesi (nesne {_1} için {size} bayt girdi gerekiyor, {left} kaldı)
@msg object budget (more than {MAX_OBJECTS} read-only objects) nesne bütçesi ({MAX_OBJECTS} adetten fazla salt okunur nesne)
@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.
+7
View File
@@ -5544,6 +5544,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) бюджет пам’яті (об’єкту {_1} потрібно {size} байтів вхідних даних, залишилося {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет об’єктів (понад {MAX_OBJECTS} об’єктів лише для читання)
@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.
+7
View File
@@ -5545,6 +5545,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) ngân sách bộ nhớ (đối tượng {_1} cần {size} byte đầu vào, còn lại {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) ngân sách đối tượng (hơn {MAX_OBJECTS} đối tượng chỉ đọc)
@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.
+7
View File
@@ -5785,6 +5785,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) 内存预算(对象 {_1} 需要 {size} 字节的输入,剩余 {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) 对象预算(超过 {MAX_OBJECTS} 个只读对象)
@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.
+7
View File
@@ -5582,6 +5582,13 @@ 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 memory budget (object {_1} needs {size} bytes of input, {left} left) 記憶體預算(物件 {_1} 需要 {size} 位元組的輸入,剩餘 {left})
@msg object budget (more than {MAX_OBJECTS} read-only objects) 物件預算(超過 {MAX_OBJECTS} 個唯讀物件)
@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.
+11 -1
View File
@@ -6642,7 +6642,17 @@ 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) also lists `doc.read` in its `permissions`, so
`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of
pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`). Every object counts
toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included: pixels and runs that
don't fit come the same way, and an object whose other fields don't fit makes `plugin.run` return a memory-budget error
naming the object and its size, with nothing changed. A path or compound path that doesn't fit makes it return the
memory-budget error `the selection is too large to hand over`, and more than 100,000 of these objects make it return
an object-budget error. 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. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api`
and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them
+75 -6
View File
@@ -224,9 +224,12 @@ 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) and a live effect's [`paintOutput`](#painted-live-effects). A higher one is
refused. `plugin.list` and `plugin.info` give it as `api`. A level 2 effect that declares `paintOutput` needs no
other permission than `doc.read` and `effect.render`.
imports](#host-imports-api-level-2), a filter's `inputs` ([reading type, images and
symbols](#reading-type-images-and-symbols)) and a live effect's [`paintOutput`](#painted-live-effects). 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 `inputs` must list in `permissions`, so installing it asks the
user to grant them. A level 2 effect that declares `paintOutput` needs no other permission than `doc.read` and
`effect.render`.
- Parameter declarations are unchanged by permissions (`params`, below).
Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its
@@ -379,6 +382,11 @@ A live effect runs while the art draws, so keep the entries it uses small.
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 without a `permissions` list that holds `doc.read`, is refused when the plug-in is
installed, and so is `api` 2 on hosts from before it.
- `paintOutput` (live effects at `api` 2 only): `true` makes the effect return [painted pieces](#painted-live-effects)
instead of geometry alone. `true` is the only value the key takes; `false`, `null`, another type, another kind or
`api` 1 is refused when the module is installed, and the error names `paintOutput`. Without the key the effect returns
@@ -429,8 +437,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, unless it declares `paintOutput` ([painted live
@@ -479,6 +488,65 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON),
`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, objects that clip a clip group, alone or inside a group that clips
it, 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`. The runs and paragraphs of all the type in one input take at most 16 MiB
of JSON: type whose runs and paragraphs don't fit what the type before it in paint order left comes with
`truncated: true` too.
- 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 CMYK image's pixels are its inks through the working CMYK profile. The pixels of all the
images in one input take at most 16 MiB in base64. A larger image, one whose pixels don't fit what the images before
it in paint order left, and a linked image (`linked: true`) have
`pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. 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`. The field is named with control and bidirectional characters escaped (`\u{202E}`), cut to 64
characters. New objects listed after one of them go just above it.
Every object in the input counts toward the half of the memory cap that the input and the parameters share, in paint
order, with its id, bounds, transform and name. Type whose runs and paragraphs don't fit what is left comes with
`truncated: true`, and an image whose pixels don't fit comes with `pixelsOmitted: true`. When the other fields of one
of these objects don't fit what is left, the run fails before the plug-in starts, naming the object and the bytes it
needs: `plug-in exceeded its memory budget (object 17 needs 4200 bytes of input, 1000 left)`. A path or compound path
that doesn't fit fails it with `plug-in exceeded its memory budget (the selection is too large to hand over)` before
the objects after it are written. An output lists at most 100,000 objects, so a run given more than 100,000 of these
objects fails with `plug-in exceeded its object budget (more than 100000 read-only objects)`. An echo of them counts
toward the 64 MiB output cap.
### Parameters
`params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_context`:
@@ -632,5 +700,6 @@ per change of their input, parameters or paints (results are cached), not per fr
importers that place files (File › Place reads only the built-in formats) or make type, images, layers or
artboards.
- Live effects that change opacity, blending or effects (at `api` 2 one may [paint](#painted-live-effects) the pieces
it returns), and filters on type, images or symbols.
it returns), 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
@@ -1571,6 +1571,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged.
- **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; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. 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.
- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, 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, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. 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, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. 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 grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.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.