diff --git a/Cargo.lock b/Cargo.lock index 591389407..e9d3ad332 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6651,6 +6651,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 15f1cf6a0..c2736d74d 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -36,7 +36,12 @@ //! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]): //! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size //! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them. +//! +//! A filter whose manifest lists `inputs` (plug-in API level 2) is also given the selected type, +//! images or symbol instances of those kinds, to read only: its output must return each of them +//! unchanged ([`objects::decode_output_with`]). +use std::collections::HashMap; use std::sync::{Arc, Mutex, RwLock}; use serde_json::{Map, Value, json}; @@ -75,6 +80,9 @@ pub fn describe(p: &Plugin) -> Value { if m.budget == Budget::Heavy { v["budget"] = json!("heavy"); } + if !m.inputs.is_empty() { + v["inputs"] = json!(m.inputs); + } if m.paint_output { v["paintOutput"] = json!(true); } @@ -126,6 +134,9 @@ pub struct FilterRun { params: Vec, /// 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), @@ -192,8 +203,10 @@ impl FilterRun { st.selection.objects.clone() } }; - // Without `doc.read` a filter is given no objects: it can only add new ones. - let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; + // Without `doc.read` a filter is given no objects: it can only add new ones. Type, images + // and symbol instances only when its manifest asks for them (`inputs`). + let kinds = m.input_kinds(); + let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets_with(&st.doc, &selected, &kinds) } else { Vec::new() }; let parent = st.insertion_parent(); let bounds = st.doc.bounds_of(&inputs, false); let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); @@ -206,10 +219,12 @@ impl FilterRun { params["_context"]["mode"] = json!("command"); params["_context"]["command"] = json!(cmd.id); } - let input = objects::encode_input(&st.doc, &inputs); let params = serde_json::to_vec(¶ms).map_err(|e| bad(c, e.to_string()))?; + // The input and the parameters may use half the plug-in's memory together. + let capacity = (plugin.limits().max_memory_bytes / 2).min(i32::MAX as usize).saturating_sub(params.len()); + let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds, capacity).map_err(err)?; let document = (st.uid, st.revision); - Ok(FilterRun { plugin, command, input, params, inputs, parent, document }) + Ok(FilterRun { plugin, command, input, params, inputs, read_only, parent, document }) } /// The plug-in, for its name and budget. @@ -249,12 +264,13 @@ impl FilterRun { } /// Applies the plug-in's `output` to the active document as one undo step: its objects, and what - /// it keeps with the document from now on. An output its permissions don't allow is refused, - /// and the document stays as it was. + /// it keeps with the document from now on. An output its permissions don't allow, and one that + /// changes or leaves out a read-only object it was given, is refused, and the document stays as + /// it was. fn finish(self, s: &mut Session, output: &[u8]) -> Result { let m = self.plugin.manifest(); let may = m.permissions(); - let out = objects::decode_output(output).map_err(err)?; + let out = objects::decode_output_with(output, &self.read_only).map_err(err)?; if out.document_data.is_some() && !may.contains(&Permission::State) { return Err(not_granted(&m.name, Permission::State)); } @@ -874,7 +890,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -892,7 +908,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 57545015c..803db493e 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1542,6 +1542,67 @@ fn a_filter_does_only_what_its_permissions_allow() { assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1}))); } +/// A filter whose manifest lists `inputs` is also given the selected type, read-only: an echo +/// changes nothing, and an output that leaves the type out or changes it is refused with the +/// document and undo history as they were, handed in from a run made elsewhere too. A filter +/// without `inputs` is not given the type and so never deletes it. Installing one that lists +/// `inputs` needs the user's grant. +#[test] +fn read_only_kind_field_and_omission_refuse_without_undo() { + use cmd::plugin::FilterRun; + let mut s = session(); + let r = rect(&mut s, 10.0, "#ff0000"); + let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap()); + let reads = + |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"]}}"#); + // Each case installs another module under the same id, in place of the last (`replace: true`). + let install_wat = |s: &mut Session, src: &str| { + let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write"], "replace": true})) + }; + let echo = "test.engine.inputs.echo"; + let module = tpl::module(&reads(echo), tpl::ECHO, "", 1); + let data = vectorcraft_format::base64_encode(&wat::parse_str(&module).unwrap()); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read, doc.write"), "{e}"); + let listed = install_wat(&mut s, &module); + assert_eq!(listed["inputs"], json!(["type"])); + let p = |id: &str| json!({"id": id, "ids": [r.0, t.0]}); + let before = s.doc().unwrap().doc.clone(); + assert_eq!(run(&mut s, "plugin.run", p(echo))["ids"], json!([r.0, t.0])); + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "an echo changes nothing"); + for (output, says) in [ + (json!({"objects": [{"id": r.0}]}), format!("objects (id {}): a read-only object was left out", t.0)), + (json!({"objects": [{"id": r.0}, {"id": t.0, "name": "Renamed"}]}), format!("objects[1] (id {}): read-only field 'name' changed", t.0)), + (json!({"objects": [{"id": r.0}, {"id": t.0}, {"type": "type", "path": triangle()}]}), "`type` must be \"path\" or \"compound\"".into()), + ] { + let id = "test.engine.inputs.bad"; + install_wat(&mut s, &tpl::returning(&reads(id), &output.to_string())); + let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string(); + assert!(e.contains(&says), "{says}: {e}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "{output}"); + // Handed in from a run made away from plugin.run, it is refused the same way. + let made = FilterRun::prepare(&s, &p(id)).unwrap(); + let out = made.run().unwrap(); + made.hand_in(&mut s, out); + let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string(); + assert!(e.contains(&says), "handed in: {e}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "handed in: {output}"); + } + // Without `inputs` the type isn't given, so leaving it out keeps it. + let legacy = "test.engine.inputs.legacy"; + install_wat(&mut s, &tpl::returning(&tpl::manifest(legacy, "filter", "{}"), &json!({"objects": [{"id": r.0, "name": "Kept"}]}).to_string())); + assert_eq!(run(&mut s, "plugin.run", p(legacy))["ids"], json!([r.0])); + assert_eq!(node(&s, r).name.as_deref(), Some("Kept")); + assert!(matches!(node(&s, t).kind, vectorcraft_doc::NodeKind::Text(_)), "the type stays"); + // A manifest that lists `inputs` below `api` 2 doesn't install. + let old = format!(r#"{{"id":"{echo}","name":"Old","kind":"filter","inputs":["type"]}}"#); + let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::module(&old, tpl::ECHO, "", 1)).unwrap()); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("`inputs` needs `api` 2"), "{e}"); +} + /// What the user granted is kept in the plug-in store with the module: the next session installs /// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is /// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index 8d5396199..e896a0ab3 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -268,6 +268,28 @@ fn escape_on_a_shape_drag_saves_the_option_it_put_back() { assert_eq!(s.prefs.corner_radius, radius); } +/// Setting another tool's option during a polygon drag leaves the drag's options alone: Escape +/// still puts back the side count the arrow changed. +#[test] +fn escape_puts_back_the_sides_after_another_tool_s_option_is_set() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + select(&mut s, "polygon"); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap(); + s.tool_key(ToolKey::Up, Mods::default(), v).unwrap(); + // Saves every option, the one the arrow changed included. + s.set_tool_option("length", &json!(50)); + let values = json!({"points": 9}); + s.set_tool_options(Some("star"), values.as_object().unwrap()); + assert_eq!(s.tool_options()["sides"], json!(7), "the drag keeps its side count"); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(s.tool_options()["sides"], json!(6)); + assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6)); + assert_eq!(s.prefs.tool_settings["star"]["points"], json!(9)); +} + /// The Fidelity an Alt smoothing drag uses on a selected path. fn alt_smooth_fidelity(s: &mut Session) -> Option { use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind}; diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index fa6c21896..405940842 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -257,7 +257,15 @@ impl Session { /// Give `t` the persistent options stored for it in `prefs`. fn restore_tool_settings(prefs: &Prefs, t: &mut dyn Tool) { + Self::restore_stores(prefs, t, |_| true); + } + + /// Give `t` the persistent options stored for it in `prefs`, from the stores `wanted` accepts. + fn restore_stores(prefs: &Prefs, t: &mut dyn Tool, wanted: impl Fn(&str) -> bool) { for (store, keys) in settings::stores(t.id()) { + if !wanted(store) { + continue; + } let Some(saved) = prefs.tool_settings.get(store) else { continue }; for k in keys { if let Some(v) = saved.get(*k) { @@ -267,6 +275,7 @@ impl Session { } // Alt switches the Pencil, Paintbrush or Blob Brush to Smooth, using Smooth Tool Options' Fidelity. if matches!(t.id(), "pencil" | "paintbrush" | "blobBrush") + && wanted("smooth") && let Some(v) = prefs.tool_settings.get("smooth").and_then(|s| s.get("fidelity")) { t.set_option("smoothFidelity", v); @@ -907,7 +916,10 @@ impl Session { } Self::restore_tool_settings(&self.prefs, lender); } - Self::restore_tool_settings(&self.prefs, self.tool.as_mut()); + // Only the stores it shares with the tool set: the active tool's own options stay as they + // are, also in the middle of a drag that Escape can cancel. + let set = other.id(); + Self::restore_stores(&self.prefs, self.tool.as_mut(), |store| settings::stores(set).any(|(s, _)| s == store)); } out } diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index a18ab45e7..79d66bb63 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -824,6 +824,64 @@ proptest! { } } + /// Manifests with hostile `inputs` (unknown, repeated and oddly cased kinds, long lists, junk of + /// every type) with any `api`, kind and permissions parse to an error or to a filter at `api` 2 + /// or later asking for each kind once, whose `permissions` list `doc.read` so that installing + /// it asks for approval. One that installs and echoes its input on a document + /// with type, images and symbols leaves the document as it was, and it still renders. + #[test] + fn hostile_manifest_inputs_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + names in prop::collection::vec(prop::sample::select(vec!["type", "image", "symbolInstance", "Type", "text", "path", ""]), 0..6), + long in prop::option::weighted(0.1, 4usize..300), + inputs in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), + api in prop_oneof![2 => Just(json!(2)), 1 => Just(json!(1)), 1 => Just(json!(3)), 1 => arb_junk()], + permissions in prop_oneof![Just(Value::Null), Just(json!([])), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))], + ) { + let mut m = json!({"id": "fuzz.inputs", "name": "Inputs", "kind": kind, "api": api}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + if !permissions.is_null() { + m["permissions"] = permissions; + } + m["inputs"] = match (inputs, long) { + (_, Some(n)) => json!(vec!["image"; n]), + (Some(Value::Null) | None, None) => json!(names), + (Some(junk), None) => junk, + }; + let text = m.to_string(); + let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); + let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; + if let Ok(man) = &parsed { + let kinds = man.input_kinds(); + prop_assert_eq!(kinds.len(), man.inputs.len(), "{}", text); + prop_assert!(kinds.iter().enumerate().all(|(i, k)| !kinds[..i].contains(k)), "{}", text); + prop_assert!(kinds.is_empty() || (man.kind == vectorcraft_plugins::Kind::Filter && man.api >= 2), "{}", text); + let reads = man.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == "doc.read")); + prop_assert!(kinds.is_empty() || (man.asks_permissions() && reads), "{}", text); + } + let mut s = rich_session(); + let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap(); + let data = vectorcraft_format::base64_encode(&module); + let grant = json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]); + let before = s.doc().unwrap().doc.clone(); + let r = catch_quiet(|| { + let installed = s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok(); + let _ = s.execute("select.all", &json!({})); + let ran = installed && s.execute("plugin.run", &json!({"id": "fuzz.inputs"})).is_ok(); + (installed, ran) + }); + let (installed, ran) = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?; + prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + if ran { + prop_assert!(s.doc().unwrap().doc.layers == before.layers, "an echo changed the document: {}", text); + } + renders(&s, &text)?; + vectorcraft_plugins::registry::remove("fuzz.inputs"); + vectorcraft_plugins::registry::revoke("fuzz.inputs"); + } + /// A plug-in that calls the storage imports with hostile keys (`..`, separators, device names, /// control characters, too long), values, lengths and pointers in and out of its memory runs to /// an output, never a panic; every call returns a length or a storage code, and the settings and diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 2c9dc355c..cad65f041 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1080,11 +1080,13 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p use crate::import::ai::style::StyleItem; // A text object's slot (an empty group standing for it) takes the style as the type will. let slot = slot_of(object.name.as_deref()).is_some(); + // Several fills and strokes, on a path or a compound path. + let stacked = !slot && !st.simple && st.stacked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); // A symbol instance draws its symbol's art: it keeps its drawn look. if !st.plain || matches!(object.kind, NodeKind::SymbolInstance { .. }) // A look with no outline in it to keep takes a variable-width stroke as the object's own. - || (!slot && !st.simple && (!st.uneven || draws(look, &HashSet::new()))) + || (!slot && !st.simple && !stacked && (!st.uneven || draws(look, &HashSet::new()))) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) || (st.pathfinder && (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. }))) @@ -1099,6 +1101,19 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p // drawn look has each where the app placed it. let mut placed = HashMap::new(); placed_paints(look, &mut placed, 0); + // A stack's gradients are each its own swatch, placed where the drawn look has it: one fitted + // to the object's bounds instead could lie elsewhere, so such a stack keeps its drawn look. + if stacked { + let mut seen = HashSet::new(); + let placed_apart = st.items.iter().all(|i| match i { + StyleItem::Gradient { name: Some(n), .. } => seen.insert(n) && placed.contains_key(paints.names.get(Global::Gradient, n)), + StyleItem::Gradient { name: None, .. } => false, + _ => true, + }); + if !placed_apart { + return None; + } + } let mut items = vec![]; for (n, item) in st.items.iter().enumerate() { let mut layer = match item { @@ -1106,8 +1121,9 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p StyleItem::Gradient { name, angle } => { // The drawn look's gradients are keyed by the names they got in the document. let paint = match (name.as_ref().and_then(|n| placed.get(paints.names.get(Global::Gradient, n))), gradients, own_gradient) { - // Other objects than type: the object's own, as they always were. - (_, 1, Some(own)) if !slot => own.paint.clone(), + // Other objects than type: the object's own, as they always were (a stack's + // where its drawn look places it). + (_, 1, Some(own)) if !slot && !stacked => own.paint.clone(), (Some(p), ..) => p.clone(), (None, 1, Some(own)) => own.paint.clone(), _ => { diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 2a2e3f692..8e1d6eb94 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -47,7 +47,8 @@ //! with a pattern or gradient too; strokes with a Calligraphic or Art brush or aligned inside or outside too), each with its own //! transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and -//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the +//! effects (a path's stack of several fills and strokes when its drawn look places each of its +//! gradients); others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the //! object's chain) are read on a group with no fills or strokes of its own, which keeps its members. use std::collections::HashMap; @@ -178,11 +179,16 @@ pub(super) struct ArtStyle { /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke), /// Bas Relief read from its settings block and some Distort & Transform effects ([`any_object_takes`]) on the object /// (and Pathfinder effects when it has no fills or strokes): what any object is read with. Type takes the other plain styles too (its drawn look - /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. + /// can't be retyped), paths and compound paths [`Self::stacked`] ones; other objects keep their drawn look, which is the app's own rendering. pub simple: bool, /// It would be [`Self::simple`] but for a Stroke Style filter's variable width written as one /// centered array ([`centered_widths`]): an object whose drawn look is empty takes it with its profile. pub uneven: bool, + /// It is several fills and strokes in solid colours or gradients (strokes without a brush), + /// each with its own visibility, opacity and blend mode but no effects of its own, under only + /// the effects any object takes that don't move the outline: what a path or compound path is + /// read with when each gradient is placed as its drawn look has it (`read::ops`' `styled`). + pub stacked: bool, /// Its fills and strokes, bottom first. pub items: Vec, /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. @@ -1606,6 +1612,13 @@ fn style(filters: &[Filter]) -> ArtStyle { // A stroke with one centered width array comes in as the drawn look, which holds the outline as the file draws it. let centered = filters.iter().any(centered_widths); (st.simple, st.uneven) = (simple && !centered, simple && centered); + st.stacked = !centered + && st.plain + && !st.pathfinder + && !st.moves_outline() + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Gradient { .. } | StyleItem::Stroke { brush: None, .. })) + && st.looks.iter().all(|l| l.effects.is_empty()) + && st.effects.iter().all(any_object_takes); st } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 0bb0b14df..5c830fd17 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5925,6 +5925,115 @@ fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// An art style named `Glow`: a path filled with gradient swatch `under`, then gradient swatch +/// `over` at 50 % Screen above it. +fn two_gradient_style(under: &str, over: &str) -> String { + let fill = |name: &str| { + format!( + "/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 ({name}) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n" + ) + }; + format!( + "/KnownStyle :\n(Glow) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n{}\ + /BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /BlendStyle : 2 0.5 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n\ + {}/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n", + fill(over), + fill(under) + ) +} + +#[test] +fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_them() { + // GUARDS: a path whose style stacks fills with their own opacity and blend mode came in as its drawn look. + let defs = "%AI5_BeginGradient: (A)\n(A) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n\ + %AI5_BeginGradient: (B)\n(B) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let rect = rect_path(0.0, 0.0, 100.0, 50.0); + let hidden_rect = rect.replace('\n', "\n%_"); + // The drawn look: the path filled with each gradient where the app placed it (its transparency + // left out: only where the gradients lie is read from it). + let look = |name: &str, x0: f64| format!("{rect}Bb\n1 ({name}) {x0} 25 0 50 1 0 0 1 0 0 Bg\nf\n0 BB\n"); + let square = |under: &str, over: &str| format!("0 Ae\nu\n{}{}U\n%_{hidden_rect}f\n1 (Glow) XW\n", look(under, 0.0), look(over, 50.0)); + let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&square("A", "B")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let fills: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match (i, i.paint()) { + (AppearanceItem::Fill(f), Paint::Gradient(g)) => (g.swatch.clone(), g.geom.map(|g| g.start.x), f.opacity, f.blend), + _ => panic!("{i:?}"), + }) + .collect(); + assert_eq!(fills, [(Some("A".into()), Some(0.0), 1.0, BlendMode::Normal), (Some("B".into()), Some(50.0), 0.5, BlendMode::Screen)]); + // Two fills of one swatch: which placement is whose isn't known, so the drawn look stays. + let same = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "A"), layer(&square("A", "A")))); + assert!(same.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", same.warnings); + // One gradient over a solid fill, on a path filled with that gradient placed elsewhere: the + // gradient lies where the drawn look has it, not where the path's own fill does. + let one = two_gradient_style("A", "B").replace("1 (A) 0 0 0 1 1 0 0 1 0 0 Bg\n", "0 0 0 1 0 0 0 Xa\n"); + let own = format!( + "0 Ae\nu\n0 0 0 1 0 0 0 Xa\n{rect}f\n{}U\n%_{hidden_rect}Bb\n%_1 (B) 0 25 0 100 1 0 0 1 0 0 Bg\n%_f\n%_0 BB\n1 (Glow) XW\n", + look("B", 50.0) + ); + let one = read_ok(&format!("%AI9_BeginArtStyles\n{one}%AI9_EndArtStyles\n{defs}{}", layer(&own))); + let a = art(&one.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let placed: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match i.paint() { + Paint::Gradient(g) => g.geom.map(|g| g.start.x), + _ => None, + }) + .collect(); + assert_eq!(placed, [None, Some(50.0)], "{:?}", one.warnings); + // A look that doesn't paint the gradient (a raster, say): the drawn look stays. + let flat = format!("0 Ae\nu\n{RED}{rect}f\nU\n%_{hidden_rect}f\n1 (Glow) XW\n"); + let flat = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{defs}{}", two_gradient_style("A", "B"), layer(&flat))); + assert!(flat.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", flat.warnings); +} + +/// A stack of solid colours comes in live too: a path whose style is a solid fill under a stroke +/// without a brush at 50 % Screen comes in as itself with both, each with its own transparency. +#[test] +fn a_path_with_a_solid_fill_under_a_screened_stroke_comes_in_as_itself() { + let stroke = "/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n"; + let over = "/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 (B) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"; + let style = two_gradient_style("A", "B"); + assert!(style.contains(over)); + let style = style.replace(over, stroke).replace("1 (A) 0 0 0 1 1 0 0 1 0 0 Bg\n", "0 0 0 1 0 0 0 Xa\n"); + let rect = rect_path(0.0, 0.0, 100.0, 50.0); + let hidden_rect = rect.replace('\n', "\n%_"); + let square = format!("0 Ae\nu\n0 0 0 1 0 0 0 Xa\n{rect}f\n0 1 1 0 1 0 0 XA\n2 w\n{rect}S\nU\n%_{hidden_rect}f\n1 (Glow) XW\n"); + let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&square))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let items: Vec<_> = a[0] + .appearance + .items + .iter() + .map(|i| match i { + AppearanceItem::Fill(f) => ("fill", matches!(f.paint, Paint::Solid { .. }), f.opacity, f.blend), + AppearanceItem::Stroke(s) => { + ("stroke", matches!(s.paint, Paint::Solid { .. }) && s.brush.is_none() && s.width == 2.0, s.opacity, s.blend) + } + }) + .collect(); + assert_eq!(items, [("fill", true, 1.0, BlendMode::Normal), ("stroke", true, 0.5, BlendMode::Screen)]); +} + /// `node` is the red square of [`styled_square`] read as itself with [`ART_STYLES`]' `Blue`: a blue /// fill and a drop shadow, not the look it draws. fn assert_blue_square(node: &Node) { 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 cb9661bb8..e3f1f76de 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -18,7 +18,8 @@ //! a module does can panic or hang the host. //! //! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with -//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns +//! their geometry, fills, strokes (gradients included) and opacity, and, for a filter that asks +//! for them ([`InputKind`]), type, images and symbol instances to read. An **object filter** returns //! the objects to keep, change or add, applied to the document as one edit //! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each //! time it is drawn, from an appearance stack, and at `api` 2 may paint what it returns @@ -49,7 +50,7 @@ pub mod wat; pub use dialog::ParamUi; pub use host::{LogLevel, LogLine, Report}; -pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec}; +pub use manifest::{Budget, Command, InputKind, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index acd301d1d..d548e6444 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. @@ -54,6 +54,39 @@ impl Kind { } } +/// A kind of object a filter may ask to be given besides paths and compound paths (its manifest's +/// `inputs`, plug-in API level 2). The filter reads these objects and returns each one as it was +/// given: it can't change or delete them ([`crate::objects::decode_output_with`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum InputKind { + /// Point, area and path type: its text runs with their character attributes, and its + /// paragraph attributes. + Type, + /// An embedded or linked image: its size, color mode and, when small, its pixels. + Image, + /// A symbol instance: the symbol it shows and its transform. + SymbolInstance, +} + +impl InputKind { + /// Every kind, in the order the docs list them. + pub const ALL: [InputKind; 3] = [InputKind::Type, InputKind::Image, InputKind::SymbolInstance]; + + /// Its name in `inputs`, which is also the `type` of such an object in a filter's input. + pub fn name(self) -> &'static str { + match self { + InputKind::Type => "type", + InputKind::Image => "image", + InputKind::SymbolInstance => "symbolInstance", + } + } + + /// The kind named `name`. + pub fn parse(name: &str) -> Option { + InputKind::ALL.into_iter().find(|k| k.name() == name) + } +} + /// How much one run of a plug-in may compute. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -282,6 +315,11 @@ pub struct Manifest { /// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option>, + /// The kinds of object a filter asks to be given besides paths and compound paths, by + /// [`InputKind::name`]: only a filter at `api` 2 or later whose `permissions` list `doc.read` + /// lists them. Left out or empty, it is given paths and compound paths alone. + #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "input_names")] + pub inputs: Vec, /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] pub params: Vec<(String, Param)>, @@ -327,6 +365,7 @@ impl Manifest { author: String::new(), api: 1, permissions: None, + inputs: Vec::new(), params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, @@ -370,6 +409,7 @@ impl Manifest { let permission = host.name(); return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); } + self.validate_inputs()?; if self.paint_output && (self.kind != Kind::Effect || self.api < 2) { return Err(Error::Manifest("`paintOutput` is only for a live effect (`kind` \"effect\") at `api` 2".into())); } @@ -405,6 +445,34 @@ impl Manifest { Ok(()) } + /// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists + /// any, with a `permissions` list that holds `doc.read`: installing it then asks the user to + /// grant them ([`Manifest::asks_permissions`]). + fn validate_inputs(&self) -> Result<()> { + if self.inputs.is_empty() { + return Ok(()); + } + if self.kind != Kind::Filter { + return Err(Error::Manifest("only a filter declares `inputs`".into())); + } + for (i, name) in self.inputs.iter().enumerate() { + let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD); + if InputKind::parse(name).is_none() { + return Err(Error::Manifest(format!("`inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance"))); + } + if self.inputs.get(..i).is_some_and(|before| before.contains(name)) { + return Err(Error::Manifest(format!("`inputs` lists `{shown}` twice"))); + } + } + if self.api < INPUTS_API { + return Err(Error::Manifest("`inputs` needs `api` 2 or later".into())); + } + if !self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::DocRead.name())) { + return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into())); + } + Ok(()) + } + /// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct /// id, a printable label and parameters declared as the manifest's own are. A heavy filter /// lists none. The error names the command: its id, or its place in the list. @@ -446,6 +514,11 @@ impl Manifest { Ok(()) } + /// The kinds of object besides paths and compound paths it asks to be given (`inputs`). + pub fn input_kinds(&self) -> Vec { + self.inputs.iter().filter_map(|n| InputKind::parse(n)).collect() + } + /// An exporter or an importer lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 /// lower-case letters and digits; other kinds list none. fn validate_extensions(&self) -> Result<()> { @@ -604,6 +677,17 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result d.deserialize_any(V) } +/// Reads `inputs`: a list of names (`null`: none). Anything else is an error that names the key, as +/// [`Manifest::validate_inputs`] names a wrong entry. +fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + let wrong = || serde::de::Error::custom("`inputs` must be a list of object kinds: type, image or symbolInstance"); + match Value::deserialize(d)? { + Value::Null => Ok(Vec::new()), + Value::Array(names) => names.into_iter().map(|n| if let Value::String(s) = n { Ok(s) } else { Err(wrong()) }).collect(), + _ => Err(wrong()), + } +} + /// Reads the `commands` list one entry at a time from the manifest itself, so each command's /// `params` keep their declaration order and a repeated parameter is refused. A malformed entry /// is an error that names it (its id when the id comes before the fault, otherwise its place in @@ -940,6 +1024,53 @@ mod tests { } } + /// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once, + /// with a `permissions` list holding `doc.read`, so installing it asks for approval. Left out + /// or empty, it asks for none and isn't written back. Anything else is refused naming `inputs`. + #[test] + fn a_filter_at_api_2_lists_the_inputs_it_reads() { + let m = Manifest::parse( + br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type","image","symbolInstance"]}"#, + ) + .unwrap(); + assert_eq!(m.input_kinds(), InputKind::ALL); + assert!(m.asks_permissions()); + assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"])); + let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap(); + assert_eq!(some.input_kinds(), [InputKind::Image]); + assert!(some.asks_permissions()); + for none in [ + r#"{"id":"a","name":"x","kind":"filter"}"#, + r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#, + r#"{"id":"a","name":"x","kind":"filter","inputs":null}"#, + r#"{"id":"a","name":"x","kind":"effect","inputs":[]}"#, + ] { + let m = Manifest::parse(none.as_bytes()).unwrap(); + assert!(m.input_kinds().is_empty(), "{none}"); + assert!(serde_json::to_value(&m).unwrap().get("inputs").is_none(), "{none}"); + } + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["text"]}"#, "`inputs` names an unknown object kind `text`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["Type"]}"#, "unknown object kind `Type`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["image","image"]}"#, "`inputs` lists `image` twice"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":"type"}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":[1]}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":{"type":true}}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["type"]}"#, "`inputs` needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":[],"inputs":["image"]}"#, "`inputs` needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"), + (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"inputs":[{}]}}"#, vec![r#""type""#; 1000].join(",")); + assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("twice")); + } + /// A live effect at `api` 2 may declare `"paintOutput": true`, and only that value; any other /// kind or level, and `false`, `null` or another type, is refused naming the key. Without the /// key, at any level, the effect returns geometry only. diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 011d8a7d0..c8dfcb2f0 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -6,14 +6,19 @@ //! in-range coordinates, capped counts, clamped colours) before it reaches the document. use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; +use vectorcraft_color::cms::{self, Cms, CmykLut, lab}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{FillRule, PathData, Point, Rect}; +use vectorcraft_doc::cmyk::oversized_tiff; +use vectorcraft_doc::{ + Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes, + TextKind, TextObject, +}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; -use crate::{Error, Result}; +use crate::{Error, InputKind, Result}; /// Coordinates a plug-in may return, in points (the document's own canvas limit). pub const MAX_COORD: f64 = 4.0e6; @@ -32,6 +37,20 @@ pub const MITER_LIMIT: std::ops::RangeInclusive = 1.0..=500.0; pub const MAX_EFFECT_ID: usize = 128; /// Largest data a plug-in may keep with a document (`documentData`), in bytes of JSON. pub const MAX_DOCUMENT_DATA: usize = 1 << 20; +/// Most text a filter is given of one type object, in bytes of UTF-8 over all its runs: longer +/// type comes with `truncated` and no runs or paragraphs. +pub const MAX_INPUT_TEXT: usize = 256 * 1024; +/// Most runs, and most paragraphs, a filter is given of one type object (as [`MAX_INPUT_TEXT`]). +pub const MAX_INPUT_RUNS: usize = 4_096; +/// Largest pixels a filter is given of one embedded image, in characters of base64: a larger +/// image comes with `pixelsOmitted`. +pub const MAX_INPUT_PIXELS: usize = 1 << 20; +/// Most pixels a filter is given over all the images of one input, in characters of base64: an +/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`. +pub const MAX_INPUT_PIXELS_TOTAL: usize = 16 << 20; +/// Most text a filter is given over all the type of one input, in bytes of the JSON of their runs +/// and paragraphs: type that doesn't fit what the type before it left comes with `truncated`. +pub const MAX_INPUT_TEXT_TOTAL: usize = 16 << 20; /// One object returned by a plug-in. `None` fields keep the object's value (new objects take the /// defaults: a path, non-zero rule, no fills or strokes, full opacity). @@ -56,6 +75,9 @@ pub struct OutObject { pub guide: Option, /// 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 +180,350 @@ 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, &[], usize::MAX).map(|(bytes, _)| bytes).unwrap_or_default() +} + +/// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are +/// in it as [`encode_read_only`] gives them, within [`MAX_INPUT_PIXELS_TOTAL`] and +/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order, and within `capacity`, the most bytes the +/// input may be. Every object counts toward `capacity` in order, ids, bounds, transforms and names +/// included: pixels or text that don't fit what is left are omitted (`pixelsOmitted`, +/// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`] +/// naming it and its size, a path or compound path that doesn't fit is the limit the runtime gives +/// an input past its share, and more than [`MAX_OBJECTS`] read-only objects, more than an output +/// may list, is an [`Error::Limit`] too. Also returns those objects by id, which the filter +/// must return as they were ([`decode_output_with`]). +pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec, HashMap)> { + encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL }) +} + +/// The input with no objects; each object adds its JSON and a comma. +const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}"; + +/// What an input's objects may still be given of its totals, as they are encoded in order: +/// characters of base64 for pixels, bytes of JSON for runs and paragraphs, and bytes of the whole +/// input. +#[derive(Clone, Copy, Debug)] +struct Allowance { + pixels: usize, + text: usize, + input: usize, +} + +impl Allowance { + const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX }; +} + +/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map +/// it returns, and written into the input from there. +fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec, HashMap)> { + let mut read_only = HashMap::new(); + let symbols = doc.symbol_boxes(); + let mut out = b"{\"objects\":[".to_vec(); + let mut first = true; + for n in ids.iter().filter_map(|id| doc.node(*id)) { + let path; + let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) { + // The output must return every read-only object, and lists at most `MAX_OBJECTS`. + Some(_) if read_only.len() >= MAX_OBJECTS => return Err(too_many_read_only()), + Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left)? { + Some(o) => (read_only.entry(n.id).insert_entry(o).into_mut(), true), + None => continue, + }, + None => match encode(n) { + Some(o) => { + path = o; + (&path, false) + } + None => continue, + }, + }; + let start = out.len(); + if !std::mem::take(&mut first) { + out.push(b','); + } + if serde_json::to_writer(&mut out, o).is_err() { + return Ok((Vec::new(), HashMap::new())); + } + let size = out.len() - start; + // A path or compound path past what is left fails the run as the runtime would fail it, + // before the rest of the selection is written. + if size > left.input { + return Err(if checked { too_large(n, size, left.input) } else { Error::Limit(crate::runtime::SELECTION_TOO_LARGE.into()) }); + } + left.input -= size; + } + out.extend_from_slice(b"]}"); + Ok((out, read_only)) +} + +/// The error for read-only object `n`, which needs `size` bytes of input where `left` are left. +fn too_large(n: &Node, size: usize, left: usize) -> Error { + Error::Limit(format!("memory budget (object {} needs {size} bytes of input, {left} left)", n.id.0)) +} + +/// The error for an input with more read-only objects than an output may list. +fn too_many_read_only() -> Error { + Error::Limit(format!("object budget (more than {MAX_OBJECTS} read-only objects)")) +} + +/// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). +pub fn input_kind(n: &Node) -> Option { + match &n.kind { + NodeKind::Text(_) => Some(InputKind::Type), + NodeKind::Image(_) => Some(InputKind::Image), + NodeKind::SymbolInstance { .. } => Some(InputKind::SymbolInstance), + _ => None, + } +} + +fn affine_json(xf: Affine) -> Value { + json!(xf.as_coeffs()) +} + +/// Type, an image or a symbol instance of `doc` as a filter that asks for its kind reads it (`None` +/// for other objects). Each has `{id, type, name?, bounds, transform}`, `transform` mapping its own +/// space (text space, pixel space, the symbol's art) into the document, and: +/// - type (`"type"`): `text: {kind: "point"|"area"|"path", vertical, runs: [{text, style, inline?}], +/// paragraphs: [style]}` with the native character and paragraph attributes, one paragraph style +/// for each paragraph, and `truncated`: true, with no runs or paragraphs, past [`MAX_INPUT_TEXT`] +/// or [`MAX_INPUT_RUNS`]; +/// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`, +/// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`] +/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top (a CMYK image's inks +/// through the working CMYK profile). A linked image's file and path are never read or given; +/// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when +/// the document has no such symbol, and `overrides: {}`. +pub fn encode_read_only(doc: &Document, n: &Node) -> Option { + let mut left = Allowance::FULL; + encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left).ok().flatten() +} + +/// The bytes the `pixelsBase64` key adds to an image's JSON besides the base64 itself. +const PIXELS_KEY: usize = ",\"pixelsBase64\":\"\"".len(); + +/// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the +/// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that +/// don't fit what is left of their totals or of the input are omitted (`pixelsOmitted`, +/// `truncated`), and an image's size is checked against it before anything is decoded. The object's +/// other fields are measured first, with a comma before it: when they alone don't fit what is left +/// of the input, it is an [`Error::Limit`] naming the object and that size. +fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Result> { + let Some(kind) = input_kind(n) else { return Ok(None) }; + let mut o = json!({"id": n.id.0, "type": kind.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); + // Each flag is measured as `false`, its longer value. + match &n.kind { + NodeKind::Text(t) => { + o["transform"] = affine_json(t.xf); + o["text"] = text_json(t, None).0; + o["truncated"] = json!(false); + } + NodeKind::Image(im) => { + o["transform"] = affine_json(im.xf); + o["pixelSize"] = json!([im.width, im.height]); + o["colorMode"] = json!(doc.image_blob(im).map_or("unknown", color_mode)); + o["linked"] = json!(im.link.is_some()); + o["pixelsOmitted"] = json!(false); + } + NodeKind::SymbolInstance { symbol, xf } => { + o["transform"] = affine_json(*xf); + o["symbolId"] = json!(symbol); + if !doc.symbols.iter().any(|s| s.name == *symbol) { + o["missing"] = json!(true); + } + o["overrides"] = json!({}); + } + _ => return Ok(None), + } + if let Some(name) = &n.name { + o["name"] = json!(name); + } + let fields = serde_json::to_vec(&o).map_or(usize::MAX, |b| b.len()).saturating_add(1); + if fields > left.input { + return Err(too_large(n, fields, left.input)); + } + let room = left.input - fields; + match &n.kind { + NodeKind::Text(t) => { + let mut budget = left.text.min(room); + let (text, truncated) = text_json(t, Some(&mut budget)); + left.text -= left.text.min(room) - budget; + o["text"] = text; + o["truncated"] = json!(truncated); + } + NodeKind::Image(im) => { + let blob = doc.image_blob(im); + let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY)); + // A linked image's pixels are its file's: never given (nor its path). + let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max)); + o["pixelsOmitted"] = json!(pixels.is_none()); + if let Some(p) = pixels { + let b64 = craft_vectorcraft_format::base64::encode(&p); + left.pixels = left.pixels.saturating_sub(b64.len()); + o["pixelsBase64"] = json!(b64); + } + } + _ => {} + } + Ok(Some(o)) +} + +/// Counts the bytes written to it and fails past `left`, so nothing is held to find a size. +struct Measure { + bytes: usize, + left: usize, +} + +impl std::io::Write for Measure { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.bytes = self.bytes.saturating_add(buf.len()); + if self.bytes > self.left { Err(std::io::ErrorKind::OutOfMemory.into()) } else { Ok(buf.len()) } + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Adds `item` as JSON to `list` when it fits what is left of `limit` after `used`, counting the +/// comma before it; `false`, with nothing built, when it doesn't. +fn push_within(item: &T, list: &mut Vec, used: &mut usize, limit: usize) -> bool { + let comma = usize::from(!list.is_empty()); + let Some(left) = limit.checked_sub(used.saturating_add(comma)) else { return false }; + if serde_json::to_writer(Measure { bytes: 0, left }, item).is_err() { + return false; + } + let Ok(value) = serde_json::to_value(item) else { return false }; + // A `Value` writes a number in its widest form, so the bytes given are counted from it. + let mut measure = Measure { bytes: 0, left }; + if serde_json::to_writer(&mut measure, &value).is_err() { + return false; + } + *used += comma + measure.bytes; + list.push(value); + true +} + +/// The text of type `t` as a filter reads it, and whether it was too long to give +/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives +/// is taken from, with the commas between them). With `left` `None`, its kind and direction with no +/// runs or paragraphs. +fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) { + let kind = match t.kind { + TextKind::Point => "point", + TextKind::Area { .. } => "area", + TextKind::OnPath { .. } => "path", + }; + let (mut runs, mut paras): (Vec, Vec) = (Vec::new(), Vec::new()); + let mut truncated = false; + if let Some(left) = left { + let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); + let paragraphs = t.paragraph_count(); + // The text alone is never more bytes than its JSON, so type that can't fit isn't built. + truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; + if !truncated { + // Each run and paragraph is measured before it is built, with the comma before it, so + // no more than `left` is held before the type is found too long. + let (limit, mut used) = (*left, 0usize); + let fits = t.runs.iter().all(|r| push_within(r, &mut runs, &mut used, limit)) + && (0..paragraphs).all(|i| push_within(&t.para_at(i), &mut paras, &mut used, limit)); + if fits { + *left -= used; + } else { + (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + } + } + } + (json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated) +} + +/// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or +/// `"unknown"` when it can't be read, is a TIFF too large for the decoder to count, or only a +/// linked image's preview is there. +fn color_mode(b: &ImageBlob) -> &'static str { + if oversized_tiff(&b.bytes).is_some() { + return "unknown"; + } + if b.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &b.bytes) || **p == *b.bytes) { + return "unknown"; + } + if b.is_cmyk() { + return "cmyk"; + } + if b.gray_source().is_some() { + return "gray"; + } + let readable = + image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); + if readable.is_some() { "rgb" } else { "unknown" } +} + +/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max` +/// characters of base64; the size is checked, and read from the header, before anything is decoded. +fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option> { + let bytes = u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)?; + if bytes == 0 || bytes.div_ceil(3).saturating_mul(4) > max as u64 || oversized_tiff(&b.bytes).is_some() { + return None; + } + if b.is_cmyk() { + return cmyk_rgba(b, w, h); + } + let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); + if reader()?.into_dimensions().ok()? != (w, h) { + return None; + } + let mut r = reader()?; + let mut limits = image::Limits::default(); + // The decoder's own buffers besides the RGBA it gives. + limits.max_alloc = Some(64 << 20); + r.limits(limits); + let img = r.decode().ok()?.into_rgba8(); + (img.dimensions() == (w, h)).then(|| img.into_raw()) +} + +/// The inks → XYZ table of [`cmyk_rgba`] and the color settings it was built with. +static CMYK_DISPLAY: Mutex, Arc)>> = Mutex::new(None); + +/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels (checked from its +/// header first): its inks shown through the working CMYK profile, by a table built as +/// `vectorcraft-render`'s `InkLut` builds its own and kept for the active color settings. +fn cmyk_rgba(b: &ImageBlob, w: u32, h: u32) -> Option> { + if b.header_size()? != (w, h) { + return None; + } + let inks = b.cmyk().filter(|i| (i.width, i.height) == (w, h))?; + let lut = { + let cms = cms::active(); + let mut slot = CMYK_DISPLAY.lock().unwrap_or_else(|e| e.into_inner()); + match &*slot { + Some((owner, lut)) if Arc::ptr_eq(owner, &cms) => lut.clone(), + _ => { + let lut = Arc::new(CmykLut::build(|c| { + let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(c)); + [x, y, z, 0.0] + })); + *slot = Some((cms, lut.clone())); + lut + } + } + }; + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let mut out = Vec::with_capacity(inks.data.len()); + // Runs of one color convert once. + let mut last: Option<([u8; 4], [u8; 4])> = None; + for p in inks.data.as_chunks::<4>().0 { + let rgba = match last { + Some((k, v)) if k == *p => v, + _ => { + let [x, y, z, _] = lut.apply(p.map(|v| f32::from(v) / 255.0)); + let [r, g, b] = lab::xyz_to_srgb([x, y, z]).map(q); + last = Some((*p, [r, g, b, 255])); + [r, g, b, 255] + } + }; + out.extend_from_slice(&rgba); + } + Some(out) } pub(crate) fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { @@ -397,8 +761,16 @@ pub struct Output { /// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}` /// when the plug-in declined (the message reaches the user). pub fn decode_output(bytes: &[u8]) -> Result { + 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 +789,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 +803,91 @@ 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)| match output_id(o).and_then(|id| Some((id, read_only.get(&id)?))) { + Some((id, given)) => { + let at = format!("objects[{i}] (id {})", id.0); + if !returned.insert(id) { + return Err(out_err(&at, "is listed twice")); + } + check_unchanged(o, given, &at)?; + Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() }) + } + None => decode_object(o, &format!("objects[{i}]"), &mut anchors), + }) + .collect::>>()?; + 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) +} + +/// The id entry `o` gives, a whole number written either way (`17` or `17.0`). +fn output_id(o: &Value) -> Option { + let id = o.get("id")?; + id.as_u64().or_else(|| id.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=9_007_199_254_740_992.0).contains(f)).map(|f| f as u64)).map(NodeId) +} + +/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone, +/// or `given` itself, numbers compared by value. Otherwise the error names the first field that +/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as +/// [`crate::permissions::display_text`] shows a manifest string, since the entry's keys are the +/// plug-in's. +fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> { + if o.as_object().is_some_and(|m| m.len() == 1) { + return Ok(()); + } + let changed = match (o, given) { + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().collect(); + keys.sort(); + keys.into_iter().find_map(|k| match y.get(k) { + Some(q) => first_difference(&x[k], q, k), + None => Some(k.clone()), + }) + } + _ => None, + }; + match changed.or_else(|| first_difference(o, given, "")) { + None => Ok(()), + Some(field) => { + let field = crate::permissions::display_text(&field, crate::permissions::MAX_SHOWN_FIELD); + Err(out_err(at, format!("read-only field '{field}' changed"))) + } + } +} + +/// The path of the first place `a` and `b` differ (`""`: they differ as a whole), `None` when they +/// are the same JSON with numbers compared by value. +fn first_difference(a: &Value, b: &Value, at: &str) -> Option { + let key = |k: &str| if at.is_empty() { k.to_string() } else { format!("{at}.{k}") }; + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.as_f64() != y.as_f64()).then(|| at.into()), + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect(); + keys.sort(); + keys.dedup(); + keys.into_iter().find_map(|k| match (x.get(k), y.get(k)) { + (Some(p), Some(q)) => first_difference(p, q, &key(k)), + _ => Some(key(k)), + }) + } + (Value::Array(x), Value::Array(y)) => { + if x.len() != y.len() { + return Some(at.into()); + } + x.iter().zip(y).enumerate().find_map(|(i, (p, q))| first_difference(p, q, &format!("{at}[{i}]"))) + } + (a, b) => (a != b).then(|| at.into()), + } } /// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it, @@ -622,25 +1079,39 @@ pub fn selectable(n: &Node) -> bool { /// The paths and compound paths a filter reads from the selection `selected`, in paint order: /// selected ones, and those inside selected groups and layers; hidden, locked, guide (compound -/// guides too) and clipping paths are left out (so are paths inside compound paths, blends and -/// other live objects). +/// guides too) and clipping paths are left out, and so are compound paths that clip a clip group +/// and paths inside a group that clips one (and paths inside compound paths, blends and other +/// live objects). pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec { - 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 (objects that clip a clip +/// group are left out, inside a group that clips one too, and so is anything inside a symbol, a +/// placed document or a live object). +pub fn filter_targets_with(doc: &Document, selected: &[NodeId], kinds: &[InputKind]) -> Vec { + /// `source`: `list` is inside the clipping object of a clip group. + fn visit(list: &[Arc], sel: &HashSet, inside: bool, clips: bool, source: 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); + let clipping = source || (clips && i == 0); match &n.kind { - NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id), - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out), + NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !clipping && !is_guide(n) => { + out.push(n.id) + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), clipping, kinds, out), + _ if picked && !clipping && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id), _ => {} } } } let sel: HashSet = selected.iter().copied().collect(); let mut out = vec![]; - visit(&doc.layers, &sel, false, &mut out); + visit(&doc.layers, &sel, false, false, false, kinds, &mut out); out } @@ -740,7 +1211,14 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; out_err(&at, format!("{} {why}", id.0)) })?; - write(doc, &mut n, o, &at, admit)?; + // Type, images and symbol instances are only read: writing one would make it a path. + if input_kind(&n).is_some() && !o.kept { + return Err(out_err(&at, format!("{} is read-only", id.0))); + } + let kept = o.kept; + if !kept { + write(doc, &mut n, o, &at, admit)?; + } if !run.is_empty() { let nodes = std::mem::take(&mut run); match prev { @@ -748,7 +1226,9 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, None => plan.before.insert(id, nodes), }; } - plan.replace.insert(id, n); + if !kept { + plan.replace.insert(id, n); + } prev = Some(id); result.push(id); } @@ -774,7 +1254,10 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, 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..0a7b1d0d1 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -310,6 +310,476 @@ fn bad_stroke_styles_and_effect_records_are_refused() { } } +/// A `w` × `h` PNG whose pixels vary with their column, half transparent. +fn png(w: u32, h: u32) -> Vec { + 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, &[], usize::MAX).unwrap(); + assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out"); + assert!(read_only.is_empty()); + let all = filter_targets_with(&d, &[layer], &InputKind::ALL); + assert_eq!(all, every); + let images: Vec = ids.iter().copied().chain([embedded, linked]).collect(); + assert_eq!(filter_targets_with(&d, &[layer], &[InputKind::Image]), images); + assert_eq!(filter_targets_with(&d, &[star, ids[0]], &InputKind::ALL), [ids[0], star], "selected directly"); + d.node_mut(text).unwrap().visible = false; + d.node_mut(star).unwrap().locked = true; + assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat()); + let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance], usize::MAX).unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]); + assert_eq!(read_only.len(), 2); +} + +/// Type comes with its runs and their character attributes, one paragraph style per paragraph, its +/// kind and transform; type past [`MAX_INPUT_TEXT`] or [`MAX_INPUT_RUNS`] comes without text. +#[test] +fn declared_type_input_has_bounded_runs_and_paragraph_styles() { + use vectorcraft_doc::{CharStyle, ParaStyle, TextRun}; + let (mut d, _, _, [text, ..]) = mixed(); + let o = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + assert_eq!((&o["type"], &o["name"], &o["truncated"]), (&json!("type"), &json!("T"), &json!(false))); + assert_eq!(o["transform"], json!([1.0, 0.0, 0.0, 1.0, 5.0, 50.0])); + assert_eq!((&o["text"]["kind"], &o["text"]["vertical"]), (&json!("point"), &json!(false))); + assert_eq!(o["text"]["runs"], json!([{"text": "Hi\nthere", "style": serde_json::to_value(CharStyle::default()).unwrap()}])); + let spaced = ParaStyle { space_before: 6.0, ..Default::default() }; + assert_eq!(o["text"]["paragraphs"], json!([serde_json::to_value(ParaStyle::default()).unwrap(), serde_json::to_value(spaced).unwrap()])); + assert!(o.get("path").is_none() && o.get("fills").is_none(), "not made into outlines"); + let set_runs = |d: &mut Document, runs: Vec| { + if let NodeKind::Text(t) = &mut d.node_mut(text).unwrap().kind { + t.runs = runs; + t.paras.clear(); + } + }; + let run = |s: String| TextRun::new(s, CharStyle::default()); + for (runs, truncated) in [ + (vec![run("x".repeat(MAX_INPUT_TEXT))], false), + (vec![run("x".repeat(MAX_INPUT_TEXT - 1)), run("é".into())], true), + (vec![run("\n".repeat(MAX_INPUT_RUNS - 1))], false), + (vec![run("\n".repeat(MAX_INPUT_RUNS))], true), + ((0..MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), false), + ((0..=MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), true), + ] { + let n = runs.len(); + set_runs(&mut d, runs); + let o = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + assert_eq!(o["truncated"], json!(truncated), "{n} runs"); + let given = o["text"]["runs"].as_array().unwrap().len(); + assert_eq!((given, o["text"]["paragraphs"].as_array().unwrap().is_empty()), (if truncated { 0 } else { n }, truncated), "{n} runs"); + } +} + +/// An embedded image comes with its pixels when they are small; a linked image never does, and +/// nothing of its file's path is given. The color mode is read from the image. +#[test] +fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission() { + let (mut d, _, layer, [_, embedded, linked, ..]) = mixed(); + let e = encode_read_only(&d, d.node(embedded).unwrap()).unwrap(); + assert_eq!((&e["type"], &e["pixelSize"], &e["colorMode"]), (&json!("image"), &json!([2, 1]), &json!("rgb"))); + assert_eq!((&e["linked"], &e["pixelsOmitted"], &e["transform"]), (&json!(false), &json!(false), &json!([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]))); + let pixels = craft_vectorcraft_format::base64::decode(e["pixelsBase64"].as_str().unwrap()).unwrap(); + assert_eq!(pixels, [0, 0, 30, 128, 100, 0, 30, 128], "straight RGBA, row by row"); + let l = encode_read_only(&d, d.node(linked).unwrap()).unwrap(); + assert_eq!((&l["linked"], &l["pixelsOmitted"], &l["pixelSize"]), (&json!(true), &json!(true), &json!([4, 4]))); + assert!(l.get("pixelsBase64").is_none()); + assert!(!l.to_string().contains("private"), "the link's path isn't given: {l}"); + // Past the limit (443 × 443 fits it; 444 × 443 doesn't), and pixels of another size, are omitted. + for ((w, h), omitted) in [((443, 443), false), ((444, 443), true)] { + d.images.insert("big".into(), ImageBlob::png(png(w, h))); + let n = image_node(&mut d, "big", (w, h), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + let o = encode_read_only(&d, d.node(id).unwrap()).unwrap(); + assert_eq!((&o["pixelsOmitted"], o.get("pixelsBase64").is_some()), (&json!(omitted), !omitted), "{w} × {h}"); + } + let n = image_node(&mut d, "e", (3, 1), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "not the size it says"); + // A size whose pixel count overflows is omitted too. + let n = image_node(&mut d, "e", (u32::MAX, u32::MAX), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "too large to count"); + // Gray pixels, and data that isn't an image. + let mut gray = vec![]; + image::GrayImage::from_pixel(2, 2, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap(); + for (blob, mode) in [(ImageBlob::png(gray), "gray"), (ImageBlob::png(b"not an image".to_vec()), "unknown")] { + d.images.insert("x".into(), blob); + let n = image_node(&mut d, "x", (2, 2), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["colorMode"], json!(mode)); + } +} + +/// A CMYK image's pixels are its inks as the working CMYK profile shows them on the canvas: black +/// ink alone is the profile's black, not RGB black. +#[test] +fn cmyk_image_pixels_go_through_the_working_cmyk_profile() { + use vectorcraft_color::cms::{self, lab}; + let (mut d, _, layer, _) = mixed(); + let inks = vectorcraft_doc::cmyk::Inks::new(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 0]).unwrap(); + d.images.insert("k".into(), ImageBlob::cmyk_tiff(&inks).unwrap()); + let n = image_node(&mut d, "k", (2, 1), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + let o = encode_read_only(&d, d.node(id).unwrap()).unwrap(); + assert_eq!((&o["colorMode"], &o["pixelsOmitted"]), (&json!("cmyk"), &json!(false))); + let pixels = craft_vectorcraft_format::base64::decode(o["pixelsBase64"].as_str().unwrap()).unwrap(); + let shown = |c: [f32; 4]| lab::xyz_to_srgb(lab::lab_to_xyz(cms::active().cmyk_display_lab(c))).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let ([r, g, b], [r2, g2, b2]) = (shown([0.0, 0.0, 0.0, 1.0]), shown([0.0; 4])); + assert_eq!(pixels, [r, g, b, 255, r2, g2, b2, 255]); + assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile"); +} + +/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose +/// symbol the document lacks says so. +#[test] +fn declared_symbol_instance_keeps_identity_and_transform() { + let (d, _, _, [.., star, ghost]) = mixed(); + let s = encode_read_only(&d, d.node(star).unwrap()).unwrap(); + assert_eq!((&s["type"], &s["symbolId"], &s["overrides"]), (&json!("symbolInstance"), &json!("Star"), &json!({}))); + assert_eq!(s["transform"], json!([1.0, 0.0, 0.0, 1.0, 100.0, 0.0])); + assert!(s.get("missing").is_none() && s.get("path").is_none()); + let g = encode_read_only(&d, d.node(ghost).unwrap()).unwrap(); + assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true))); +} + +/// Pixels and type count against totals over the whole input, in order: an image whose pixels +/// don't fit what the images before it left comes with `pixelsOmitted` (its size checked before it +/// is decoded), and type whose runs and paragraphs don't fit comes with `truncated`. The read-only +/// objects returned are the ones in the input. +#[test] +fn input_totals_cover_images_and_type_in_order() { + let (mut d, _, layer, [text, embedded, ..]) = mixed(); + let copy = |d: &mut Document, id: NodeId| { + let mut n = d.node(id).unwrap().clone(); + n.id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, n).unwrap() + }; + let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded)); + let one = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + // The runs and paragraphs with the commas between them, without their brackets. + let size: usize = ["runs", "paragraphs"].iter().map(|k| one["text"][k].to_string().len() - 2).sum(); + // A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't. + let ids = [text, text2, embedded, image2, image3]; + let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size, input: usize::MAX }).unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let objects = v["objects"].as_array().unwrap(); + assert_eq!(objects.len(), 5); + let flags: Vec<&Value> = objects.iter().map(|o| if o["type"] == "type" { &o["truncated"] } else { &o["pixelsOmitted"] }).collect(); + assert_eq!(flags, [&json!(false), &json!(true), &json!(false), &json!(false), &json!(true)]); + assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none()); + assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]); + for o in objects { + assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap()))); + } + // Within the full totals every one of them is given. + let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap(); + let all = String::from_utf8(bytes).unwrap(); + assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}"); +} + +/// Every object counts toward the input's capacity in order, its id, bounds, transform and name +/// included: a symbol instance before type leaves the type less room, so text that fits the exact +/// size of the whole input comes with `truncated` one byte under it, and an image's pixels are +/// omitted the same way. +#[test] +fn object_fields_count_toward_the_input_capacity() { + let (d, ids, _, [text, embedded, _, star, _]) = mixed(); + for (order, flag) in [([ids[0], star, text], "truncated"), ([ids[0], star, embedded], "pixelsOmitted")] { + let (bytes, _) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap(); + let (exact, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len()).unwrap(); + assert_eq!(exact, bytes, "{flag}: the exact size fits"); + let (under, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len() - 1).unwrap(); + let v: Value = serde_json::from_slice(&under).unwrap(); + assert_eq!(v["objects"][2][flag], json!(true), "{flag}: one byte under"); + assert!(under.len() < bytes.len() - 1, "{flag}"); + } +} + +/// Type with fractional colors counts the bytes the input gives it: one byte under its exact size +/// it comes with `truncated`, not a limit. +#[test] +fn fractional_colors_count_as_the_input_writes_them() { + use vectorcraft_doc::{CharStyle, TextObject}; + let (mut d, ids, layer, _) = mixed(); + let style = CharStyle { fill: Paint::solid(Color::rgb(0.1, 0.2, 0.3)), ..CharStyle::default() }; + let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Hi", style)))); + let text = d.insert(Some(layer), usize::MAX, t).unwrap(); + let order = [ids[0], text]; + let (bytes, _) = encode_input_with(&d, &order, &[InputKind::Type], usize::MAX).unwrap(); + let (under, _) = encode_input_with(&d, &order, &[InputKind::Type], bytes.len() - 1).unwrap(); + let v: Value = serde_json::from_slice(&under).unwrap(); + assert_eq!(v["objects"][1]["truncated"], json!(true)); +} + +/// A read-only object whose fields other than its pixels and text alone don't fit what is left of +/// the input fails the run with a limit naming it and its size, rather than being left out. +#[test] +fn an_object_too_large_to_give_is_a_limit_naming_it() { + let (mut d, ids, _, [_, _, linked, star, _]) = mixed(); + d.node_mut(linked).unwrap().name = Some("n".repeat(4096)); + let (fits, _) = encode_input_with(&d, &[ids[0], star], &InputKind::ALL, usize::MAX).unwrap(); + let order = [ids[0], star, linked]; + let e = encode_input_with(&d, &order, &InputKind::ALL, fits.len() + 4096).unwrap_err(); + assert!(matches!(e, Error::Limit(_)), "{e}"); + // Measured with `pixelsOmitted` false and a comma before it. + let mut o = encode_read_only(&d, d.node(linked).unwrap()).unwrap(); + o["pixelsOmitted"] = json!(false); + let size = o.to_string().len() + 1; + assert_eq!(e.to_string(), format!("plug-in exceeded its memory budget (object {} needs {size} bytes of input, 4096 left)", linked.0)); + // A filter that doesn't list its kind is given the rest. + let (bytes, read_only) = encode_input_with(&d, &order, &[InputKind::SymbolInstance], fits.len() + 4096).unwrap(); + assert_eq!((bytes, read_only.len()), (fits, 1)); +} + +/// A path or compound path that doesn't fit what is left of the input fails the run with the limit +/// the runtime gives an input past its share, before anything after it is written; the exact size +/// still fits. +#[test] +fn a_path_past_the_input_capacity_is_the_runtime_limit() { + let (d, ids, _, [text, ..]) = mixed(); + let (bytes, _) = encode_input_with(&d, &ids, &[], usize::MAX).unwrap(); + assert_eq!(encode_input_with(&d, &ids, &[], bytes.len()).unwrap().0, bytes, "the exact size fits"); + let e = encode_input_with(&d, &[ids[0], ids[1], ids[2], text], &[InputKind::Type], bytes.len() - 1).unwrap_err(); + assert_eq!(e.to_string(), "plug-in exceeded its memory budget (the selection is too large to hand over)"); +} + +/// An output lists at most [`MAX_OBJECTS`] objects and must return every read-only one, so an input +/// with more read-only objects than that is a limit; exactly that many are given. +#[test] +fn more_read_only_objects_than_an_output_lists_is_a_limit() { + let (mut d, _, layer, [.., ghost]) = mixed(); + let mut ids = vec![ghost]; + for _ in 1..MAX_OBJECTS { + let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY }); + ids.push(d.insert(Some(layer), usize::MAX, n).unwrap()); + } + let (_, read_only) = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap(); + assert_eq!(read_only.len(), MAX_OBJECTS); + let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY }); + ids.push(d.insert(Some(layer), usize::MAX, n).unwrap()); + let e = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap_err(); + assert_eq!(e.to_string(), format!("plug-in exceeded its object budget (more than {MAX_OBJECTS} read-only objects)")); + // A filter that doesn't list the kind is given none of them. + assert!(encode_input_with(&d, &ids, &[], usize::MAX).unwrap().1.is_empty()); +} + +/// Objects that clip a clip group are left out, and so are those inside a group that clips one; +/// those it clips are given. This covers a filter that lists no `inputs` too. +#[test] +fn objects_that_clip_a_clip_group_are_not_inputs() { + let (mut d, _, layer, [text, embedded, ..]) = mixed(); + let (t, e) = (Arc::new(d.node(text).unwrap().clone()), Arc::new(d.node(embedded).unwrap().clone())); + let mut copy = |n: &Arc| { + let mut c = (**n).clone(); + c.id = d.alloc_id(); + Arc::new(c) + }; + let (t_source, e_source, t_inner, e_inner, t_clipped) = (copy(&t), copy(&e), copy(&t), copy(&e), copy(&t)); + let inner = Node::group(d.alloc_id(), vec![t_inner.clone(), e_inner.clone()]); + let mut by_group = Node::group(d.alloc_id(), vec![Arc::new(inner), t_clipped.clone()]); + by_group.set_clips(true); + let mut by_type = Node::group(d.alloc_id(), vec![t_source.clone(), e_source.clone()]); + by_type.set_clips(true); + let by_group = d.insert(Some(layer), usize::MAX, by_group).unwrap(); + let by_type = d.insert(Some(layer), usize::MAX, by_type).unwrap(); + let path = |d: &mut Document, x: f64| Arc::new(Node::path(d.alloc_id(), square(x), Appearance::default())); + let (part, clipped_by_compound, in_source, clipped_by_paths) = (path(&mut d, 0.0), path(&mut d, 10.0), path(&mut d, 20.0), path(&mut d, 30.0)); + let compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![part], rule: FillRule::NonZero }); + let mut by_compound = Node::group(d.alloc_id(), vec![Arc::new(compound), clipped_by_compound.clone()]); + by_compound.set_clips(true); + let source = Node::group(d.alloc_id(), vec![in_source]); + let mut by_paths = Node::group(d.alloc_id(), vec![Arc::new(source), clipped_by_paths.clone()]); + by_paths.set_clips(true); + let by_compound = d.insert(Some(layer), usize::MAX, by_compound).unwrap(); + let by_paths = d.insert(Some(layer), usize::MAX, by_paths).unwrap(); + let ids = |list: &[NodeId]| filter_targets_with(&d, list, &InputKind::ALL); + assert_eq!(ids(&[by_type]), [e_source.id], "the type clips; the image is clipped"); + assert_eq!(ids(&[by_group]), [t_clipped.id], "the group's type and image clip"); + assert_eq!(ids(&[by_compound]), [clipped_by_compound.id], "the compound path clips"); + assert_eq!(ids(&[by_paths]), [clipped_by_paths.id], "the group's path clips"); + assert_eq!(filter_targets(&d, &[by_compound]), [clipped_by_compound.id], "a filter without inputs"); + assert_eq!(filter_targets(&d, &[by_paths]), [clipped_by_paths.id], "a filter without inputs"); +} + +/// A TIFF with more strips and planes than the decoder can count comes as `"unknown"`, without its +/// pixels, and never reaches the decoder. +#[test] +fn a_tiff_too_large_to_count_is_unknown_and_has_no_pixels() { + let entries: [(u16, u16, u32); 9] = + [(256, 4, 1), (257, 4, 1 << 31 | 1), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)]; + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + for (tag, ty, v) in entries { + tiff.extend(tag.to_le_bytes()); + tiff.extend(ty.to_le_bytes()); + tiff.extend(1u32.to_le_bytes()); + tiff.extend(v.to_le_bytes()); + } + tiff.extend([0; 4]); + let (mut d, _, layer, _) = mixed(); + d.images.insert("big".into(), ImageBlob::new("image/tiff", tiff)); + let big = image_node(&mut d, "big", (1, 1), None); + let big = d.insert(Some(layer), usize::MAX, big).unwrap(); + let (bytes, _) = encode_input_with(&d, &[big], &[InputKind::Image], usize::MAX).unwrap(); + let input: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(input["objects"][0]["colorMode"], "unknown"); + assert_eq!(input["objects"][0]["pixelsOmitted"], true); +} + +/// A field a plug-in adds to a read-only object is named in the refusal with its control and +/// direction characters escaped, and cut short. +#[test] +fn a_changed_field_is_named_escaped_and_short() { + let (d, _, layer, [text, ..]) = mixed(); + let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]); + let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type], usize::MAX).unwrap(); + let mut v: Value = serde_json::from_slice(&bytes).unwrap(); + let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap(); + let key = format!("\u{202E}{}", "k".repeat(500)); + v["objects"][at][key.as_str()] = json!(1); + let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string(); + let shown = e.split('\'').nth(1).unwrap_or_default(); + assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}"); + assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}"); +} + +/// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by +/// value), and they stay as they are, new objects listed after one going just above it. Any change, +/// an object left out or listed twice, and a new object of such a kind are refused before anything +/// changes. +#[test] +fn read_only_kind_field_and_omission_refuse() { + let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed(); + let targets = filter_targets_with(&d, &[layer], &InputKind::ALL); + let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL, usize::MAX).unwrap(); + assert_eq!(read_only.len(), 5); + let input: Value = serde_json::from_slice(&bytes).unwrap(); + let before = d.clone(); + let out = decode_output_with(&bytes, &read_only).unwrap(); + assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5); + apply_output(&mut d, &targets, out.objects, None, &keep).unwrap(); + assert_eq!(d, before, "an exact echo changes nothing"); + // A filter that doesn't list `inputs` could never hand them back. + assert!(decode_output(&bytes).is_err()); + // Numbers written another way are the same numbers. + let mut echo = input.clone(); + echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]); + assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok()); + // So is an id written as a float; a fractional one names no object. + let id = input["objects"][3]["id"].as_u64().unwrap(); + let mut echo = input.clone(); + echo["objects"][3]["id"] = json!(id as f64); + let out = decode_output_with(echo.to_string().as_bytes(), &read_only).unwrap(); + assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5); + echo["objects"][3]["id"] = json!(id as f64 + 0.5); + assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_err()); + // `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type. + let ro = |id: NodeId| json!({"id": id.0}); + let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]}); + let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap(); + assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]); + assert_eq!(made.len(), 6); + assert!(ids.iter().all(|id| d.node(*id).is_none())); + assert_eq!(d.node(text), before.node(text), "the type is as it was"); + // Refused, naming the object and the field. + let t = text.0; + let changed = |f: &dyn Fn(&mut Value)| { + let mut v = input.clone(); + f(&mut v); + v + }; + for (out, says) in [ + ( + changed(&|v| v["objects"][3]["text"]["runs"][0]["text"] = json!("Bye")), + format!("objects[3] (id {t}): read-only field 'text.runs[0].text' changed"), + ), + (changed(&|v| v["objects"][3]["type"] = json!("path")), format!("(id {t}): read-only field 'type' changed")), + (changed(&|v| v["objects"][3]["opacity"] = json!(0.5)), format!("(id {t}): read-only field 'opacity' changed")), + (changed(&|v| v["objects"][3]["transform"][4] = json!(6)), format!("(id {t}): read-only field 'transform[4]' changed")), + ( + changed(&|v| { + v["objects"][4].as_object_mut().unwrap().remove("pixelsBase64"); + }), + format!("(id {}): read-only field 'pixelsBase64' changed", embedded.0), + ), + (changed(&|v| v["objects"][6]["symbolId"] = json!("Moon")), format!("(id {}): read-only field 'symbolId' changed", star.0)), + ( + changed(&|v| { + v["objects"].as_array_mut().unwrap().remove(3); + }), + format!("objects (id {t}): a read-only object was left out"), + ), + (changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"id": ghost.0}))), format!("objects[8] (id {}): is listed twice", ghost.0)), + ( + changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"type": "type", "path": square(0.0)}))), + "`type` must be \"path\" or \"compound\"".to_string(), + ), + ] { + let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string(); + assert!(e.contains(&says), "{says}: {e}"); + } + // Writing one or leaving one out is refused when applying too, whatever decoded the output. + let mut d = before.clone(); + let write = vec![OutObject { id: Some(text), name: Some("Renamed".into()), ..OutObject::default() }]; + let e = apply_output(&mut d, &[text], write, None, &keep).unwrap_err().to_string(); + assert!(e.contains("is read-only"), "{e}"); + let e = apply_output(&mut d, &[text], vec![], None, &keep).unwrap_err().to_string(); + assert!(e.contains("a read-only object was left out"), "{e}"); + assert_eq!(d, before); +} + /// A filter's `documentData`: absent keeps what the plug-in keeps, `null` forgets it, any other JSON /// of at most [`MAX_DOCUMENT_DATA`] bytes is kept, and more refuses the whole output. #[test] diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index a17d9e95d..778b19cee 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -22,6 +22,9 @@ pub const API_LEVEL: u32 = 2; /// `settings` and `cache` permissions they need. pub const PROGRESS_API: u32 = 2; +/// The API level that adds a filter's `inputs` ([`crate::InputKind`]). +pub const INPUTS_API: u32 = 2; + /// Most permissions a manifest may list. pub const MAX_PERMISSIONS: usize = 16; diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 1f09ddfe5..b2f830778 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -90,7 +90,9 @@ pub fn list() -> Vec> { /// Uninstalls `id`; false if it wasn't installed. pub fn remove(id: &str) -> bool { - let gone = PLUGINS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some(); + // The revision moves while the write lock is held, as in `install`. + let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); + let gone = plugins.remove(id).is_some(); if gone { REVISION.fetch_add(1, Ordering::Relaxed); } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 7eba48485..e6278fc99 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -32,6 +32,9 @@ pub const HEAVY_FUEL: u64 = 1_000_000_000; /// Wall-clock budget of a heavy filter's run, in milliseconds (native builds). pub const HEAVY_WALL_TIME_MS: u64 = 300_000; +/// The limit a run reports when its input and parameters together are past half its memory. +pub(crate) const SELECTION_TOO_LARGE: &str = "memory budget (the selection is too large to hand over)"; + /// A heavy run's budget as people read it: (minutes it may take, times the instructions a light /// run gets). pub fn heavy_terms() -> (u64, u64) { @@ -311,7 +314,7 @@ impl Plugin { fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result> { let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2); - total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; + total.ok_or_else(|| Error::Limit(SELECTION_TOO_LARGE.into()))?; let deadline = Deadline::new(limits.wall_time_ms); let m = &self.manifest; let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id)); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 4a48e291e..7783e205f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -654,6 +654,7 @@ impl PluginIntField { author: String::new(), api: 1, permissions: None, + inputs: Vec::new(), params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 8afa35c83..ed5c348a9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5955,6 +5955,13 @@ digits Malayalam Malaiàlam @msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}` @msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades +@msg only a filter declares `inputs` només un filtre declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anomena un tipus d’objecte desconegut `{shown}`: feu servir type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` inclou `{shown}` dues vegades +@msg `inputs` needs `api` 2 or later `inputs` requereix `api` 2 o posterior +@msg `inputs` needs the permission `doc.read` `inputs` requereix el permís `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) pressupost de memòria (l’objecte {_1} necessita {size} bytes d’entrada, en queden {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) pressupost d’objectes (més de {MAX_OBJECTS} objectes de només lectura) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix @msg unknown permission `{shown}` permís desconegut `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3268e4639..8ac035945 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5947,6 +5947,13 @@ axis Y Axis osa Y @msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}` @msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát +@msg only a filter declares `inputs` `inputs` deklaruje pouze filtr +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` uvádí neznámý druh objektu `{shown}`: použijte type, image nebo symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` uvádí `{shown}` dvakrát +@msg `inputs` needs `api` 2 or later `inputs` vyžaduje `api` 2 nebo vyšší +@msg `inputs` needs the permission `doc.read` `inputs` vyžaduje oprávnění `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) rozpočet paměti (objekt {_1} potřebuje {size} bajtů vstupu, zbývá {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) rozpočet objektů (více než {MAX_OBJECTS} objektů jen pro čtení) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje @msg unknown permission `{shown}` neznámé oprávnění `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 848364280..9f7dba47e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5530,6 +5530,13 @@ axis Y Axis Y-Achse @msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}` @msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt +@msg only a filter declares `inputs` nur ein Filter deklariert `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nennt eine unbekannte Objektart `{shown}`: verwenden Sie type, image oder symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` führt `{shown}` doppelt auf +@msg `inputs` needs `api` 2 or later `inputs` erfordert `api` 2 oder höher +@msg `inputs` needs the permission `doc.read` `inputs` erfordert die Berechtigung `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) Speicherbudget (Objekt {_1} benötigt {size} Byte Eingabe, {left} verbleiben) +@msg object budget (more than {MAX_OBJECTS} read-only objects) Objektbudget (mehr als {MAX_OBJECTS} schreibgeschützte Objekte) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index fc6782c31..d5eb542ad 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5898,6 +5898,13 @@ axis Y Axis Άξονας Y @msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}` @msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές +@msg only a filter declares `inputs` μόνο ένα φίλτρο δηλώνει `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance το `inputs` αναφέρει άγνωστο είδος αντικειμένου `{shown}`: χρησιμοποιήστε type, image ή symbolInstance +@msg `inputs` lists `{shown}` twice το `inputs` αναφέρει το `{shown}` δύο φορές +@msg `inputs` needs `api` 2 or later το `inputs` απαιτεί `api` 2 ή νεότερο +@msg `inputs` needs the permission `doc.read` το `inputs` απαιτεί το δικαίωμα `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) προϋπολογισμός μνήμης (το αντικείμενο {_1} χρειάζεται {size} byte εισόδου, απομένουν {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) προϋπολογισμός αντικειμένων (περισσότερα από {MAX_OBJECTS} αντικείμενα μόνο για ανάγνωση) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f27548d48..b7a6cd712 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5889,6 +5889,13 @@ axis Y Axis Eje Y @msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}` @msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces +@msg only a filter declares `inputs` solo un filtro declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nombra un tipo de objeto desconocido `{shown}`: use type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` incluye `{shown}` dos veces +@msg `inputs` needs `api` 2 or later `inputs` requiere `api` 2 o posterior +@msg `inputs` needs the permission `doc.read` `inputs` requiere el permiso `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) presupuesto de memoria (el objeto {_1} necesita {size} bytes de entrada, quedan {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) presupuesto de objetos (más de {MAX_OBJECTS} objetos de solo lectura) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede @msg unknown permission `{shown}` permiso desconocido `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 122af6088..ee65d5c41 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5977,6 +5977,13 @@ axis Y Axis Y-akseli @msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta @msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti +@msg only a filter declares `inputs` vain suodatin voi määrittää `inputs`-avaimen +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nimeää tuntemattoman objektilajin `{shown}`: käytä arvoa type, image tai symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` luettelee kohteen `{shown}` kahdesti +@msg `inputs` needs `api` 2 or later `inputs` edellyttää `api`-tasoa 2 tai uudempaa +@msg `inputs` needs the permission `doc.read` `inputs` edellyttää käyttöoikeutta `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) muistibudjetti (objekti {_1} tarvitsee {size} tavua syötettä, jäljellä {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) objektibudjetti (yli {MAX_OBJECTS} vain luku -objektia) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ea78178de..e2654b645 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5943,6 +5943,13 @@ axis Y Axis Axe Y @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` @msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg only a filter declares `inputs` seul un filtre déclare `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` désigne un type d’objet inconnu `{shown}` : utilisez type, image ou symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` mentionne `{shown}` deux fois +@msg `inputs` needs `api` 2 or later `inputs` nécessite `api` 2 ou plus +@msg `inputs` needs the permission `doc.read` `inputs` nécessite l’autorisation `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) budget mémoire (l’objet {_1} nécessite {size} octets d’entrée, il en reste {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) budget d’objets (plus de {MAX_OBJECTS} objets en lecture seule) @msg unknown permission `{shown}` autorisation inconnue `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index de53671c4..02ffe5525 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5286,6 +5286,13 @@ antiAlias Sharp शार्प @msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती @msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है +@msg only a filter declares `inputs` केवल फ़िल्टर `inputs` घोषित करता है +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` में अज्ञात ऑब्जेक्ट प्रकार `{shown}` है: type, image या symbolInstance का उपयोग करें +@msg `inputs` lists `{shown}` twice `inputs` में `{shown}` दो बार सूचीबद्ध है +@msg `inputs` needs `api` 2 or later `inputs` के लिए `api` 2 या बाद का स्तर आवश्यक है +@msg `inputs` needs the permission `doc.read` `inputs` के लिए अनुमति `doc.read` आवश्यक है +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) मेमोरी बजट (ऑब्जेक्ट {_1} को इनपुट के {size} बाइट चाहिए, {left} शेष हैं) +@msg object budget (more than {MAX_OBJECTS} read-only objects) ऑब्जेक्ट बजट ({MAX_OBJECTS} से अधिक केवल-पठन ऑब्जेक्ट) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index bf14f3ccb..c13e4a335 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5930,6 +5930,13 @@ axis Y Axis Y tengely @msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre @msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel +@msg only a filter declares `inputs` `inputs` kulcsot csak szűrő adhat meg +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance az `inputs` ismeretlen objektumfajtát nevez meg: `{shown}`; használja a type, image vagy symbolInstance értéket +@msg `inputs` lists `{shown}` twice az `inputs` kétszer sorolja fel ezt: `{shown}` +@msg `inputs` needs `api` 2 or later az `inputs` használatához `api` 2 vagy újabb szükséges +@msg `inputs` needs the permission `doc.read` az `inputs` használatához a(z) `doc.read` engedély szükséges +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) memóriakeret (a(z) {_1} objektumnak {size} bájt bemenet kell, {left} maradt) +@msg object budget (more than {MAX_OBJECTS} read-only objects) objektumkeret (több mint {MAX_OBJECTS} csak olvasható objektum) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8d557366c..37237f613 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5564,6 +5564,13 @@ axis Y Axis Asse Y @msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}` @msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte +@msg only a filter declares `inputs` solo un filtro dichiara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` indica un tipo di oggetto sconosciuto `{shown}`: usa type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` elenca `{shown}` due volte +@msg `inputs` needs `api` 2 or later `inputs` richiede `api` 2 o successivo +@msg `inputs` needs the permission `doc.read` `inputs` richiede l’autorizzazione `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) budget di memoria (l’oggetto {_1} richiede {size} byte di input, ne restano {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) budget di oggetti (più di {MAX_OBJECTS} oggetti di sola lettura) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4ccf44b63..b3b12f40a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5629,6 +5629,13 @@ axis Y Axis Y 軸 @msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません @msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています +@msg only a filter declares `inputs` `inputs` を宣言できるのはフィルターだけです +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` に不明なオブジェクトの種類 `{shown}` があります。type、image、symbolInstance のいずれかを使ってください +@msg `inputs` lists `{shown}` twice `inputs` に `{shown}` が 2 回指定されています +@msg `inputs` needs `api` 2 or later `inputs` には `api` 2 以上が必要です +@msg `inputs` needs the permission `doc.read` `inputs` には権限 `doc.read` が必要です +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) メモリ予算 (オブジェクト {_1} には {size} バイトの入力が必要ですが、残りは {left} です) +@msg object budget (more than {MAX_OBJECTS} read-only objects) オブジェクト予算 ({MAX_OBJECTS} 個を超える読み取り専用オブジェクト) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません @msg unknown permission `{shown}` 不明な権限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0118e427b..54123e003 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5870,6 +5870,13 @@ axis Y Axis Y ღერძი @msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს @msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი +@msg only a filter declares `inputs` `inputs`-ის გამოცხადება მხოლოდ ფილტრს შეუძლია +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` უცნობ ობიექტის სახეობას ასახელებს `{shown}`: გამოიყენეთ type, image ან symbolInstance +@msg `inputs` lists `{shown}` twice `inputs`-ში `{shown}` ორჯერაა ჩამოთვლილი +@msg `inputs` needs `api` 2 or later `inputs`-ს სჭირდება `api` 2 ან უფრო ახალი +@msg `inputs` needs the permission `doc.read` `inputs`-ს სჭირდება ნებართვა `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) მეხსიერების ბიუჯეტი (ობიექტს {_1} სჭირდება შეყვანის {size} ბაიტი, დარჩენილია {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) ობიექტების ბიუჯეტი ({MAX_OBJECTS}-ზე მეტი მხოლოდ წასაკითხი ობიექტი) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ccdc29216..db76438cd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5525,6 +5525,13 @@ axis Y Axis Y축 @msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다 @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다 @msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 +@msg only a filter declares `inputs` 필터만 `inputs`를 선언할 수 있습니다 +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs`에 알 수 없는 개체 종류 `{shown}`이(가) 있습니다. type, image 또는 symbolInstance를 사용하세요 +@msg `inputs` lists `{shown}` twice `inputs`에 `{shown}`이(가) 두 번 나열되어 있습니다 +@msg `inputs` needs `api` 2 or later `inputs`에는 `api` 2 이상이 필요합니다 +@msg `inputs` needs the permission `doc.read` `inputs`에는 `doc.read` 권한이 필요합니다 +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) 메모리 예산(개체 {_1}에 입력 {size}바이트가 필요하지만 {left}바이트 남음) +@msg object budget (more than {MAX_OBJECTS} read-only objects) 개체 예산(읽기 전용 개체 {MAX_OBJECTS}개 초과) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6b85ece72..fb5b1f74a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5801,6 +5801,13 @@ axis Y Axis Oś Y @msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}` @msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy +@msg only a filter declares `inputs` tylko filtr deklaruje `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` wymienia nieznany rodzaj obiektu `{shown}`: użyj type, image lub symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` wymienia `{shown}` dwa razy +@msg `inputs` needs `api` 2 or later `inputs` wymaga `api` 2 lub nowszego +@msg `inputs` needs the permission `doc.read` `inputs` wymaga uprawnienia `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) budżet pamięci (obiekt {_1} wymaga {size} bajtów danych wejściowych, pozostało {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) budżet obiektów (ponad {MAX_OBJECTS} obiektów tylko do odczytu) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 33eaae54b..5742d6207 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5862,6 +5862,13 @@ axis Y Axis Eixo Y @msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}` @msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes +@msg only a filter declares `inputs` somente um filtro declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` cita um tipo de objeto desconhecido `{shown}`: use type, image ou symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` lista `{shown}` duas vezes +@msg `inputs` needs `api` 2 or later `inputs` exige `api` 2 ou posterior +@msg `inputs` needs the permission `doc.read` `inputs` exige a permissão `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) orçamento de memória (o objeto {_1} precisa de {size} bytes de entrada, restam {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) orçamento de objetos (mais de {MAX_OBJECTS} objetos somente leitura) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede @msg unknown permission `{shown}` permissão desconhecida `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4f7cd74ad..f9993db47 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5558,6 +5558,13 @@ axis Y Axis Ось Y @msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}` @msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды +@msg only a filter declares `inputs` `inputs` может объявлять только фильтр +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указан неизвестный вид объекта `{shown}`: используйте type, image или symbolInstance +@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано дважды +@msg `inputs` needs `api` 2 or later для `inputs` нужен `api` 2 или выше +@msg `inputs` needs the permission `doc.read` для `inputs` нужно разрешение `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) бюджет памяти (объекту {_1} нужно {size} байт входных данных, осталось {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет объектов (более {MAX_OBJECTS} объектов только для чтения) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт @msg unknown permission `{shown}` неизвестное разрешение `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 63da6d174..d39277765 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5970,6 +5970,13 @@ axis Y Axis Y-axel @msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}` @msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger +@msg only a filter declares `inputs` bara ett filter anger `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anger en okänd objekttyp `{shown}`: använd type, image eller symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` anger `{shown}` två gånger +@msg `inputs` needs `api` 2 or later `inputs` kräver `api` 2 eller senare +@msg `inputs` needs the permission `doc.read` `inputs` kräver behörigheten `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) minnesbudget (objekt {_1} behöver {size} byte indata, {left} återstår) +@msg object budget (more than {MAX_OBJECTS} read-only objects) objektbudget (fler än {MAX_OBJECTS} skrivskyddade objekt) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ebf411e88..696c8f7ab 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5903,6 +5903,13 @@ axis Y Axis Y ekseni @msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz @msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş +@msg only a filter declares `inputs` `inputs` yalnızca bir filtrede bildirilebilir +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` bilinmeyen bir nesne türü belirtiyor: `{shown}`; type, image veya symbolInstance kullanın +@msg `inputs` lists `{shown}` twice `inputs` `{shown}` değerini iki kez listeliyor +@msg `inputs` needs `api` 2 or later `inputs` için `api` 2 veya üstü gerekir +@msg `inputs` needs the permission `doc.read` `inputs` için `doc.read` izni gerekir +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) bellek bütçesi (nesne {_1} için {size} bayt girdi gerekiyor, {left} kaldı) +@msg object budget (more than {MAX_OBJECTS} read-only objects) nesne bütçesi ({MAX_OBJECTS} adetten fazla salt okunur nesne) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez @msg unknown permission `{shown}` bilinmeyen izin `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0906c5c83..bd7e8eb39 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5544,6 +5544,13 @@ axis Y Axis Вісь Y @msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}` @msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі +@msg only a filter declares `inputs` `inputs` може оголошувати лише фільтр +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указано невідомий вид об’єкта `{shown}`: використовуйте type, image або symbolInstance +@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано двічі +@msg `inputs` needs `api` 2 or later для `inputs` потрібен `api` 2 або новіший +@msg `inputs` needs the permission `doc.read` для `inputs` потрібен дозвіл `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) бюджет пам’яті (об’єкту {_1} потрібно {size} байтів вхідних даних, залишилося {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет об’єктів (понад {MAX_OBJECTS} об’єктів лише для читання) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає @msg unknown permission `{shown}` невідомий дозвіл `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f9b97f547..22b4c9573 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5545,6 +5545,13 @@ axis Y Axis Trục Y @msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}` @msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần +@msg only a filter declares `inputs` chỉ bộ lọc mới khai báo `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nêu một loại đối tượng không xác định `{shown}`: dùng type, image hoặc symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` liệt kê `{shown}` hai lần +@msg `inputs` needs `api` 2 or later `inputs` cần `api` 2 trở lên +@msg `inputs` needs the permission `doc.read` `inputs` cần quyền `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) ngân sách bộ nhớ (đối tượng {_1} cần {size} byte đầu vào, còn lại {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) ngân sách đối tượng (hơn {MAX_OBJECTS} đối tượng chỉ đọc) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó @msg unknown permission `{shown}` quyền không xác định `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 63f6e5d67..04d01ee08 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5785,6 +5785,13 @@ axis Y Axis Y 轴 @msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具 @msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 +@msg only a filter declares `inputs` 只有滤镜才能声明 `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的对象类型 `{shown}`:请使用 type、image 或 symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` 列出了两次 `{shown}` +@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高 +@msg `inputs` needs the permission `doc.read` `inputs` 需要权限 `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) 内存预算(对象 {_1} 需要 {size} 字节的输入,剩余 {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) 对象预算(超过 {MAX_OBJECTS} 个只读对象) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 @msg unknown permission `{shown}` 未知权限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 55d950f91..4ed012db5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5582,6 +5582,13 @@ axis Y Axis Y 軸 @msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組 @msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 +@msg only a filter declares `inputs` 只有濾鏡才能宣告 `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的物件類型 `{shown}`:請使用 type、image 或 symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` 列出了兩次 `{shown}` +@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高 +@msg `inputs` needs the permission `doc.read` `inputs` 需要權限 `doc.read` +@msg memory budget (object {_1} needs {size} bytes of input, {left} left) 記憶體預算(物件 {_1} 需要 {size} 位元組的輸入,剩餘 {left}) +@msg object budget (more than {MAX_OBJECTS} read-only objects) 物件預算(超過 {MAX_OBJECTS} 個唯讀物件) @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 @msg unknown permission `{shown}` 未知權限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 333df502c..2a8885019 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -313,6 +313,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS and `.ai` editing data: a path or compound path whose art style stacks several fills and strokes (solid colours and gradients, each with its own visibility, opacity and blend mode, under effects any object takes) comes in as itself with that appearance instead of its drawn look. Each gradient must be its own swatch, placed as the drawn look places it; a stack that repeats a swatch, or whose look doesn't paint its gradients, keeps the drawn look, as do fills and strokes with effects of their own. | | 2026-11-06 | minor | `.ai` and EPS import: Art brushes whose objects are in the older form (first number 1, one field fewer and 8 numbers at the end) come into the Brushes panel with their art instead of being left out with a note. In a survey of 302 users' EPS files (used locally, not committed), 652 Art brushes in 188 of the 197 files with the note were left out this way; afterwards 55 files have the note, all for Pattern brushes. Test `an_older_art_brush_comes_in_with_its_art` (crate `vectorcraft-eps`); scores unchanged | | 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. | | 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 9b373aea3..40a1e237d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -464,7 +464,6 @@ opens the picker through `run_command` with the remote backend. | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version`, `preview` or `precision`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | -| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -6643,7 +6642,17 @@ WebAssembly plug-ins ([plugins.md](plugins.md)) are reached through commands; th them again at startup (`plugin.remove` deletes them there): `plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one -undo step; it may change, delete or add objects, and the result is selected). A live-effect plug-in is an effect with +undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` +(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so +`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of +pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`). Every object counts +toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included: pixels and runs that +don't fit come the same way, and an object whose other fields don't fit makes `plugin.run` return a memory-budget error +naming the object and its size, with nothing changed. A path or compound path that doesn't fit makes it return the +memory-budget error `the selection is too large to hand over`, and more than 100,000 of these objects make it return +an object-budget error. Its output returns +each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the +field, and the document and undo history are unchanged. A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and `effect.list` lists it. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api` and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them diff --git a/docs/plugins.md b/docs/plugins.md index 6fc254fdd..af00957de 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -224,9 +224,12 @@ capability model proposed for PhotoCraft plug-ins `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. - `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host - imports](#host-imports-api-level-2) and a live effect's [`paintOutput`](#painted-live-effects). A higher one is - refused. `plugin.list` and `plugin.info` give it as `api`. A level 2 effect that declares `paintOutput` needs no - other permission than `doc.read` and `effect.render`. + imports](#host-imports-api-level-2), a filter's `inputs` ([reading type, images and + symbols](#reading-type-images-and-symbols)) and a live effect's [`paintOutput`](#painted-live-effects). A higher one + is refused. `plugin.list` and `plugin.info` give it as `api`. `inputs` is not a permission: the objects it asks for + are read under `doc.read`, which a filter that lists `inputs` must list in `permissions`, so installing it asks the + user to grant them. A level 2 effect that declares `paintOutput` needs no other permission than `doc.read` and + `effect.render`. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its @@ -379,6 +382,11 @@ A live effect runs while the art draws, so keep the entries it uses small. filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. - `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names ([permissions](#permissions)); without it, what its kind implies. +- `inputs` (filters at `api` 2 only): the kinds of object besides paths and compound paths the filter reads, any of + `"type"`, `"image"` and `"symbolInstance"`, each at most once (`"inputs": ["type", "image"]`). Left out or `[]`, the + filter gets paths and compound paths alone, as before. An unknown, repeated or wrongly typed entry, `inputs` on + another kind, below `api` 2, or without a `permissions` list that holds `doc.read`, is refused when the plug-in is + installed, and so is `api` 2 on hosts from before it. - `paintOutput` (live effects at `api` 2 only): `true` makes the effect return [painted pieces](#painted-live-effects) instead of geometry alone. `true` is the only value the key takes; `false`, `null`, another type, another kind or `api` 1 is refused when the module is installed, and the error names `paintOutput`. Without the key the effect returns @@ -429,8 +437,9 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet `input` is UTF-8 JSON: `{"objects": [...]}`. An object filter gets the selected paths and compound paths in paint order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound -guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never -touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one +guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), +which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and +symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry, unless it declares `paintOutput` ([painted live @@ -479,6 +488,65 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON), `plugin.` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed. - `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). +### Reading type, images and symbols + +A filter whose manifest lists `inputs` also gets the selected objects of those kinds, in paint order among the paths +and under the same rules (hidden and locked ones, objects that clip a clip group, alone or inside a group that clips +it, and anything inside a symbol, a placed document or a live object are left out). They are never turned into +outlines, rectangles or the symbol's art. Each has `id`, `type`, `name` (when it has one), `bounds` (`null` until the +object has been laid out or drawn) and +`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. +In this example the character and paragraph styles are cut short; a filter gets every attribute: + +```json +{"objects": [ + {"id": 17, "type": "type", "bounds": [10, 38, 52, 52], "transform": [1, 0, 0, 1, 10, 50], + "text": {"kind": "point", "vertical": false, + "runs": [{"text": "Hello\nthere", "style": {"font_family": "Source Sans 3", "size": 12}}], + "paragraphs": [{"justify": "Left"}, {"justify": "Center"}]}, + "truncated": false}, + {"id": 18, "type": "image", "bounds": [0, 0, 4, 2], "transform": [2, 0, 0, 2, 0, 0], "pixelSize": [2, 1], + "colorMode": "rgb", "linked": false, "pixelsOmitted": false, "pixelsBase64": "AAAegGQAHoA="}, + {"id": 19, "type": "symbolInstance", "bounds": [100, 0, 110, 10], "transform": [1, 0, 0, 1, 100, 0], + "symbolId": "Star", "overrides": {}} +]} +``` + +- Type (`"type"`): `text.kind` is `"point"`, `"area"` or `"path"`; `runs` are its text runs with their character + attributes as the native file stores them (`inline` when a run is an inline graphic), and `paragraphs` holds one + paragraph style per paragraph (the paragraphs are split at `\n`). No font data or glyph outlines are given. Type + whose runs hold more than 256 KiB of UTF-8 text, or more than 4,096 runs or paragraphs, comes with `truncated: + true` and empty `runs` and `paragraphs`. The runs and paragraphs of all the type in one input take at most 16 MiB + of JSON: type whose runs and paragraphs don't fit what the type before it in paint order left comes with + `truncated: true` too. +- An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or + `"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443 + pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and + `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile. The pixels of all the + images in one input take at most 16 MiB in base64. A larger image, one whose pixels don't fit what the images before + it in paint order left, and a linked image (`linked: true`) have + `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. The host never + reads a linked image's file for a filter and never gives its path. +- A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the + document has no symbol of that name. `overrides` is `{}`. + +These objects are read only. The output must list each of them exactly once, as `{"id": 17}` alone or exactly as it +was given (numbers compare by value, so `1` and `1.0` are the same). A changed or added field, one left out, an object +listed twice or left out, and a new object with one of these types are refused, and the document and undo history +stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only +object was left out`. The field is named with control and bidirectional characters escaped (`\u{202E}`), cut to 64 +characters. New objects listed after one of them go just above it. + +Every object in the input counts toward the half of the memory cap that the input and the parameters share, in paint +order, with its id, bounds, transform and name. Type whose runs and paragraphs don't fit what is left comes with +`truncated: true`, and an image whose pixels don't fit comes with `pixelsOmitted: true`. When the other fields of one +of these objects don't fit what is left, the run fails before the plug-in starts, naming the object and the bytes it +needs: `plug-in exceeded its memory budget (object 17 needs 4200 bytes of input, 1000 left)`. A path or compound path +that doesn't fit fails it with `plug-in exceeded its memory budget (the selection is too large to hand over)` before +the objects after it are written. An output lists at most 100,000 objects, so a run given more than 100,000 of these +objects fails with `plug-in exceeded its object budget (more than 100000 read-only objects)`. An echo of them counts +toward the 64 MiB output cap. + ### Parameters `params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_context`: @@ -632,5 +700,6 @@ per change of their input, parameters or paints (results are cached), not per fr importers that place files (File › Place reads only the built-in formats) or make type, images, layers or artboards. - Live effects that change opacity, blending or effects (at `api` 2 one may [paint](#painted-live-effects) the pieces - it returns), and filters on type, images or symbols. + it returns), and filters that change type, images or symbol instances (a filter at `api` 2 reads them, see + [`inputs`](#reading-type-images-and-symbols)). - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 658be7b9d..97107f9e8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1571,6 +1571,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.