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