From 68609fb43b66f401e9064c617aba5e2c0dcbb7b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 22:07:20 -0800 Subject: [PATCH] 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 --- Cargo.lock | 2 + crates/engine/src/cmd/plugin.rs | 32 ++- crates/engine/src/tests/plugins.rs | 50 +++++ crates/engine/tests/plugin_fuzz.rs | 55 ++++++ crates/plugins/Cargo.toml | 3 + crates/plugins/src/lib.rs | 5 +- crates/plugins/src/manifest.rs | 127 +++++++++++- crates/plugins/src/objects.rs | 297 ++++++++++++++++++++++++++-- crates/plugins/src/objects/tests.rs | 238 ++++++++++++++++++++++ crates/plugins/src/permissions.rs | 3 + crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + docs/mcp.md | 5 +- docs/plugins.md | 62 +++++- docs/target-app-parity.md | 1 + 36 files changed, 956 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72d591b91..025810f08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6422,6 +6422,8 @@ dependencies = [ name = "vectorcraft-plugins" version = "0.21.0" dependencies = [ + "craft-vectorcraft-format", + "image", "log", "serde", "serde_json", diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..316221488 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -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, /// The objects the plug-in is given, and the container new objects go in. inputs: Vec, + /// 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, parent: Option, /// 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 { 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 { "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.), format? and extensions? (an exporter's format id, plugin., 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.), format? and extensions? (an exporter's format id, plugin., 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 { "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 ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index fdfe60067..ee67fe603 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -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 diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1791555f7..5dc2e2a5e 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -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"); + } } diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 5e4117a18..7c18f2c06 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -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 } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 5e5da17b9..8bbc2906b 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -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 diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 36b4b78c4..0016c533c 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -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::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>, + /// 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, /// 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 { + 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, 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(p: &[(String, Param)], s: S) -> std::result::Result { 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( diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 037591f8b..9fa37ce54 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -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 = 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, /// Locks or unlocks the object (as Object › Lock and Unlock do). pub locked: Option, + /// 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 { /// 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 { - let objects: Vec = 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, HashMap) { + let mut read_only = HashMap::new(); + let objects: Vec = 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 { + 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 { + 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, Vec) = if truncated { + (Vec::new(), Vec::new()) + } else { + let value = |v: std::result::Result| 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> { + 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 { + 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) -> Result { 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 { /// declined (the message reaches the user). A live effect's other keys don't matter. pub fn decode(bytes: &[u8]) -> Result> { 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> { +/// 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) -> Result> { if let Some(e) = declined(v) { return Err(e); } @@ -430,7 +597,83 @@ fn decode_objects(v: &Value) -> Result> { 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 = 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::>>()?; + let mut missing: Vec = 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 { + 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 { - fn visit(list: &[Arc], sel: &HashSet, inside: bool, out: &mut Vec) { - 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 { + fn visit(list: &[Arc], sel: &HashSet, inside: bool, clips: bool, kinds: &[InputKind], out: &mut Vec) { + 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 = 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, 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, 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, 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; diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 74219a31b..48b0a38b3 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -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 { + 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; 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 = 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 = 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| { + 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] diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 083c78a04..c5959b8c0 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -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; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 51959c320..bc17b6d1b 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -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(), diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ea246b35c..a58a6c7ae 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e51de244..7f1ab772a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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í diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7d0e48fb8..6c7453f51 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index be001e2fb..036c75f2e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 930ecf090..578a8ae2f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 99594975d..ef71d5371 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1aebc348d..21173799a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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é diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0f212f4fd..806986bd6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -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` के साथ इंस्टॉल करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a400c495f..adfe48363 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b19e01f96..fcf5cef1f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 77d50e0a8..1407ca8b7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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` を指定してインストールしてください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c710ee3c5..649eeaf8e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -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` პარამეტრით, რომელშიც ისინი ჩამოთვლილია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c0545a3da..88a5e6fee 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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`로 설치하세요 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ff2b9bd6b..a788257b5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3340597d8..a2dc3ad53 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3732558f7..3e0425623 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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`, где они перечислены, когда пользователь согласится diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 58ceab701..aaefbc72d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9385b30cc..c037a53ea 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1b96c235e..f66048c92 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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`, де їх перелічено, коли користувач погодиться diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ce98bc978..fb8c36009 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 ý diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2cf40630..f449dc315 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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` 安装 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9440e225f..e5b3b5641 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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` 安裝 diff --git a/docs/mcp.md b/docs/mcp.md index 9220b1c62..d2606e316 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.`: `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.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..fb1c06a6e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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.` 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 31dc47a26..dab0ea86e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.