From e88d1b6380d4efabdb681c9572b63550ee5ab5c7 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:34:10 -0500 Subject: [PATCH 01/18] EPS/.ai editing data: paths keep art styles of several fills and strokes live Only type kept an art style that stacks several fills or strokes, or gives one its own opacity or blend mode, as a live appearance; paths came in as their drawn look. A path or compound path now takes such a style (solid colours and gradients, strokes without a brush, no effects on the items, object effects any object takes that don't move the outline) when every gradient is its own swatch painted by the drawn look, which gives each its placement. Anything else keeps the drawn look. --- crates/eps/src/import/ai/read/ops.rs | 17 ++++++++- crates/eps/src/import/ai/style.rs | 16 +++++++- crates/eps/src/import/ai/tests.rs | 56 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index dc3089024..30637f181 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -985,10 +985,12 @@ 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 { .. }) - || (!slot && !st.simple) + || (!slot && !st.simple && !stacked) || (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, .. }))) @@ -1003,6 +1005,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 { diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 0e1a52a6f..31c6f9808 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -40,7 +40,8 @@ //! with a pattern or gradient too; strokes with a Calligraphic brush 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; @@ -141,8 +142,13 @@ pub(super) struct ArtStyle { /// Feather, Round Corners, Scribble, Convert to Shape, the Path effects (Offset Path, Outline Object, Outline Stroke) /// 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 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`]. @@ -1334,6 +1340,12 @@ fn style(filters: &[Filter]) -> ArtStyle { && (!st.moves_outline() || st.effects.len() == 1) // Pathfinder effects on a group with no fills or strokes of its own (`styled` checks the group). && (!st.pathfinder || st.items.is_empty()); + st.stacked = 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 c883719e3..f6b9bbd99 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4911,6 +4911,62 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() { 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); + // 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); +} + /// An art style named `name` (its bytes as written) that fills with colour `xa` (`c m y k r g b Xa`). fn fill_style(name: &str, xa: &str) -> String { fill_style_with(name, xa, "") diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..d6e325efc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS/`.ai` editing data: paths keep art styles of several fills and strokes live) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 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 | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | From 92726632284173b8025135522b1596db82c2093f Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:14:20 -0500 Subject: [PATCH 02/18] EPS/.ai editing data: a stack's one gradient lies where its drawn look has it A stacked style with a single gradient took the object's own gradient fill whenever the object had one, even where the drawn look placed the gradient elsewhere. A stack now always takes the placement of its drawn look, which it already requires for each of its gradients. --- crates/eps/src/import/ai/read/ops.rs | 5 +++-- crates/eps/src/import/ai/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 764598dc6..57eac4d15 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1051,8 +1051,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/tests.rs b/crates/eps/src/import/ai/tests.rs index ddce056fd..43c24c0e1 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5346,6 +5346,26 @@ fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_t // 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))); From 68609fb43b66f401e9064c617aba5e2c0dcbb7b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 22:07:20 -0800 Subject: [PATCH 03/18] Plug-ins: filters at API level 2 read type, images and symbol instances A filter manifest at `api` 2 may list `inputs` (`type`, `image`, `symbolInstance`). Such a filter is also given the selected objects of those kinds, read only: type with its runs and paragraph attributes, images with their pixel size, color mode and, when embedded and small enough, their RGBA pixels in base64 (never a linked file's pixels or path), and symbol instances with their symbol and transform. The output must return each read-only object as {"id": ...} alone or exactly as given. A change, an omission or a duplicate is refused before the edit, so the document and undo history stay as they were, including for output handed in from a heavy run. A filter without `inputs` gets the same input bytes as before. A manifest that lists `inputs` below `api` 2, outside a filter, with an unknown or repeated kind, or with a permission list that lacks `doc.read` does not install. Part of #4333 --- Cargo.lock | 2 + crates/engine/src/cmd/plugin.rs | 32 ++- crates/engine/src/tests/plugins.rs | 50 +++++ crates/engine/tests/plugin_fuzz.rs | 55 ++++++ crates/plugins/Cargo.toml | 3 + crates/plugins/src/lib.rs | 5 +- crates/plugins/src/manifest.rs | 127 +++++++++++- crates/plugins/src/objects.rs | 297 ++++++++++++++++++++++++++-- crates/plugins/src/objects/tests.rs | 238 ++++++++++++++++++++++ crates/plugins/src/permissions.rs | 3 + crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + docs/mcp.md | 5 +- docs/plugins.md | 62 +++++- docs/target-app-parity.md | 1 + 36 files changed, 956 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72d591b91..025810f08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6422,6 +6422,8 @@ dependencies = [ name = "vectorcraft-plugins" version = "0.21.0" dependencies = [ + "craft-vectorcraft-format", + "image", "log", "serde", "serde_json", diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..316221488 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -25,7 +25,12 @@ //! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its //! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log //! under its id. +//! +//! A filter whose manifest lists `inputs` (plug-in API level 2) is also given the selected type, +//! images or symbol instances of those kinds, to read only: its output must return each of them +//! unchanged ([`objects::decode_output_with`]). +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use serde_json::{Map, Value, json}; @@ -64,6 +69,9 @@ pub fn describe(p: &Plugin) -> Value { if m.budget == Budget::Heavy { v["budget"] = json!("heavy"); } + if !m.inputs.is_empty() { + v["inputs"] = json!(m.inputs); + } v } @@ -98,6 +106,9 @@ pub struct FilterRun { params: Vec, /// The objects the plug-in is given, and the container new objects go in. inputs: Vec, + /// The type, images and symbol instances among them (a filter that asks for them), as given: + /// its output must return each one unchanged. + read_only: HashMap, parent: Option, /// The document it was made from, and that document's revision then. document: (u64, u64), @@ -140,8 +151,10 @@ impl FilterRun { let may = m.permissions(); let st = s.doc()?; let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); - // Without `doc.read` a filter is given no objects: it can only add new ones. - let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; + // Without `doc.read` a filter is given no objects: it can only add new ones. Type, images + // and symbol instances only when its manifest asks for them (`inputs`). + let kinds = m.input_kinds(); + let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets_with(&st.doc, &selected, &kinds) } else { Vec::new() }; let parent = st.insertion_parent(); let bounds = st.doc.bounds_of(&inputs, false); let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); @@ -149,10 +162,10 @@ impl FilterRun { // What the plug-in keeps with the document, as it last wrote it. let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); - let input = objects::encode_input(&st.doc, &inputs); + let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds); let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; let document = (st.uid, st.revision); - Ok(FilterRun { plugin, input, params, inputs, parent, document }) + Ok(FilterRun { plugin, input, params, inputs, read_only, parent, document }) } /// The plug-in, for its name and budget. @@ -192,12 +205,13 @@ impl FilterRun { } /// Applies the plug-in's `output` to the active document as one undo step: its objects, and what - /// it keeps with the document from now on. An output its permissions don't allow is refused, - /// and the document stays as it was. + /// it keeps with the document from now on. An output its permissions don't allow, and one that + /// changes or leaves out a read-only object it was given, is refused, and the document stays as + /// it was. fn finish(self, s: &mut Session, output: &[u8]) -> Result { let m = self.plugin.manifest(); let may = m.permissions(); - let out = objects::decode_output(output).map_err(err)?; + let out = objects::decode_output_with(output, &self.read_only).map_err(err)?; if out.document_data.is_some() && !may.contains(&Permission::State) { return Err(not_granted(&m.name, Permission::State)); } @@ -499,7 +513,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), @@ -517,7 +531,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance) is also given the selected objects of those kinds to read: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index fdfe60067..ee67fe603 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -660,6 +660,56 @@ fn a_filter_does_only_what_its_permissions_allow() { assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1}))); } +/// A filter whose manifest lists `inputs` is also given the selected type, read-only: an echo +/// changes nothing, and an output that leaves the type out or changes it is refused with the +/// document and undo history as they were, handed in from a run made elsewhere too. A filter +/// without `inputs` is not given the type and so never deletes it. +#[test] +fn read_only_kind_field_and_omission_refuse_without_undo() { + use cmd::plugin::FilterRun; + let mut s = session(); + let r = rect(&mut s, 10.0, "#ff0000"); + let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap()); + let reads = |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"inputs":["type"]}}"#); + let echo = "test.engine.inputs.echo"; + let listed = install_wat(&mut s, &tpl::module(&reads(echo), tpl::ECHO, "", 1)); + assert_eq!(listed["inputs"], json!(["type"])); + let p = |id: &str| json!({"id": id, "ids": [r.0, t.0]}); + let before = s.doc().unwrap().doc.clone(); + assert_eq!(run(&mut s, "plugin.run", p(echo))["ids"], json!([r.0, t.0])); + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "an echo changes nothing"); + for (output, says) in [ + (json!({"objects": [{"id": r.0}]}), format!("objects (id {}): a read-only object was left out", t.0)), + (json!({"objects": [{"id": r.0}, {"id": t.0, "name": "Renamed"}]}), format!("objects[1] (id {}): read-only field 'name' changed", t.0)), + (json!({"objects": [{"id": r.0}, {"id": t.0}, {"type": "type", "path": triangle()}]}), "`type` must be \"path\" or \"compound\"".into()), + ] { + let id = "test.engine.inputs.bad"; + install_wat(&mut s, &tpl::returning(&reads(id), &output.to_string())); + let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string(); + assert!(e.contains(&says), "{says}: {e}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "{output}"); + // Handed in from a run made away from plugin.run, it is refused the same way. + let made = FilterRun::prepare(&s, &p(id)).unwrap(); + let out = made.run().unwrap(); + made.hand_in(&mut s, out); + let e = s.execute("plugin.run", &p(id)).unwrap_err().to_string(); + assert!(e.contains(&says), "handed in: {e}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&doc, undo), "handed in: {output}"); + } + // Without `inputs` the type isn't given, so leaving it out keeps it. + let legacy = "test.engine.inputs.legacy"; + install_wat(&mut s, &tpl::returning(&tpl::manifest(legacy, "filter", "{}"), &json!({"objects": [{"id": r.0, "name": "Kept"}]}).to_string())); + assert_eq!(run(&mut s, "plugin.run", p(legacy))["ids"], json!([r.0])); + assert_eq!(node(&s, r).name.as_deref(), Some("Kept")); + assert!(matches!(node(&s, t).kind, vectorcraft_doc::NodeKind::Text(_)), "the type stays"); + // A manifest that lists `inputs` below `api` 2 doesn't install. + let old = format!(r#"{{"id":"{echo}","name":"Old","kind":"filter","inputs":["type"]}}"#); + let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::module(&old, tpl::ECHO, "", 1)).unwrap()); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("`inputs` needs `api` 2"), "{e}"); +} + /// What the user granted is kept in the plug-in store with the module: the next session installs /// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is /// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1791555f7..5dc2e2a5e 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -362,4 +362,59 @@ proptest! { prop_assert!(total == 0 || done <= total); } } + + /// Manifests with hostile `inputs` (unknown, repeated and oddly cased kinds, long lists, junk of + /// every type) with any `api`, kind and permissions parse to an error or to a filter at `api` 2 + /// or later asking for each kind once. One that installs and echoes its input on a document + /// with type, images and symbols leaves the document as it was, and it still renders. + #[test] + fn hostile_manifest_inputs_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + names in prop::collection::vec(prop::sample::select(vec!["type", "image", "symbolInstance", "Type", "text", "path", ""]), 0..6), + long in prop::option::weighted(0.1, 4usize..300), + inputs in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), + api in prop_oneof![2 => Just(json!(2)), 1 => Just(json!(1)), 1 => Just(json!(3)), 1 => arb_junk()], + permissions in prop_oneof![Just(Value::Null), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))], + ) { + let mut m = json!({"id": "fuzz.inputs", "name": "Inputs", "kind": kind, "api": api}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + if !permissions.is_null() { + m["permissions"] = permissions; + } + m["inputs"] = match (inputs, long) { + (_, Some(n)) => json!(vec!["image"; n]), + (Some(Value::Null) | None, None) => json!(names), + (Some(junk), None) => junk, + }; + let text = m.to_string(); + let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); + let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; + if let Ok(man) = &parsed { + let kinds = man.input_kinds(); + prop_assert_eq!(kinds.len(), man.inputs.len(), "{}", text); + prop_assert!(kinds.iter().enumerate().all(|(i, k)| !kinds[..i].contains(k)), "{}", text); + prop_assert!(kinds.is_empty() || (man.kind == vectorcraft_plugins::Kind::Filter && man.api >= 2), "{}", text); + } + let mut s = rich_session(); + let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap(); + let data = vectorcraft_format::base64_encode(&module); + let grant = json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]); + let before = s.doc().unwrap().doc.clone(); + let r = catch_quiet(|| { + let installed = s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok(); + let _ = s.execute("select.all", &json!({})); + let ran = installed && s.execute("plugin.run", &json!({"id": "fuzz.inputs"})).is_ok(); + (installed, ran) + }); + let (installed, ran) = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?; + prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + if ran { + prop_assert!(s.doc().unwrap().doc.layers == before.layers, "an echo changed the document: {}", text); + } + renders(&s, &text)?; + vectorcraft_plugins::registry::remove("fuzz.inputs"); + vectorcraft_plugins::registry::revoke("fuzz.inputs"); + } } diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 5e4117a18..7c18f2c06 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -15,6 +15,9 @@ vectorcraft-doc = { workspace = true } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# The pixels of a small image a filter asks to read, as RGBA in base64. +image = { workspace = true } +craft-vectorcraft-format = { workspace = true } # The SHA-256 of a module, which the install prompt shows and a grant records. sha2 = { version = "0.10", default-features = false } thiserror = { workspace = true } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 5e5da17b9..8bbc2906b 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -16,7 +16,8 @@ //! a module does can panic or hang the host. //! //! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with -//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns +//! their geometry, fills, strokes (gradients included) and opacity, and, for a filter that asks +//! for them ([`InputKind`]), type, images and symbol instances to read. An **object filter** returns //! the objects to keep, change or add, applied to the document as one edit //! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each //! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole @@ -39,7 +40,7 @@ pub mod wat; pub use dialog::ParamUi; pub use host::{LogLevel, LogLine, Report}; -pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; +pub use manifest::{Budget, InputKind, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 36b4b78c4..0016c533c 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::dialog::ParamUi; -use crate::permissions::{self, API_LEVEL, PROGRESS_API, Permission}; +use crate::permissions::{self, API_LEVEL, INPUTS_API, PROGRESS_API, Permission}; use crate::{Error, Result}; /// Longest manifest the host reads. @@ -46,6 +46,39 @@ impl Kind { } } +/// A kind of object a filter may ask to be given besides paths and compound paths (its manifest's +/// `inputs`, plug-in API level 2). The filter reads these objects and returns each one as it was +/// given: it can't change or delete them ([`crate::objects::decode_output_with`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum InputKind { + /// Point, area and path type: its text runs with their character attributes, and its + /// paragraph attributes. + Type, + /// An embedded or linked image: its size, color mode and, when small, its pixels. + Image, + /// A symbol instance: the symbol it shows and its transform. + SymbolInstance, +} + +impl InputKind { + /// Every kind, in the order the docs list them. + pub const ALL: [InputKind; 3] = [InputKind::Type, InputKind::Image, InputKind::SymbolInstance]; + + /// Its name in `inputs`, which is also the `type` of such an object in a filter's input. + pub fn name(self) -> &'static str { + match self { + InputKind::Type => "type", + InputKind::Image => "image", + InputKind::SymbolInstance => "symbolInstance", + } + } + + /// The kind named `name`. + pub fn parse(name: &str) -> Option { + InputKind::ALL.into_iter().find(|k| k.name() == name) + } +} + /// How much one run of a plug-in may compute. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -250,6 +283,11 @@ pub struct Manifest { /// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option>, + /// The kinds of object a filter asks to be given besides paths and compound paths, by + /// [`InputKind::name`]: only a filter at `api` 2 or later lists them. Left out or empty, it is + /// given paths and compound paths alone. + #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "input_names")] + pub inputs: Vec, /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] pub params: Vec<(String, Param)>, @@ -285,6 +323,7 @@ impl Manifest { author: String::new(), api: 1, permissions: None, + inputs: Vec::new(), params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, @@ -313,6 +352,7 @@ impl Manifest { { return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); } + self.validate_inputs()?; self.validate_extensions()?; match (self.budget, self.kind) { (Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {} @@ -336,6 +376,38 @@ impl Manifest { Ok(()) } + /// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists + /// any, with `doc.read` among its permissions when it lists them. + fn validate_inputs(&self) -> Result<()> { + if self.inputs.is_empty() { + return Ok(()); + } + if self.kind != Kind::Filter { + return Err(Error::Manifest("only a filter declares `inputs`".into())); + } + for (i, name) in self.inputs.iter().enumerate() { + let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD); + if InputKind::parse(name).is_none() { + return Err(Error::Manifest(format!("`inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance"))); + } + if self.inputs.get(..i).is_some_and(|before| before.contains(name)) { + return Err(Error::Manifest(format!("`inputs` lists `{shown}` twice"))); + } + } + if self.api < INPUTS_API { + return Err(Error::Manifest("`inputs` needs `api` 2 or later".into())); + } + if self.permissions.as_ref().is_some_and(|p| !p.iter().any(|n| n == Permission::DocRead.name())) { + return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into())); + } + Ok(()) + } + + /// The kinds of object besides paths and compound paths it asks to be given (`inputs`). + pub fn input_kinds(&self) -> Vec { + self.inputs.iter().filter_map(|n| InputKind::parse(n)).collect() + } + /// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case /// letters and digits; other kinds list none. fn validate_extensions(&self) -> Result<()> { @@ -491,6 +563,17 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result d.deserialize_any(V) } +/// Reads `inputs`: a list of names (`null`: none). Anything else is an error that names the key, as +/// [`Manifest::validate_inputs`] names a wrong entry. +fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + let wrong = || serde::de::Error::custom("`inputs` must be a list of object kinds: type, image or symbolInstance"); + match Value::deserialize(d)? { + Value::Null => Ok(Vec::new()), + Value::Array(names) => names.into_iter().map(|n| if let Value::String(s) = n { Ok(s) } else { Err(wrong()) }).collect(), + _ => Err(wrong()), + } +} + fn params_as_object(p: &[(String, Param)], s: S) -> std::result::Result { use serde::ser::SerializeMap; let mut m = s.serialize_map(Some(p.len()))?; @@ -674,6 +757,48 @@ mod tests { } } + /// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once, + /// with `doc.read` when it lists permissions. Left out or empty, it asks for none and isn't + /// written back. Anything else is refused naming `inputs`. + #[test] + fn a_filter_at_api_2_lists_the_inputs_it_reads() { + let m = + Manifest::parse(br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"inputs":["type","image","symbolInstance"]}"#) + .unwrap(); + assert_eq!(m.input_kinds(), InputKind::ALL); + assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"])); + let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap(); + assert_eq!(some.input_kinds(), [InputKind::Image]); + for none in [ + r#"{"id":"a","name":"x","kind":"filter"}"#, + r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#, + r#"{"id":"a","name":"x","kind":"filter","inputs":null}"#, + r#"{"id":"a","name":"x","kind":"effect","inputs":[]}"#, + ] { + let m = Manifest::parse(none.as_bytes()).unwrap(); + assert!(m.input_kinds().is_empty(), "{none}"); + assert!(serde_json::to_value(&m).unwrap().get("inputs").is_none(), "{none}"); + } + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["text"]}"#, "`inputs` names an unknown object kind `text`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["Type"]}"#, "unknown object kind `Type`"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["image","image"]}"#, "`inputs` lists `image` twice"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":"type"}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":[1]}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":{"type":true}}"#, "`inputs` must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"), + (r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"), + (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"inputs":[{}]}}"#, vec![r#""type""#; 1000].join(",")); + assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("twice")); + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 037591f8b..9fa37ce54 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -10,10 +10,12 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{FillRule, PathData, Point, Rect}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, TextKind, TextObject, +}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; -use crate::{Error, Result}; +use crate::{Error, InputKind, Result}; /// Coordinates a plug-in may return, in points (the document's own canvas limit). pub const MAX_COORD: f64 = 4.0e6; @@ -32,6 +34,14 @@ pub const MITER_LIMIT: std::ops::RangeInclusive = 1.0..=500.0; pub const MAX_EFFECT_ID: usize = 128; /// Largest data a plug-in may keep with a document (`documentData`), in bytes of JSON. pub const MAX_DOCUMENT_DATA: usize = 1 << 20; +/// Most text a filter is given of one type object, in bytes of UTF-8 over all its runs: longer +/// type comes with `truncated` and no runs or paragraphs. +pub const MAX_INPUT_TEXT: usize = 256 * 1024; +/// Most runs, and most paragraphs, a filter is given of one type object (as [`MAX_INPUT_TEXT`]). +pub const MAX_INPUT_RUNS: usize = 4_096; +/// Largest pixels a filter is given of one embedded image, in characters of base64: a larger +/// image comes with `pixelsOmitted`. +pub const MAX_INPUT_PIXELS: usize = 1 << 20; /// One object returned by a plug-in. `None` fields keep the object's value (new objects take the /// defaults: a path, non-zero rule, no fills or strokes, full opacity). @@ -56,6 +66,9 @@ pub struct OutObject { pub guide: Option, /// Locks or unlocks the object (as Object › Lock and Unlock do). pub locked: Option, + /// A read-only input object (type, an image or a symbol instance) returned as it was given: + /// it stays as it is, and new objects listed after it go above it. + pub kept: bool, } /// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's @@ -158,8 +171,153 @@ pub fn encode(n: &Node) -> Option { /// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths; /// others are left out). pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { - let objects: Vec = ids.iter().filter_map(|id| doc.node(*id)).filter_map(encode).collect(); - serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default() + encode_input_with(doc, ids, &[]).0 +} + +/// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are +/// in it as [`encode_read_only`] gives them. Also returns those objects by id, which the filter must +/// return as they were ([`decode_output_with`]). +pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind]) -> (Vec, HashMap) { + let mut read_only = HashMap::new(); + let objects: Vec = ids + .iter() + .filter_map(|id| doc.node(*id)) + .filter_map(|n| match input_kind(n).filter(|k| kinds.contains(k)) { + Some(_) => { + let o = encode_read_only(doc, n)?; + read_only.insert(n.id, o.clone()); + Some(o) + } + None => encode(n), + }) + .collect(); + (serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default(), read_only) +} + +/// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). +pub fn input_kind(n: &Node) -> Option { + match &n.kind { + NodeKind::Text(_) => Some(InputKind::Type), + NodeKind::Image(_) => Some(InputKind::Image), + NodeKind::SymbolInstance { .. } => Some(InputKind::SymbolInstance), + _ => None, + } +} + +fn affine_json(xf: Affine) -> Value { + json!(xf.as_coeffs()) +} + +/// Type, an image or a symbol instance of `doc` as a filter that asks for its kind reads it (`None` +/// for other objects). Each has `{id, type, name?, bounds, transform}`, `transform` mapping its own +/// space (text space, pixel space, the symbol's art) into the document, and: +/// - type (`"type"`): `text: {kind: "point"|"area"|"path", vertical, runs: [{text, style, inline?}], +/// paragraphs: [style]}` with the native character and paragraph attributes, one paragraph style +/// for each paragraph, and `truncated`: true, with no runs or paragraphs, past [`MAX_INPUT_TEXT`] +/// or [`MAX_INPUT_RUNS`]; +/// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`, +/// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`] +/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top. A linked image's file +/// and path are never read or given; +/// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when +/// the document has no such symbol, and `overrides: {}`. +pub fn encode_read_only(doc: &Document, n: &Node) -> Option { + let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of(&[n.id], false))}); + match &n.kind { + NodeKind::Text(t) => { + o["transform"] = affine_json(t.xf); + let (text, truncated) = text_json(t); + o["text"] = text; + o["truncated"] = json!(truncated); + } + NodeKind::Image(im) => { + o["transform"] = affine_json(im.xf); + let linked = im.link.is_some(); + let blob = doc.image_blob(im); + // A linked image's pixels are its file's: never given (nor its path). + let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height)); + o["pixelSize"] = json!([im.width, im.height]); + o["colorMode"] = json!(blob.map_or("unknown", color_mode)); + o["linked"] = json!(linked); + o["pixelsOmitted"] = json!(pixels.is_none()); + if let Some(p) = pixels { + o["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p)); + } + } + NodeKind::SymbolInstance { symbol, xf } => { + o["transform"] = affine_json(*xf); + o["symbolId"] = json!(symbol); + if !doc.symbols.iter().any(|s| s.name == *symbol) { + o["missing"] = json!(true); + } + o["overrides"] = json!({}); + } + _ => return None, + } + if let Some(name) = &n.name { + o["name"] = json!(name); + } + Some(o) +} + +/// The text of type `t` as a filter reads it, and whether it was too long to give +/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`]). +fn text_json(t: &TextObject) -> (Value, bool) { + let kind = match t.kind { + TextKind::Point => "point", + TextKind::Area { .. } => "area", + TextKind::OnPath { .. } => "path", + }; + let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); + let paragraphs = t.paragraph_count(); + let truncated = bytes > MAX_INPUT_TEXT || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; + let (runs, paras): (Vec, Vec) = if truncated { + (Vec::new(), Vec::new()) + } else { + let value = |v: std::result::Result| v.unwrap_or(Value::Null); + ( + t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(), + (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(), + ) + }; + (json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated) +} + +/// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or +/// `"unknown"` when it can't be read or only a linked image's preview is there. +fn color_mode(b: &ImageBlob) -> &'static str { + if b.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &b.bytes) || **p == *b.bytes) { + return "unknown"; + } + if b.is_cmyk() { + return "cmyk"; + } + if b.gray_source().is_some() { + return "gray"; + } + let readable = + image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); + if readable.is_some() { "rgb" } else { "unknown" } +} + +/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit +/// [`MAX_INPUT_PIXELS`] in base64; the size is checked from the header before anything is decoded. +fn rgba_pixels(b: &ImageBlob, w: u32, h: u32) -> Option> { + let bytes = u64::from(w) * u64::from(h) * 4; + if bytes == 0 || bytes.div_ceil(3) * 4 > MAX_INPUT_PIXELS as u64 { + return None; + } + let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); + if reader()?.into_dimensions().ok()? != (w, h) { + return None; + } + let mut r = reader()?; + let mut limits = image::Limits::default(); + // The decoder's own buffers besides the RGBA it gives. + limits.max_alloc = Some(64 << 20); + r.limits(limits); + let img = r.decode().ok()?.into_rgba8(); + (img.dimensions() == (w, h)).then(|| img.into_raw()) } fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { @@ -397,8 +555,16 @@ pub struct Output { /// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}` /// when the plug-in declined (the message reaches the user). pub fn decode_output(bytes: &[u8]) -> Result { + decode_output_with(bytes, &HashMap::new()) +} + +/// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list +/// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by +/// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left +/// out, is refused naming the object and the first field that differs, before anything changes. +pub fn decode_output_with(bytes: &[u8], read_only: &HashMap) -> Result { let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; - let objects = decode_objects(&v)?; + let objects = decode_objects(&v, read_only)?; let document_data = match v.get_mut("documentData").map(Value::take) { None => None, Some(Value::Null) => Some(None), @@ -417,11 +583,12 @@ pub fn decode_output(bytes: &[u8]) -> Result { /// declined (the message reaches the user). A live effect's other keys don't matter. pub fn decode(bytes: &[u8]) -> Result> { let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; - decode_objects(&v) + decode_objects(&v, &HashMap::new()) } -/// The objects of output `v`, or its decline. -fn decode_objects(v: &Value) -> Result> { +/// The objects of output `v`, or its decline; entries for `read_only` objects as +/// [`decode_output_with`] reads them. +fn decode_objects(v: &Value, read_only: &HashMap) -> Result> { if let Some(e) = declined(v) { return Err(e); } @@ -430,7 +597,83 @@ fn decode_objects(v: &Value) -> Result> { return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len()))); } let mut anchors = 0; - objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect() + let mut returned: HashSet = HashSet::new(); + let out = objects + .iter() + .enumerate() + .map(|(i, o)| { + let id = o.get("id").and_then(Value::as_u64).map(NodeId); + match id.and_then(|id| Some((id, read_only.get(&id)?))) { + Some((id, given)) => { + let at = format!("objects[{i}] (id {})", id.0); + if !returned.insert(id) { + return Err(out_err(&at, "is listed twice")); + } + check_unchanged(o, given, &at)?; + Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() }) + } + None => decode_object(o, &format!("objects[{i}]"), &mut anchors), + } + }) + .collect::>>()?; + let mut missing: Vec = read_only.keys().filter(|id| !returned.contains(id)).copied().collect(); + missing.sort(); + if let Some(id) = missing.first() { + return Err(out_err( + &format!("objects (id {})", id.0), + "a read-only object was left out: return it, as {\"id\": …} alone or as it was given", + )); + } + Ok(out) +} + +/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone, +/// or `given` itself, numbers compared by value. Otherwise the error names the first field that +/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out. +fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> { + if o.as_object().is_some_and(|m| m.len() == 1) { + return Ok(()); + } + let changed = match (o, given) { + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().collect(); + keys.sort(); + keys.into_iter().find_map(|k| match y.get(k) { + Some(q) => first_difference(&x[k], q, k), + None => Some(k.clone()), + }) + } + _ => None, + }; + match changed.or_else(|| first_difference(o, given, "")) { + None => Ok(()), + Some(field) => Err(out_err(at, format!("read-only field '{field}' changed"))), + } +} + +/// The path of the first place `a` and `b` differ (`""`: they differ as a whole), `None` when they +/// are the same JSON with numbers compared by value. +fn first_difference(a: &Value, b: &Value, at: &str) -> Option { + let key = |k: &str| if at.is_empty() { k.to_string() } else { format!("{at}.{k}") }; + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.as_f64() != y.as_f64()).then(|| at.into()), + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect(); + keys.sort(); + keys.dedup(); + keys.into_iter().find_map(|k| match (x.get(k), y.get(k)) { + (Some(p), Some(q)) => first_difference(p, q, &key(k)), + _ => Some(key(k)), + }) + } + (Value::Array(x), Value::Array(y)) => { + if x.len() != y.len() { + return Some(at.into()); + } + x.iter().zip(y).enumerate().find_map(|(i, (p, q))| first_difference(p, q, &format!("{at}[{i}]"))) + } + (a, b) => (a != b).then(|| at.into()), + } } /// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it, @@ -625,22 +868,30 @@ pub fn selectable(n: &Node) -> bool { /// guides too) and clipping paths are left out (so are paths inside compound paths, blends and /// other live objects). pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec { - fn visit(list: &[Arc], sel: &HashSet, inside: bool, out: &mut Vec) { - for n in list { + filter_targets_with(doc, selected, &[]) +} + +/// [`filter_targets`] for a filter that asks for `kinds` too: type, images and symbol instances of +/// those kinds join the paths in paint order, under the same rules (type that clips a clip group is +/// left out, and so is anything inside a symbol, a placed document or a live object). +pub fn filter_targets_with(doc: &Document, selected: &[NodeId], kinds: &[InputKind]) -> Vec { + fn visit(list: &[Arc], sel: &HashSet, inside: bool, clips: bool, kinds: &[InputKind], out: &mut Vec) { + for (i, n) in list.iter().enumerate() { if !n.visible || n.locked { continue; } let picked = inside || sel.contains(&n.id); match &n.kind { NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id), - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), kinds, out), + _ if picked && !(clips && i == 0) && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id), _ => {} } } } let sel: HashSet = selected.iter().copied().collect(); let mut out = vec![]; - visit(&doc.layers, &sel, false, &mut out); + visit(&doc.layers, &sel, false, false, kinds, &mut out); out } @@ -740,7 +991,14 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; out_err(&at, format!("{} {why}", id.0)) })?; - write(doc, &mut n, o, &at, admit)?; + // Type, images and symbol instances are only read: writing one would make it a path. + if input_kind(&n).is_some() && !o.kept { + return Err(out_err(&at, format!("{} is read-only", id.0))); + } + let kept = o.kept; + if !kept { + write(doc, &mut n, o, &at, admit)?; + } if !run.is_empty() { let nodes = std::mem::take(&mut run); match prev { @@ -748,7 +1006,9 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, None => plan.before.insert(id, nodes), }; } - plan.replace.insert(id, n); + if !kept { + plan.replace.insert(id, n); + } prev = Some(id); result.push(id); } @@ -774,7 +1034,10 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, None => plan.top = Some((parent, run)), } } - // Input objects the output left out are deleted. + // Input objects the output left out are deleted, except read-only ones, which it must return. + if let Some(n) = originals.values().filter(|n| input_kind(n).is_some()).min_by_key(|n| n.id) { + return Err(out_err(&format!("objects (id {})", n.id.0), "a read-only object was left out")); + } plan.delete = originals.into_keys().collect(); if let Some(list) = plan.list(None, &doc.layers) { doc.layers = list; diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 74219a31b..48b0a38b3 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -310,6 +310,244 @@ fn bad_stroke_styles_and_effect_records_are_refused() { } } +/// A `w` × `h` PNG whose pixels vary with their column, half transparent. +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([(x * 100) as u8, (y * 7) as u8, 30, 128])); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// An image object `w` × `h` pixels showing blob `key`, linked to `link`. +fn image_node(d: &mut Document, key: &str, (w, h): (u32, u32), link: Option<&str>) -> Node { + let im = vectorcraft_doc::ImageObject { + key: key.into(), + width: w, + height: h, + xf: Affine::new([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]), + link: link.map(vectorcraft_doc::LinkInfo::new), + placement: Default::default(), + pixels: None, + }; + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) +} + +/// [`doc`] with, above its paths: point type "T" in two paragraphs, an embedded 2 × 1 image "E", a +/// linked image "L", an instance "S" of symbol "Star" and an instance "G" of a symbol the document +/// doesn't have. Returns those five too. +fn mixed() -> (Document, Vec, NodeId, [NodeId; 5]) { + use vectorcraft_doc::{CharStyle, ParaStyle, Symbol, TextObject}; + let (mut d, ids, layer) = doc(); + let mut t = TextObject::point(Point::new(5.0, 50.0), "Hi\nthere", CharStyle::default()); + t.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { space_before: 6.0, ..Default::default() }]); + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + d.images.insert("e".into(), ImageBlob::png(png(2, 1))); + d.images.insert("l".into(), ImageBlob::png(png(4, 4))); + let embedded = image_node(&mut d, "e", (2, 1), None); + let linked = image_node(&mut d, "l", (4, 4), Some("C:/private/photo.png")); + let art = Arc::new(Node::path(d.alloc_id(), square(0.0), Appearance::default())); + d.symbols.push(Symbol { name: "Star".into(), art, dynamic: false }); + let star = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::translate((100.0, 0.0)) }); + let ghost = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY }); + let mut extra = [NodeId(0); 5]; + for (slot, (mut n, name)) in extra.iter_mut().zip([text, embedded, linked, star, ghost].into_iter().zip(["T", "E", "L", "S", "G"])) { + n.name = Some(name.into()); + *slot = d.insert(Some(layer), usize::MAX, n).unwrap(); + } + (d, ids, layer, extra) +} + +/// A filter that doesn't list `inputs` is given what it was before: the selected paths alone, in +/// the same bytes. One that lists kinds is given those objects too, in paint order, under the same +/// rules (hidden and locked ones left out, selected ones or ones in a selected layer). +#[test] +fn legacy_filter_input_bytes_are_unchanged() { + let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed(); + assert_eq!(filter_targets(&d, &[layer]), ids); + assert_eq!(filter_targets_with(&d, &[layer], &[]), ids); + let every: Vec = ids.iter().copied().chain([text, embedded, linked, star, ghost]).collect(); + let (bytes, read_only) = encode_input_with(&d, &every, &[]); + assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out"); + assert!(read_only.is_empty()); + let all = filter_targets_with(&d, &[layer], &InputKind::ALL); + assert_eq!(all, every); + let images: Vec = ids.iter().copied().chain([embedded, linked]).collect(); + assert_eq!(filter_targets_with(&d, &[layer], &[InputKind::Image]), images); + assert_eq!(filter_targets_with(&d, &[star, ids[0]], &InputKind::ALL), [ids[0], star], "selected directly"); + d.node_mut(text).unwrap().visible = false; + d.node_mut(star).unwrap().locked = true; + assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat()); + let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance]); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]); + assert_eq!(read_only.len(), 2); +} + +/// Type comes with its runs and their character attributes, one paragraph style per paragraph, its +/// kind and transform; type past [`MAX_INPUT_TEXT`] or [`MAX_INPUT_RUNS`] comes without text. +#[test] +fn declared_type_input_has_bounded_runs_and_paragraph_styles() { + use vectorcraft_doc::{CharStyle, ParaStyle, TextRun}; + let (mut d, _, _, [text, ..]) = mixed(); + let o = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + assert_eq!((&o["type"], &o["name"], &o["truncated"]), (&json!("type"), &json!("T"), &json!(false))); + assert_eq!(o["transform"], json!([1.0, 0.0, 0.0, 1.0, 5.0, 50.0])); + assert_eq!((&o["text"]["kind"], &o["text"]["vertical"]), (&json!("point"), &json!(false))); + assert_eq!(o["text"]["runs"], json!([{"text": "Hi\nthere", "style": serde_json::to_value(CharStyle::default()).unwrap()}])); + let spaced = ParaStyle { space_before: 6.0, ..Default::default() }; + assert_eq!(o["text"]["paragraphs"], json!([serde_json::to_value(ParaStyle::default()).unwrap(), serde_json::to_value(spaced).unwrap()])); + assert!(o.get("path").is_none() && o.get("fills").is_none(), "not made into outlines"); + let set_runs = |d: &mut Document, runs: Vec| { + if let NodeKind::Text(t) = &mut d.node_mut(text).unwrap().kind { + t.runs = runs; + t.paras.clear(); + } + }; + let run = |s: String| TextRun::new(s, CharStyle::default()); + for (runs, truncated) in [ + (vec![run("x".repeat(MAX_INPUT_TEXT))], false), + (vec![run("x".repeat(MAX_INPUT_TEXT - 1)), run("é".into())], true), + (vec![run("\n".repeat(MAX_INPUT_RUNS - 1))], false), + (vec![run("\n".repeat(MAX_INPUT_RUNS))], true), + ((0..MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), false), + ((0..=MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), true), + ] { + let n = runs.len(); + set_runs(&mut d, runs); + let o = encode_read_only(&d, d.node(text).unwrap()).unwrap(); + assert_eq!(o["truncated"], json!(truncated), "{n} runs"); + let given = o["text"]["runs"].as_array().unwrap().len(); + assert_eq!((given, o["text"]["paragraphs"].as_array().unwrap().is_empty()), (if truncated { 0 } else { n }, truncated), "{n} runs"); + } +} + +/// An embedded image comes with its pixels when they are small; a linked image never does, and +/// nothing of its file's path is given. The color mode is read from the image. +#[test] +fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission() { + let (mut d, _, layer, [_, embedded, linked, ..]) = mixed(); + let e = encode_read_only(&d, d.node(embedded).unwrap()).unwrap(); + assert_eq!((&e["type"], &e["pixelSize"], &e["colorMode"]), (&json!("image"), &json!([2, 1]), &json!("rgb"))); + assert_eq!((&e["linked"], &e["pixelsOmitted"], &e["transform"]), (&json!(false), &json!(false), &json!([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]))); + let pixels = craft_vectorcraft_format::base64::decode(e["pixelsBase64"].as_str().unwrap()).unwrap(); + assert_eq!(pixels, [0, 0, 30, 128, 100, 0, 30, 128], "straight RGBA, row by row"); + let l = encode_read_only(&d, d.node(linked).unwrap()).unwrap(); + assert_eq!((&l["linked"], &l["pixelsOmitted"], &l["pixelSize"]), (&json!(true), &json!(true), &json!([4, 4]))); + assert!(l.get("pixelsBase64").is_none()); + assert!(!l.to_string().contains("private"), "the link's path isn't given: {l}"); + // Past the limit (443 × 443 fits it; 444 × 443 doesn't), and pixels of another size, are omitted. + for ((w, h), omitted) in [((443, 443), false), ((444, 443), true)] { + d.images.insert("big".into(), ImageBlob::png(png(w, h))); + let n = image_node(&mut d, "big", (w, h), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + let o = encode_read_only(&d, d.node(id).unwrap()).unwrap(); + assert_eq!((&o["pixelsOmitted"], o.get("pixelsBase64").is_some()), (&json!(omitted), !omitted), "{w} × {h}"); + } + let n = image_node(&mut d, "e", (3, 1), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "not the size it says"); + // Gray pixels, and data that isn't an image. + let mut gray = vec![]; + image::GrayImage::from_pixel(2, 2, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap(); + for (blob, mode) in [(ImageBlob::png(gray), "gray"), (ImageBlob::png(b"not an image".to_vec()), "unknown")] { + d.images.insert("x".into(), blob); + let n = image_node(&mut d, "x", (2, 2), None); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["colorMode"], json!(mode)); + } +} + +/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose +/// symbol the document lacks says so. +#[test] +fn declared_symbol_instance_keeps_identity_and_transform() { + let (d, _, _, [.., star, ghost]) = mixed(); + let s = encode_read_only(&d, d.node(star).unwrap()).unwrap(); + assert_eq!((&s["type"], &s["symbolId"], &s["overrides"]), (&json!("symbolInstance"), &json!("Star"), &json!({}))); + assert_eq!(s["transform"], json!([1.0, 0.0, 0.0, 1.0, 100.0, 0.0])); + assert!(s.get("missing").is_none() && s.get("path").is_none()); + let g = encode_read_only(&d, d.node(ghost).unwrap()).unwrap(); + assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true))); +} + +/// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by +/// value), and they stay as they are, new objects listed after one going just above it. Any change, +/// an object left out or listed twice, and a new object of such a kind are refused before anything +/// changes. +#[test] +fn read_only_kind_field_and_omission_refuse() { + let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed(); + let targets = filter_targets_with(&d, &[layer], &InputKind::ALL); + let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL); + assert_eq!(read_only.len(), 5); + let input: Value = serde_json::from_slice(&bytes).unwrap(); + let before = d.clone(); + let out = decode_output_with(&bytes, &read_only).unwrap(); + assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5); + apply_output(&mut d, &targets, out.objects, None, &keep).unwrap(); + assert_eq!(d, before, "an exact echo changes nothing"); + // A filter that doesn't list `inputs` could never hand them back. + assert!(decode_output(&bytes).is_err()); + // Numbers written another way are the same numbers. + let mut echo = input.clone(); + echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]); + assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok()); + // `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type. + let ro = |id: NodeId| json!({"id": id.0}); + let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]}); + let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap(); + assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]); + assert_eq!(made.len(), 6); + assert!(ids.iter().all(|id| d.node(*id).is_none())); + assert_eq!(d.node(text), before.node(text), "the type is as it was"); + // Refused, naming the object and the field. + let t = text.0; + let changed = |f: &dyn Fn(&mut Value)| { + let mut v = input.clone(); + f(&mut v); + v + }; + for (out, says) in [ + ( + changed(&|v| v["objects"][3]["text"]["runs"][0]["text"] = json!("Bye")), + format!("objects[3] (id {t}): read-only field 'text.runs[0].text' changed"), + ), + (changed(&|v| v["objects"][3]["type"] = json!("path")), format!("(id {t}): read-only field 'type' changed")), + (changed(&|v| v["objects"][3]["opacity"] = json!(0.5)), format!("(id {t}): read-only field 'opacity' changed")), + (changed(&|v| v["objects"][3]["transform"][4] = json!(6)), format!("(id {t}): read-only field 'transform[4]' changed")), + ( + changed(&|v| { + v["objects"][4].as_object_mut().unwrap().remove("pixelsBase64"); + }), + format!("(id {}): read-only field 'pixelsBase64' changed", embedded.0), + ), + (changed(&|v| v["objects"][6]["symbolId"] = json!("Moon")), format!("(id {}): read-only field 'symbolId' changed", star.0)), + ( + changed(&|v| { + v["objects"].as_array_mut().unwrap().remove(3); + }), + format!("objects (id {t}): a read-only object was left out"), + ), + (changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"id": ghost.0}))), format!("objects[8] (id {}): is listed twice", ghost.0)), + ( + changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"type": "type", "path": square(0.0)}))), + "`type` must be \"path\" or \"compound\"".to_string(), + ), + ] { + let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string(); + assert!(e.contains(&says), "{says}: {e}"); + } + // Writing one or leaving one out is refused when applying too, whatever decoded the output. + let mut d = before.clone(); + let write = vec![OutObject { id: Some(text), name: Some("Renamed".into()), ..OutObject::default() }]; + let e = apply_output(&mut d, &[text], write, None, &keep).unwrap_err().to_string(); + assert!(e.contains("is read-only"), "{e}"); + let e = apply_output(&mut d, &[text], vec![], None, &keep).unwrap_err().to_string(); + assert!(e.contains("a read-only object was left out"), "{e}"); + assert_eq!(d, before); +} + /// A filter's `documentData`: absent keeps what the plug-in keeps, `null` forgets it, any other JSON /// of at most [`MAX_DOCUMENT_DATA`] bytes is kept, and more refuses the whole output. #[test] diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 083c78a04..c5959b8c0 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -20,6 +20,9 @@ pub const API_LEVEL: u32 = 2; /// permission they need. pub const PROGRESS_API: u32 = 2; +/// The API level that adds a filter's `inputs` ([`crate::InputKind`]). +pub const INPUTS_API: u32 = 2; + /// Most permissions a manifest may list. pub const MAX_PERMISSIONS: usize = 16; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 51959c320..bc17b6d1b 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -627,6 +627,7 @@ impl PluginIntField { author: String::new(), api: 1, permissions: None, + inputs: Vec::new(), params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ea246b35c..a58a6c7ae 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5864,6 +5864,11 @@ digits Malayalam Malaiàlam @msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}` @msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades +@msg only a filter declares `inputs` només un filtre declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anomena un tipus d’objecte desconegut `{shown}`: feu servir type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` inclou `{shown}` dues vegades +@msg `inputs` needs `api` 2 or later `inputs` requereix `api` 2 o posterior +@msg `inputs` needs the permission `doc.read` `inputs` requereix el permís `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix @msg unknown permission `{shown}` permís desconegut `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e51de244..7f1ab772a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5844,6 +5844,11 @@ axis Y Axis osa Y @msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}` @msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát +@msg only a filter declares `inputs` `inputs` deklaruje pouze filtr +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` uvádí neznámý druh objektu `{shown}`: použijte type, image nebo symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` uvádí `{shown}` dvakrát +@msg `inputs` needs `api` 2 or later `inputs` vyžaduje `api` 2 nebo vyšší +@msg `inputs` needs the permission `doc.read` `inputs` vyžaduje oprávnění `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje @msg unknown permission `{shown}` neznámé oprávnění `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7d0e48fb8..6c7453f51 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5426,6 +5426,11 @@ axis Y Axis Y-Achse @msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}` @msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt +@msg only a filter declares `inputs` nur ein Filter deklariert `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nennt eine unbekannte Objektart `{shown}`: verwenden Sie type, image oder symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` führt `{shown}` doppelt auf +@msg `inputs` needs `api` 2 or later `inputs` erfordert `api` 2 oder höher +@msg `inputs` needs the permission `doc.read` `inputs` erfordert die Berechtigung `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index be001e2fb..036c75f2e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5795,6 +5795,11 @@ axis Y Axis Άξονας Y @msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}` @msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές +@msg only a filter declares `inputs` μόνο ένα φίλτρο δηλώνει `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance το `inputs` αναφέρει άγνωστο είδος αντικειμένου `{shown}`: χρησιμοποιήστε type, image ή symbolInstance +@msg `inputs` lists `{shown}` twice το `inputs` αναφέρει το `{shown}` δύο φορές +@msg `inputs` needs `api` 2 or later το `inputs` απαιτεί `api` 2 ή νεότερο +@msg `inputs` needs the permission `doc.read` το `inputs` απαιτεί το δικαίωμα `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 930ecf090..578a8ae2f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5785,6 +5785,11 @@ axis Y Axis Eje Y @msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}` @msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces +@msg only a filter declares `inputs` solo un filtro declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nombra un tipo de objeto desconocido `{shown}`: use type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` incluye `{shown}` dos veces +@msg `inputs` needs `api` 2 or later `inputs` requiere `api` 2 o posterior +@msg `inputs` needs the permission `doc.read` `inputs` requiere el permiso `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede @msg unknown permission `{shown}` permiso desconocido `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 99594975d..ef71d5371 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5874,6 +5874,11 @@ axis Y Axis Y-akseli @msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta @msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti +@msg only a filter declares `inputs` vain suodatin voi määrittää `inputs`-avaimen +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nimeää tuntemattoman objektilajin `{shown}`: käytä arvoa type, image tai symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` luettelee kohteen `{shown}` kahdesti +@msg `inputs` needs `api` 2 or later `inputs` edellyttää `api`-tasoa 2 tai uudempaa +@msg `inputs` needs the permission `doc.read` `inputs` edellyttää käyttöoikeutta `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1aebc348d..21173799a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5840,6 +5840,11 @@ axis Y Axis Axe Y @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` @msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg only a filter declares `inputs` seul un filtre déclare `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` désigne un type d’objet inconnu `{shown}` : utilisez type, image ou symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` mentionne `{shown}` deux fois +@msg `inputs` needs `api` 2 or later `inputs` nécessite `api` 2 ou plus +@msg `inputs` needs the permission `doc.read` `inputs` nécessite l’autorisation `doc.read` @msg unknown permission `{shown}` autorisation inconnue `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0f212f4fd..806986bd6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5236,6 +5236,11 @@ @msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती @msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है +@msg only a filter declares `inputs` केवल फ़िल्टर `inputs` घोषित करता है +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` में अज्ञात ऑब्जेक्ट प्रकार `{shown}` है: type, image या symbolInstance का उपयोग करें +@msg `inputs` lists `{shown}` twice `inputs` में `{shown}` दो बार सूचीबद्ध है +@msg `inputs` needs `api` 2 or later `inputs` के लिए `api` 2 या बाद का स्तर आवश्यक है +@msg `inputs` needs the permission `doc.read` `inputs` के लिए अनुमति `doc.read` आवश्यक है @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a400c495f..adfe48363 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5827,6 +5827,11 @@ axis Y Axis Y tengely @msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre @msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel +@msg only a filter declares `inputs` `inputs` kulcsot csak szűrő adhat meg +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance az `inputs` ismeretlen objektumfajtát nevez meg: `{shown}`; használja a type, image vagy symbolInstance értéket +@msg `inputs` lists `{shown}` twice az `inputs` kétszer sorolja fel ezt: `{shown}` +@msg `inputs` needs `api` 2 or later az `inputs` használatához `api` 2 vagy újabb szükséges +@msg `inputs` needs the permission `doc.read` az `inputs` használatához a(z) `doc.read` engedély szükséges @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b19e01f96..fcf5cef1f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5460,6 +5460,11 @@ axis Y Axis Asse Y @msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}` @msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte +@msg only a filter declares `inputs` solo un filtro dichiara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` indica un tipo di oggetto sconosciuto `{shown}`: usa type, image o symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` elenca `{shown}` due volte +@msg `inputs` needs `api` 2 or later `inputs` richiede `api` 2 o successivo +@msg `inputs` needs the permission `doc.read` `inputs` richiede l’autorizzazione `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 77d50e0a8..1407ca8b7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5525,6 +5525,11 @@ axis Y Axis Y 軸 @msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません @msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています +@msg only a filter declares `inputs` `inputs` を宣言できるのはフィルターだけです +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` に不明なオブジェクトの種類 `{shown}` があります。type、image、symbolInstance のいずれかを使ってください +@msg `inputs` lists `{shown}` twice `inputs` に `{shown}` が 2 回指定されています +@msg `inputs` needs `api` 2 or later `inputs` には `api` 2 以上が必要です +@msg `inputs` needs the permission `doc.read` `inputs` には権限 `doc.read` が必要です @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません @msg unknown permission `{shown}` 不明な権限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c710ee3c5..649eeaf8e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5767,6 +5767,11 @@ axis Y Axis Y ღერძი @msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს @msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი +@msg only a filter declares `inputs` `inputs`-ის გამოცხადება მხოლოდ ფილტრს შეუძლია +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` უცნობ ობიექტის სახეობას ასახელებს `{shown}`: გამოიყენეთ type, image ან symbolInstance +@msg `inputs` lists `{shown}` twice `inputs`-ში `{shown}` ორჯერაა ჩამოთვლილი +@msg `inputs` needs `api` 2 or later `inputs`-ს სჭირდება `api` 2 ან უფრო ახალი +@msg `inputs` needs the permission `doc.read` `inputs`-ს სჭირდება ნებართვა `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c0545a3da..88a5e6fee 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5421,6 +5421,11 @@ axis Y Axis Y축 @msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다 @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다 @msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 +@msg only a filter declares `inputs` 필터만 `inputs`를 선언할 수 있습니다 +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs`에 알 수 없는 개체 종류 `{shown}`이(가) 있습니다. type, image 또는 symbolInstance를 사용하세요 +@msg `inputs` lists `{shown}` twice `inputs`에 `{shown}`이(가) 두 번 나열되어 있습니다 +@msg `inputs` needs `api` 2 or later `inputs`에는 `api` 2 이상이 필요합니다 +@msg `inputs` needs the permission `doc.read` `inputs`에는 `doc.read` 권한이 필요합니다 @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ff2b9bd6b..a788257b5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5697,6 +5697,11 @@ axis Y Axis Oś Y @msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}` @msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy +@msg only a filter declares `inputs` tylko filtr deklaruje `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` wymienia nieznany rodzaj obiektu `{shown}`: użyj type, image lub symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` wymienia `{shown}` dwa razy +@msg `inputs` needs `api` 2 or later `inputs` wymaga `api` 2 lub nowszego +@msg `inputs` needs the permission `doc.read` `inputs` wymaga uprawnienia `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3340597d8..a2dc3ad53 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5759,6 +5759,11 @@ axis Y Axis Eixo Y @msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}` @msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes +@msg only a filter declares `inputs` somente um filtro declara `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` cita um tipo de objeto desconhecido `{shown}`: use type, image ou symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` lista `{shown}` duas vezes +@msg `inputs` needs `api` 2 or later `inputs` exige `api` 2 ou posterior +@msg `inputs` needs the permission `doc.read` `inputs` exige a permissão `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede @msg unknown permission `{shown}` permissão desconhecida `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3732558f7..3e0425623 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5455,6 +5455,11 @@ axis Y Axis Ось Y @msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}` @msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды +@msg only a filter declares `inputs` `inputs` может объявлять только фильтр +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указан неизвестный вид объекта `{shown}`: используйте type, image или symbolInstance +@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано дважды +@msg `inputs` needs `api` 2 or later для `inputs` нужен `api` 2 или выше +@msg `inputs` needs the permission `doc.read` для `inputs` нужно разрешение `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт @msg unknown permission `{shown}` неизвестное разрешение `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 58ceab701..aaefbc72d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5867,6 +5867,11 @@ axis Y Axis Y-axel @msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}` @msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger +@msg only a filter declares `inputs` bara ett filter anger `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` anger en okänd objekttyp `{shown}`: använd type, image eller symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` anger `{shown}` två gånger +@msg `inputs` needs `api` 2 or later `inputs` kräver `api` 2 eller senare +@msg `inputs` needs the permission `doc.read` `inputs` kräver behörigheten `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9385b30cc..c037a53ea 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5801,6 +5801,11 @@ axis Y Axis Y ekseni @msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz @msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş +@msg only a filter declares `inputs` `inputs` yalnızca bir filtrede bildirilebilir +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` bilinmeyen bir nesne türü belirtiyor: `{shown}`; type, image veya symbolInstance kullanın +@msg `inputs` lists `{shown}` twice `inputs` `{shown}` değerini iki kez listeliyor +@msg `inputs` needs `api` 2 or later `inputs` için `api` 2 veya üstü gerekir +@msg `inputs` needs the permission `doc.read` `inputs` için `doc.read` izni gerekir @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez @msg unknown permission `{shown}` bilinmeyen izin `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1b96c235e..f66048c92 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5440,6 +5440,11 @@ axis Y Axis Вісь Y @msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}` @msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі +@msg only a filter declares `inputs` `inputs` може оголошувати лише фільтр +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance в `inputs` указано невідомий вид об’єкта `{shown}`: використовуйте type, image або symbolInstance +@msg `inputs` lists `{shown}` twice в `inputs` `{shown}` указано двічі +@msg `inputs` needs `api` 2 or later для `inputs` потрібен `api` 2 або новіший +@msg `inputs` needs the permission `doc.read` для `inputs` потрібен дозвіл `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає @msg unknown permission `{shown}` невідомий дозвіл `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ce98bc978..fb8c36009 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5441,6 +5441,11 @@ axis Y Axis Trục Y @msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}` @msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần +@msg only a filter declares `inputs` chỉ bộ lọc mới khai báo `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` nêu một loại đối tượng không xác định `{shown}`: dùng type, image hoặc symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` liệt kê `{shown}` hai lần +@msg `inputs` needs `api` 2 or later `inputs` cần `api` 2 trở lên +@msg `inputs` needs the permission `doc.read` `inputs` cần quyền `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó @msg unknown permission `{shown}` quyền không xác định `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2cf40630..f449dc315 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5682,6 +5682,11 @@ axis Y Axis Y 轴 @msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具 @msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 +@msg only a filter declares `inputs` 只有滤镜才能声明 `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的对象类型 `{shown}`:请使用 type、image 或 symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` 列出了两次 `{shown}` +@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高 +@msg `inputs` needs the permission `doc.read` `inputs` 需要权限 `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 @msg unknown permission `{shown}` 未知权限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9440e225f..e5b3b5641 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5478,6 +5478,11 @@ axis Y Axis Y 軸 @msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組 @msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 +@msg only a filter declares `inputs` 只有濾鏡才能宣告 `inputs` +@msg `inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance `inputs` 列出了未知的物件類型 `{shown}`:請使用 type、image 或 symbolInstance +@msg `inputs` lists `{shown}` twice `inputs` 列出了兩次 `{shown}` +@msg `inputs` needs `api` 2 or later `inputs` 需要 `api` 2 或更高 +@msg `inputs` needs the permission `doc.read` `inputs` 需要權限 `doc.read` @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 @msg unknown permission `{shown}` 未知權限 `{shown}` @msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 diff --git a/docs/mcp.md b/docs/mcp.md index 9220b1c62..d2606e316 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6086,7 +6086,10 @@ WebAssembly plug-ins ([plugins.md](plugins.md)) are reached through commands; th them again at startup (`plugin.remove` deletes them there): `plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one -undo step; it may change, delete or add objects, and the result is selected). A live-effect plug-in is an effect with +undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` +(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) is also given the selected objects of those +kinds to read: its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an +error naming the object and the field, and the document and undo history are unchanged. A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and `effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..fb1c06a6e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -145,7 +145,10 @@ capability model proposed for PhotoCraft plug-ins `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. - `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host - imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. + imports](#host-imports-api-level-2) and a filter's `inputs` ([reading type, images and + symbols](#reading-type-images-and-symbols)). A higher one is refused. `plugin.list` and `plugin.info` give it as + `api`. `inputs` is not a permission: the objects it asks for are read under `doc.read`, which a filter that lists + `permissions` and `inputs` must list. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed @@ -237,6 +240,11 @@ still returns `1`. filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. - `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names ([permissions](#permissions)); without it, what its kind implies. +- `inputs` (filters at `api` 2 only): the kinds of object besides paths and compound paths the filter reads, any of + `"type"`, `"image"` and `"symbolInstance"`, each at most once (`"inputs": ["type", "image"]`). Left out or `[]`, the + filter gets paths and compound paths alone, as before. An unknown, repeated or wrongly typed entry, `inputs` on + another kind, below `api` 2, or with `permissions` that leave out `doc.read`, is refused when the plug-in is + installed, and so is `api` 2 on hosts from before it. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. @@ -276,8 +284,9 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet `input` is UTF-8 JSON: `{"objects": [...]}`. An object filter gets the selected paths and compound paths in paint order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound -guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never -touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one +guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), +which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and +symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry. @@ -323,6 +332,49 @@ type and `blob` but without their data, which follows the JSON in a saved file; `plugin.` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed. - `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). +### Reading type, images and symbols + +A filter whose manifest lists `inputs` also gets the selected objects of those kinds, in paint order among the paths +and under the same rules (hidden and locked ones, type that clips a clip group and anything inside a symbol, a placed +document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each +has `id`, `type`, `name` (when it has one), `bounds` (`null` until the object has been laid out or drawn) and +`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. +In this example the character and paragraph styles are cut short; a filter gets every attribute: + +```json +{"objects": [ + {"id": 17, "type": "type", "bounds": [10, 38, 52, 52], "transform": [1, 0, 0, 1, 10, 50], + "text": {"kind": "point", "vertical": false, + "runs": [{"text": "Hello\nthere", "style": {"font_family": "Source Sans 3", "size": 12}}], + "paragraphs": [{"justify": "Left"}, {"justify": "Center"}]}, + "truncated": false}, + {"id": 18, "type": "image", "bounds": [0, 0, 4, 2], "transform": [2, 0, 0, 2, 0, 0], "pixelSize": [2, 1], + "colorMode": "rgb", "linked": false, "pixelsOmitted": false, "pixelsBase64": "AAAegGQAHoA="}, + {"id": 19, "type": "symbolInstance", "bounds": [100, 0, 110, 10], "transform": [1, 0, 0, 1, 100, 0], + "symbolId": "Star", "overrides": {}} +]} +``` + +- Type (`"type"`): `text.kind` is `"point"`, `"area"` or `"path"`; `runs` are its text runs with their character + attributes as the native file stores them (`inline` when a run is an inline graphic), and `paragraphs` holds one + paragraph style per paragraph (the paragraphs are split at `\n`). No font data or glyph outlines are given. Type + whose runs hold more than 256 KiB of UTF-8 text, or more than 4,096 runs or paragraphs, comes with `truncated: + true` and empty `runs` and `paragraphs`. +- An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or + `"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443 + pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and + `pixelsOmitted: false`. A larger one, and a linked image (`linked: true`), has `pixelsOmitted: true` and no pixels. + The host never reads a linked image's file for a filter and never gives its path. +- A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the + document has no symbol of that name. `overrides` is `{}`. + +These objects are read only. The output must list each of them exactly once, as `{"id": 17}` alone or exactly as it +was given (numbers compare by value, so `1` and `1.0` are the same). A changed or added field, one left out, an object +listed twice or left out, and a new object with one of these types are refused, and the document and undo history +stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only +object was left out`. New objects listed after one of them go just above it. Their text and pixels count towards the +input's half of the memory cap, and an echo of them towards the 64 MiB output cap. + ### Parameters `params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_context`: @@ -420,6 +472,6 @@ per change of their input, parameters or paints (results are cached), not per fr - Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, importers (plug-in formats that open files). -- Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or - symbols. +- Live effects that change paint (they reshape geometry; an object filter recolours), and filters that change type, + images or symbol instances (a filter at `api` 2 reads them, see [`inputs`](#reading-type-images-and-symbols)). - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 31dc47a26..dab0ea86e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1488,6 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs), images with their size, color mode and, when embedded and at most 1 MiB in base64, their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From 66a01584aeb40cd2bec99a3a59f02f9fa01b1345 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 00:15:36 -0800 Subject: [PATCH 04/18] Plug-ins: inputs need a granted doc.read and stay within totals A filter manifest that lists `inputs` must now list `doc.read` in `permissions`, so installing it asks the user to grant them and the Additional Plug-ins Folder loads it only once granted. Left out, the manifest does not install. The pixels of all images in one filter input take at most 16 MiB of base64, and the runs and paragraphs of all its type at most 16 MiB of JSON. An image whose pixels do not fit what is left comes with `pixelsOmitted`, checked before it is decoded, and type that does not fit comes with `truncated`. The read-only objects are kept once and written into the input from there. A field a filter adds to a read-only object is named in the refusal with control and bidirectional characters escaped, cut to 64 characters. --- crates/engine/src/cmd/plugin.rs | 2 +- crates/engine/src/tests/plugins.rs | 16 +++- crates/engine/tests/plugin_fuzz.rs | 7 +- crates/plugins/src/manifest.rs | 24 +++-- crates/plugins/src/objects.rs | 136 ++++++++++++++++++++-------- crates/plugins/src/objects/tests.rs | 51 +++++++++++ docs/mcp.md | 8 +- docs/plugins.md | 19 ++-- docs/target-app-parity.md | 2 +- 9 files changed, 202 insertions(+), 63 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 316221488..f352bdac1 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -531,7 +531,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance) is also given the selected objects of those kinds to read: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; 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): 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 ee67fe603..d80873ac8 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -663,16 +663,26 @@ fn a_filter_does_only_what_its_permissions_allow() { /// 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. +/// 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,"inputs":["type"]}}"#); + let reads = + |id: &str| format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"]}}"#); + 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"]})) + }; let echo = "test.engine.inputs.echo"; - let listed = install_wat(&mut s, &tpl::module(&reads(echo), tpl::ECHO, "", 1)); + 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(); diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 5dc2e2a5e..089e6255e 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -365,7 +365,8 @@ 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. One that installs and echoes its input on a document + /// 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( @@ -374,7 +375,7 @@ proptest! { long in prop::option::weighted(0.1, 4usize..300), inputs in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), api in prop_oneof![2 => Just(json!(2)), 1 => Just(json!(1)), 1 => Just(json!(3)), 1 => arb_junk()], - permissions in prop_oneof![Just(Value::Null), Just(json!(["doc.read", "doc.write"])), Just(json!(["doc.write"]))], + 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" { @@ -396,6 +397,8 @@ proptest! { 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(); diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 0016c533c..1c9bca8bc 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -284,8 +284,8 @@ pub struct Manifest { #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option>, /// The kinds of object a filter asks to be given besides paths and compound paths, by - /// [`InputKind::name`]: only a filter at `api` 2 or later lists them. Left out or empty, it is - /// given paths and compound paths alone. + /// [`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. @@ -377,7 +377,8 @@ impl Manifest { } /// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists - /// any, with `doc.read` among its permissions when it lists them. + /// 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(()); @@ -397,7 +398,7 @@ impl Manifest { 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())) { + 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(()) @@ -758,17 +759,20 @@ mod tests { } /// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once, - /// with `doc.read` when it lists permissions. Left out or empty, it asks for none and isn't - /// written back. Anything else is refused naming `inputs`. + /// 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,"inputs":["type","image","symbolInstance"]}"#) - .unwrap(); + 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":[]}"#, @@ -789,6 +793,8 @@ mod tests { (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`"), ] { diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 9fa37ce54..e8db87319 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -11,7 +11,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, TextKind, TextObject, + Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes, + TextKind, TextObject, }; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; @@ -42,6 +43,12 @@ 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). @@ -175,23 +182,56 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { } /// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are -/// in it as [`encode_read_only`] gives them. Also returns those objects by id, which the filter must -/// return as they were ([`decode_output_with`]). +/// in it as [`encode_read_only`] gives them, within [`MAX_INPUT_PIXELS_TOTAL`] and +/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order. Also returns those objects by id, which the +/// filter must return as they were ([`decode_output_with`]). pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind]) -> (Vec, HashMap) { + encode_input_within(doc, ids, kinds, Allowance::FULL) +} + +/// What an input's images and type may still be given of its totals, as its objects are encoded in +/// order: characters of base64 for pixels, bytes of JSON for runs and paragraphs. +#[derive(Clone, Copy, Debug)] +struct Allowance { + pixels: usize, + text: usize, +} + +impl Allowance { + const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL }; +} + +/// [`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) -> (Vec, HashMap) { let mut read_only = HashMap::new(); - let objects: Vec = ids - .iter() - .filter_map(|id| doc.node(*id)) - .filter_map(|n| match input_kind(n).filter(|k| kinds.contains(k)) { - Some(_) => { - let o = encode_read_only(doc, n)?; - read_only.insert(n.id, o.clone()); - Some(o) - } - None => encode(n), - }) - .collect(); - (serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default(), read_only) + 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: &Value = match input_kind(n).filter(|k| kinds.contains(k)) { + Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left) { + Some(o) => read_only.entry(n.id).insert_entry(o).into_mut(), + None => continue, + }, + None => match encode(n) { + Some(o) => { + path = o; + &path + } + None => continue, + }, + }; + if !std::mem::take(&mut first) { + out.push(b','); + } + if serde_json::to_writer(&mut out, o).is_err() { + return (Vec::new(), HashMap::new()); + } + } + out.extend_from_slice(b"]}"); + (out, read_only) } /// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). @@ -222,11 +262,20 @@ fn affine_json(xf: Affine) -> Value { /// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when /// the document has no such symbol, and `overrides: {}`. pub fn encode_read_only(doc: &Document, n: &Node) -> Option { - let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of(&[n.id], false))}); + let mut left = Allowance::FULL; + encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left) +} + +/// [`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 are omitted (`pixelsOmitted`, `truncated`), and an image's size is +/// checked against it before anything is decoded. +fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Option { + let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); match &n.kind { NodeKind::Text(t) => { o["transform"] = affine_json(t.xf); - let (text, truncated) = text_json(t); + let (text, truncated) = text_json(t, &mut left.text); o["text"] = text; o["truncated"] = json!(truncated); } @@ -235,13 +284,15 @@ pub fn encode_read_only(doc: &Document, n: &Node) -> Option { let linked = im.link.is_some(); let blob = doc.image_blob(im); // A linked image's pixels are its file's: never given (nor its path). - let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height)); + let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height, MAX_INPUT_PIXELS.min(left.pixels))); o["pixelSize"] = json!([im.width, im.height]); o["colorMode"] = json!(blob.map_or("unknown", color_mode)); o["linked"] = json!(linked); o["pixelsOmitted"] = json!(pixels.is_none()); if let Some(p) = pixels { - o["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p)); + let b64 = craft_vectorcraft_format::base64::encode(&p); + left.pixels = left.pixels.saturating_sub(b64.len()); + o["pixelsBase64"] = json!(b64); } } NodeKind::SymbolInstance { symbol, xf } => { @@ -261,8 +312,9 @@ pub fn encode_read_only(doc: &Document, n: &Node) -> Option { } /// The text of type `t` as a filter reads it, and whether it was too long to give -/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`]). -fn text_json(t: &TextObject) -> (Value, bool) { +/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives +/// is taken from). +fn text_json(t: &TextObject, left: &mut usize) -> (Value, bool) { let kind = match t.kind { TextKind::Point => "point", TextKind::Area { .. } => "area", @@ -270,16 +322,21 @@ fn text_json(t: &TextObject) -> (Value, bool) { }; let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); let paragraphs = t.paragraph_count(); - let truncated = bytes > MAX_INPUT_TEXT || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; - let (runs, paras): (Vec, Vec) = if truncated { - (Vec::new(), Vec::new()) - } else { + // The text alone is never more bytes than its JSON, so type that can't fit isn't built. + let mut truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; + let (mut runs, mut paras): (Vec, Vec) = (Vec::new(), Vec::new()); + if !truncated { let value = |v: std::result::Result| v.unwrap_or(Value::Null); - ( - t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(), - (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(), - ) - }; + runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); + paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); + let size = runs.iter().chain(¶s).fold(0usize, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); + match left.checked_sub(size) { + Some(rest) => *left = rest, + None => { + (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + } + } + } (json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated) } @@ -300,11 +357,11 @@ fn color_mode(b: &ImageBlob) -> &'static str { if readable.is_some() { "rgb" } else { "unknown" } } -/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit -/// [`MAX_INPUT_PIXELS`] in base64; the size is checked from the header before anything is decoded. -fn rgba_pixels(b: &ImageBlob, w: u32, h: u32) -> Option> { +/// 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) * u64::from(h) * 4; - if bytes == 0 || bytes.div_ceil(3) * 4 > MAX_INPUT_PIXELS as u64 { + if bytes == 0 || bytes.div_ceil(3) * 4 > max as u64 { return None; } let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); @@ -629,7 +686,9 @@ fn decode_objects(v: &Value, read_only: &HashMap) -> Result Result<()> { if o.as_object().is_some_and(|m| m.len() == 1) { return Ok(()); @@ -647,7 +706,10 @@ fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> { }; match changed.or_else(|| first_difference(o, given, "")) { None => Ok(()), - Some(field) => Err(out_err(at, format!("read-only field '{field}' changed"))), + Some(field) => { + let field = crate::permissions::display_text(&field, crate::permissions::MAX_SHOWN_FIELD); + Err(out_err(at, format!("read-only field '{field}' changed"))) + } } } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 48b0a38b3..dddf62fbf 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -471,6 +471,57 @@ fn declared_symbol_instance_keeps_identity_and_transform() { 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(); + let size: usize = ["runs", "paragraphs"].iter().flat_map(|k| one["text"][k].as_array().unwrap()).map(|v| v.to_string().len()).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 }); + 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); + let all = String::from_utf8(bytes).unwrap(); + assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}"); +} + +/// 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]); + 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 diff --git a/docs/mcp.md b/docs/mcp.md index d2606e316..af66deab4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6087,9 +6087,11 @@ 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 filter whose manifest lists `inputs` -(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) is also given the selected objects of those -kinds to read: its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an -error naming the object and the field, and the document and undo history are unchanged. A live-effect plug-in is an effect with +(`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`): its output returns +each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the +field, and the document and undo history are unchanged. A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and `effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions diff --git a/docs/plugins.md b/docs/plugins.md index fb1c06a6e..05132fc02 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -148,7 +148,7 @@ capability model proposed for PhotoCraft plug-ins imports](#host-imports-api-level-2) and a filter's `inputs` ([reading type, images and symbols](#reading-type-images-and-symbols)). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. `inputs` is not a permission: the objects it asks for are read under `doc.read`, which a filter that lists - `permissions` and `inputs` must list. + `inputs` must list in `permissions`, so installing it asks the user to grant them. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed @@ -243,7 +243,7 @@ still returns `1`. - `inputs` (filters at `api` 2 only): the kinds of object besides paths and compound paths the filter reads, any of `"type"`, `"image"` and `"symbolInstance"`, each at most once (`"inputs": ["type", "image"]`). Left out or `[]`, the filter gets paths and compound paths alone, as before. An unknown, repeated or wrongly typed entry, `inputs` on - another kind, below `api` 2, or with `permissions` that leave out `doc.read`, is refused when the plug-in is + 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. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): @@ -359,12 +359,16 @@ In this example the character and paragraph styles are cut short; a filter gets 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`. + 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 larger one, and a linked image (`linked: true`), has `pixelsOmitted: true` and no pixels. - The host never reads a linked image's file for a filter and never gives its path. + `pixelsOmitted: false`. 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 `{}`. @@ -372,8 +376,9 @@ These objects are read only. The output must list each of them exactly once, as was given (numbers compare by value, so `1` and `1.0` are the same). A changed or added field, one left out, an object listed twice or left out, and a new object with one of these types are refused, and the document and undo history stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only -object was left out`. New objects listed after one of them go just above it. Their text and pixels count towards the -input's half of the memory cap, and an echo of them towards the 64 MiB output cap. +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. Their text and pixels count toward the input's +half of the memory cap, and an echo of them toward the 64 MiB output cap. ### Parameters diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dab0ea86e..812e777ed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1488,7 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs), images with their size, color mode and, when embedded and at most 1 MiB in base64, their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **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 (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From e5674ba1c5c1171507bf484a30269c436a5d596a Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:40:55 -0500 Subject: [PATCH 05/18] EPS/.ai editing data: test a stack of a solid fill and a stroke without a brush A path whose style is a solid fill under a 50% Screen stroke comes in as itself with both, each with its own transparency. --- crates/eps/src/import/ai/tests.rs | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 43c24c0e1..7d907f727 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5372,6 +5372,39 @@ fn a_path_with_several_gradient_fills_comes_in_as_itself_where_its_look_places_t 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)]); +} + /// An art style named `name` (its bytes as written) that fills with colour `xa` (`c m y k r g b Xa`). fn fill_style(name: &str, xa: &str) -> String { fill_style_with(name, xa, "") From 96bdb813a6628290696bf80dad40430f27c06572 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 04:42:17 -0800 Subject: [PATCH 06/18] Plug-ins: read-only inputs count toward the input's memory share A filter's input is now encoded within the half of the plug-in's memory cap that the input and the parameters share. Every object counts toward it in paint order, with its id, bounds, transform and name. Type runs and image pixels that don't fit what is left come with truncated or pixelsOmitted, and a type, image or symbol instance whose other fields alone don't fit fails the run before the plug-in starts with "memory budget (object N needs S bytes of input, L left)". --- crates/engine/src/cmd/plugin.rs | 6 +- crates/plugins/src/objects.rs | 153 ++++++++++++++++++---------- crates/plugins/src/objects/tests.rs | 53 ++++++++-- docs/mcp.md | 5 +- docs/plugins.md | 10 +- docs/target-app-parity.md | 2 +- 6 files changed, 164 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index f352bdac1..e5ecc9992 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -162,8 +162,10 @@ impl FilterRun { // What the plug-in keeps with the document, as it last wrote it. let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); - let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds); let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + // 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, input, params, inputs, read_only, parent, document }) } @@ -531,7 +533,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. 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): 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}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; 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: 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/plugins/src/objects.rs b/crates/plugins/src/objects.rs index e8db87319..fa309ae62 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -178,60 +178,80 @@ 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 { - encode_input_with(doc, ids, &[]).0 + 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. Also returns those objects by id, which the -/// filter must return as they were ([`decode_output_with`]). -pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind]) -> (Vec, HashMap) { - encode_input_within(doc, ids, kinds, Allowance::FULL) +/// [`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`), and a read-only object whose other fields alone don't fit is an +/// [`Error::Limit`] naming it and its size. 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 }) } -/// What an input's images and type may still be given of its totals, as its objects are encoded in -/// order: characters of base64 for pixels, bytes of JSON for runs and paragraphs. +/// 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 }; + 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) -> (Vec, HashMap) { +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: &Value = match input_kind(n).filter(|k| kinds.contains(k)) { - Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left) { - Some(o) => read_only.entry(n.id).insert_entry(o).into_mut(), + let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) { + 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 + (&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 (Vec::new(), HashMap::new()); + return Ok((Vec::new(), HashMap::new())); } + let size = out.len() - start; + if checked && size > left.input { + return Err(too_large(n, size, left.input)); + } + left.input = left.input.saturating_sub(size); } out.extend_from_slice(b"]}"); - (out, read_only) + 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 kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). @@ -263,37 +283,34 @@ fn affine_json(xf: Affine) -> Value { /// 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) + 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 are omitted (`pixelsOmitted`, `truncated`), and an image's size is -/// checked against it before anything is decoded. -fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Option { - let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); +/// 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); - let (text, truncated) = text_json(t, &mut left.text); - o["text"] = text; - o["truncated"] = json!(truncated); + o["text"] = text_json(t, None).0; + o["truncated"] = json!(false); } NodeKind::Image(im) => { o["transform"] = affine_json(im.xf); - let linked = im.link.is_some(); - let blob = doc.image_blob(im); - // A linked image's pixels are its file's: never given (nor its path). - let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height, MAX_INPUT_PIXELS.min(left.pixels))); o["pixelSize"] = json!([im.width, im.height]); - o["colorMode"] = json!(blob.map_or("unknown", color_mode)); - o["linked"] = json!(linked); - o["pixelsOmitted"] = json!(pixels.is_none()); - if let Some(p) = pixels { - let b64 = craft_vectorcraft_format::base64::encode(&p); - left.pixels = left.pixels.saturating_sub(b64.len()); - o["pixelsBase64"] = json!(b64); - } + 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); @@ -303,37 +320,69 @@ fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left } o["overrides"] = json!({}); } - _ => return None, + _ => return Ok(None), } if let Some(name) = &n.name { o["name"] = json!(name); } - Some(o) + 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)) } /// 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). -fn text_json(t: &TextObject, left: &mut usize) -> (Value, bool) { +/// 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 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. - let mut truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; let (mut runs, mut paras): (Vec, Vec) = (Vec::new(), Vec::new()); - if !truncated { - let value = |v: std::result::Result| v.unwrap_or(Value::Null); - runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); - paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); - let size = runs.iter().chain(¶s).fold(0usize, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); - match left.checked_sub(size) { - Some(rest) => *left = rest, - None => { - (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + 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 { + let value = |v: std::result::Result| v.unwrap_or(Value::Null); + runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); + paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); + let commas = runs.len().saturating_sub(1) + paras.len().saturating_sub(1); + let size = runs.iter().chain(¶s).fold(commas, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); + match left.checked_sub(size) { + Some(rest) => *left = rest, + None => { + (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + } } } } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index dddf62fbf..9277ce708 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -366,7 +366,7 @@ fn legacy_filter_input_bytes_are_unchanged() { 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, &[]); + 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); @@ -377,7 +377,7 @@ fn legacy_filter_input_bytes_are_unchanged() { d.node_mut(text).unwrap().visible = false; d.node_mut(star).unwrap().locked = true; assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat()); - let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance]); + let (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"]); @@ -485,10 +485,11 @@ fn input_totals_cover_images_and_type_in_order() { }; 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(); - let size: usize = ["runs", "paragraphs"].iter().flat_map(|k| one["text"][k].as_array().unwrap()).map(|v| v.to_string().len()).sum(); + // 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 }); + 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); @@ -500,18 +501,56 @@ fn input_totals_cover_images_and_type_in_order() { 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); + 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}"); + } +} + +/// 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 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]); + 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)); @@ -530,7 +569,7 @@ fn a_changed_field_is_named_escaped_and_short() { 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); + 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(); diff --git a/docs/mcp.md b/docs/mcp.md index af66deab4..53f6ff89b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6089,7 +6089,10 @@ schema, defaults). An object filter runs on the selected paths and compound path 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`): its output returns +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. 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 diff --git a/docs/plugins.md b/docs/plugins.md index 05132fc02..dd39fe512 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -377,8 +377,14 @@ was given (numbers compare by value, so `1` and `1.0` are the same). A changed o 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. Their text and pixels count toward the input's -half of the memory cap, and an echo of them toward the 64 MiB output cap. +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)`. An echo of them counts +toward the 64 MiB output cap. ### Parameters diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 812e777ed..fa4a8f0d6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1488,7 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) 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 (never a linked file's), and symbol instances with their symbol and transform. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **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 (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. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From 56f8e3208a915d0f37e8ae80119d64e8893639be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:13:18 -0800 Subject: [PATCH 07/18] i18n: French rows keep the no-break space before punctuation --- crates/ui-egui/src/i18n/fr.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 21173799a..c5e915074 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5841,7 +5841,7 @@ axis Y Axis Axe Y @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` 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` From 5540a5e79710ffb267830d3b1a12e0cb7df2f132 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:27:11 -0800 Subject: [PATCH 08/18] Plug-ins: an oversized path fails a filter run, and objects that clip stay out of its input A path or compound path that doesn't fit what is left of the input now fails the run with the runtime's own "selection is too large to hand over" limit before the rest of the selection is written, instead of later read-only objects reporting a size error. Objects that clip a clip group are left out for every filter: compound paths and paths inside a group that clips one reached filters before, as type, images and symbol instances did. --- crates/engine/src/cmd/plugin.rs | 2 +- crates/plugins/src/objects.rs | 37 +++++++++++++-------- crates/plugins/src/objects/tests.rs | 50 +++++++++++++++++++++++++++++ crates/plugins/src/runtime.rs | 5 ++- docs/mcp.md | 3 +- docs/plugins.md | 13 +++++--- docs/target-app-parity.md | 2 +- 7 files changed, 89 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 1a6ff4189..61ac3ff6d 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -703,7 +703,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. 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: 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}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, 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): 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/plugins/src/objects.rs b/crates/plugins/src/objects.rs index fa309ae62..f5d746bcf 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -186,8 +186,9 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { /// [`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`), and a read-only object whose other fields alone don't fit is an -/// [`Error::Limit`] naming it and its size. Also returns those objects by id, which the filter +/// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`] +/// naming it and its size, and a path or compound path that doesn't fit is the limit the runtime +/// gives an input past its share. 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 }) @@ -240,10 +241,12 @@ fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut return Ok((Vec::new(), HashMap::new())); } let size = out.len() - start; - if checked && size > left.input { - return Err(too_large(n, size, left.input)); + // 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 = left.input.saturating_sub(size); + left.input -= size; } out.extend_from_slice(b"]}"); Ok((out, read_only)) @@ -976,33 +979,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 { filter_targets_with(doc, selected, &[]) } /// [`filter_targets`] for a filter that asks for `kinds` too: type, images and symbol instances of -/// those kinds join the paths in paint order, under the same rules (type that clips a clip group is -/// left out, and so is anything inside a symbol, a placed document or a live object). +/// 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 { - fn visit(list: &[Arc], sel: &HashSet, inside: bool, clips: bool, kinds: &[InputKind], out: &mut 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, n.clips(), kinds, out), - _ if picked && !(clips && i == 0) && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id), + 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, false, kinds, &mut out); + visit(&doc.layers, &sel, false, false, false, kinds, &mut out); out } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 9277ce708..7ff87f6e9 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -544,6 +544,56 @@ fn an_object_too_large_to_give_is_a_limit_naming_it() { 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)"); +} + +/// 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 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] diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index d37a9159d..77b6d8732 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) { @@ -305,7 +308,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/docs/mcp.md b/docs/mcp.md index d08fa834d..36586303a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6479,7 +6479,8 @@ undo step; it may change, delete or add objects, and the result is selected). A 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. Its output returns +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`. 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 diff --git a/docs/plugins.md b/docs/plugins.md index 8325a5737..f13e28e5c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -398,9 +398,10 @@ type and `blob` but without their data, which follows the JSON in a saved file; ### Reading type, images and symbols A filter whose manifest lists `inputs` also gets the selected objects of those kinds, in paint order among the paths -and under the same rules (hidden and locked ones, type that clips a clip group and anything inside a symbol, a placed -document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each -has `id`, `type`, `name` (when it has one), `bounds` (`null` until the object has been laid out or drawn) and +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: @@ -446,7 +447,9 @@ Every object in the input counts toward the half of the memory cap that the inpu 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)`. An echo of them counts +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 echo of them counts toward the 64 MiB output cap. ### Parameters @@ -546,6 +549,6 @@ per change of their input, parameters or paints (results are cached), not per fr - Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, importers (plug-in formats that open files). -- Live effects that change paint (they reshape geometry; an object filter recolours), and filters that change type, +- Live effects that change paint (they reshape geometry; an object filter recolors), 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 49e26abb4..ee3fae0b8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1542,8 +1542,8 @@ 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; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **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 (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, and a path or compound path that doesn't fit fails it too. 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 progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) 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 (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. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From 027dc8894d8de99c84d602daf0303ddc8e572b1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:31:09 -0800 Subject: [PATCH 09/18] Plug-ins: a filter run given more read-only objects than an output lists fails up front An output lists at most 100,000 objects and must return every read-only object, so an input with more than that many is now an object-budget limit before the plug-in starts. --- crates/engine/src/cmd/plugin.rs | 2 +- crates/plugins/src/objects.rs | 12 ++++++++++-- crates/plugins/src/objects/tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/i18n/ca.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fi.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hi.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ka.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sv.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ docs/mcp.md | 3 ++- docs/plugins.md | 3 ++- docs/target-app-parity.md | 2 +- 28 files changed, 80 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 61ac3ff6d..9b17b7bfc 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -703,7 +703,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, 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): 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}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, 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/plugins/src/objects.rs b/crates/plugins/src/objects.rs index f5d746bcf..5c915b5a2 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -187,8 +187,9 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { /// 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, and a path or compound path that doesn't fit is the limit the runtime -/// gives an input past its share. Also returns those objects by id, which the filter +/// 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 }) @@ -221,6 +222,8 @@ fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut 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, @@ -257,6 +260,11 @@ 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 { diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 7ff87f6e9..308bcd2a4 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -556,6 +556,26 @@ fn a_path_past_the_input_capacity_is_the_runtime_limit() { 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] diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0641c866c..4b03660b0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5921,6 +5921,8 @@ digits Malayalam Malaiàlam @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 259166d53..463e27814 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5908,6 +5908,8 @@ axis Y Axis osa Y @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 45fb3a62b..ebbf0ddb3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5490,6 +5490,8 @@ axis Y Axis Y-Achse @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 fd231b813..d06573533 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5859,6 +5859,8 @@ axis Y Axis Άξονας Y @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 639aebd6d..e393697bd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5849,6 +5849,8 @@ axis Y Axis Eje Y @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 f68b48957..5f18091f0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5938,6 +5938,8 @@ axis Y Axis Y-akseli @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 4f92cad34..830bbf617 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5904,6 +5904,8 @@ axis Y Axis Axe Y @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 90e753b42..8a8625ac5 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5268,6 +5268,8 @@ @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 58a91e22a..2223aae2d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5891,6 +5891,8 @@ axis Y Axis Y tengely @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 5f42a3cd2..d82b28c08 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5524,6 +5524,8 @@ axis Y Axis Asse Y @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 dd9c0e48d..46430d37e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5589,6 +5589,8 @@ axis Y Axis Y 軸 @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 5901bed4c..5879c6b16 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5832,6 +5832,8 @@ axis Y Axis Y ღერძი @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 bf5b425dd..dbff16774 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5485,6 +5485,8 @@ axis Y Axis Y축 @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 1642eb636..a307b5c9f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5761,6 +5761,8 @@ axis Y Axis Oś Y @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 c62014e6a..2f5a5f879 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5823,6 +5823,8 @@ axis Y Axis Eixo Y @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 04dc1d172..5a33eddfe 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5519,6 +5519,8 @@ axis Y Axis Ось Y @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 848ea3445..909f32853 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5931,6 +5931,8 @@ axis Y Axis Y-axel @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 ef1b4a9a1..75fd5394a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5865,6 +5865,8 @@ axis Y Axis Y ekseni @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 f05fb9c04..0d2ded16b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5504,6 +5504,8 @@ axis Y Axis Вісь Y @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 b679531c2..47ae81470 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5505,6 +5505,8 @@ axis Y Axis Trục Y @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 dbef1f911..30a81a043 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5746,6 +5746,8 @@ axis Y Axis Y 轴 @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 89e208fa5..621b9cee3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5542,6 +5542,8 @@ axis Y Axis Y 軸 @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/mcp.md b/docs/mcp.md index 36586303a..9d58b25db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6480,7 +6480,8 @@ pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted 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`. Its output returns +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 diff --git a/docs/plugins.md b/docs/plugins.md index f13e28e5c..f0f205a89 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -449,7 +449,8 @@ order, with its id, bounds, transform and name. Type whose runs and paragraphs d 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 echo of them counts +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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ee3fae0b8..bf3f20da8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1542,7 +1542,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; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **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 (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, and a path or compound path that doesn't fit fails it too. 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. +- **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 (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 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. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. From 606077386c40806a9761d0f067da39407032cc84 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:41:47 -0800 Subject: [PATCH 10/18] Plug-ins: a filter reads a CMYK image's pixels through the working CMYK profile A CMYK image's pixels are now its inks shown through the working CMYK profile, by a table built the way the canvas builds its own, instead of the image decoder's own conversion to RGB. --- crates/plugins/src/objects.rs | 55 +++++++++++++++++++++++++++-- crates/plugins/src/objects/tests.rs | 19 ++++++++++ docs/plugins.md | 5 +-- docs/target-app-parity.md | 2 +- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 5c915b5a2..17d7c1e19 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -6,9 +6,10 @@ //! 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, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes, @@ -288,8 +289,8 @@ fn affine_json(xf: Affine) -> Value { /// or [`MAX_INPUT_RUNS`]; /// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`, /// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`] -/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top. A linked image's file -/// and path are never read or given; +/// `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 { @@ -424,6 +425,9 @@ fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option> { if bytes == 0 || bytes.div_ceil(3) * 4 > max as u64 { 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; @@ -437,6 +441,51 @@ fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option> { (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) +} + fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { Error::Output(format!("{at}: {msg}")) } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 308bcd2a4..d421f98dc 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -458,6 +458,25 @@ fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission } } +/// 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] diff --git a/docs/plugins.md b/docs/plugins.md index f0f205a89..1c4495b7c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -429,8 +429,9 @@ In this example the character and paragraph styles are cut short; a filter gets - 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`. 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: 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bf3f20da8..0848098d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1542,7 +1542,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; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **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 (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. +- **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 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. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. From ab029e1b533bb1081b335ebd715cc664aadcecf5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:44:42 -0800 Subject: [PATCH 11/18] Plug-ins: an image too large to count its pixels comes without them An image object whose width times height overflows is given with pixelsOmitted instead of overflowing the size check. --- crates/plugins/src/objects.rs | 4 ++-- crates/plugins/src/objects/tests.rs | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 17d7c1e19..3f572e0b8 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -421,8 +421,8 @@ fn color_mode(b: &ImageBlob) -> &'static str { /// 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) * u64::from(h) * 4; - if bytes == 0 || bytes.div_ceil(3) * 4 > max as u64 { + 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 { return None; } if b.is_cmyk() { diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index d421f98dc..85778552b 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -447,6 +447,10 @@ fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission 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(); From 3f484c4f0d7986259553f648ada9607d8a253264 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:47:35 -0800 Subject: [PATCH 12/18] Plug-ins: type's runs and paragraphs are measured as they are built for a filter Building a type input's runs and paragraphs stops at the first one past what is left of the text and input totals, instead of building all of them before the size check. --- crates/plugins/src/objects.rs | 55 +++++++++++++++++++++++------ crates/plugins/src/objects/tests.rs | 16 +++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 3f572e0b8..70193028c 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -367,6 +367,42 @@ fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left 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 @@ -385,16 +421,15 @@ fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) { // 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 { - let value = |v: std::result::Result| v.unwrap_or(Value::Null); - runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); - paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); - let commas = runs.len().saturating_sub(1) + paras.len().saturating_sub(1); - let size = runs.iter().chain(¶s).fold(commas, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); - match left.checked_sub(size) { - Some(rest) => *left = rest, - None => { - (runs, paras, truncated) = (Vec::new(), Vec::new(), true); - } + // 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); } } } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 85778552b..01e248a46 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -547,6 +547,22 @@ fn object_fields_count_toward_the_input_capacity() { } } +/// 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] From 2af6f54eabef72340815e94e37f2f44247b797c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 15:54:30 -0800 Subject: [PATCH 13/18] Plug-ins: oversized TIFFs stay out of a filter's decoder, and read-only ids compare by value A TIFF with more strips and planes than the decoder can count comes to a filter as "unknown" with no pixels, instead of panicking in the metadata read. An output that writes a read-only object's id as 17.0 now names that object, as other numbers in the object compare by value; a fractional id still names no object. --- crates/plugins/src/objects.rs | 34 ++++++++++++++++++----------- crates/plugins/src/objects/tests.rs | 33 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 70193028c..4808f3d22 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -11,6 +11,7 @@ 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::cmyk::oversized_tiff; use vectorcraft_doc::{ Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, @@ -437,8 +438,12 @@ fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) { } /// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or -/// `"unknown"` when it can't be read or only a linked image's preview is there. +/// `"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"; } @@ -457,7 +462,7 @@ fn color_mode(b: &ImageBlob) -> &'static str { /// 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 { + 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() { @@ -802,19 +807,16 @@ fn decode_objects(v: &Value, read_only: &HashMap) -> Result { - 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() }) + .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")); } - None => decode_object(o, &format!("objects[{i}]"), &mut anchors), + 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(); @@ -828,6 +830,12 @@ fn decode_objects(v: &Value, read_only: &HashMap) -> Result 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 diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 01e248a46..0a7b1d0d1 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -653,6 +653,31 @@ fn objects_that_clip_a_clip_group_are_not_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] @@ -692,6 +717,14 @@ fn read_only_kind_field_and_omission_refuse() { 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)]}); From 3123b4af74949dbcb7c7cdac1b5d123fae40d156 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:35:54 -0800 Subject: [PATCH 14/18] Plug-ins: a passed reference check keeps a failed run's error A painted effect's cached result passed its swatch and pattern check and cleared the error of a failed run, so plugin.info reported none while the effect still failed on another object. A run and a reference check now record their errors separately, and each clears only its own. --- crates/engine/src/tests/plugins.rs | 18 +++++++ crates/plugins/src/effect.rs | 80 +++++++++++++++++++++++++----- docs/plugins.md | 4 +- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 1bfd30642..682895abc 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -439,6 +439,24 @@ fn a_painted_effect_on_a_fill_and_refused_results() { } } +/// A result refused for a swatch the document doesn't have stays cached: once the document has the +/// swatch, the next check of that result clears the error. +#[test] +fn a_refused_swatch_s_error_clears_once_the_document_has_the_swatch() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let mut later = solid("#00ff00"); + later["swatch"] = json!("Later"); + let output = json!({"objects": [{"path": square_at(40.0), "fills": [later]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.laterswatch", true), &output)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.laterswatch"})); + drawn(&s); + assert!(run(&mut s, "plugin.info", json!({"id": "test.engine.laterswatch"}))["lastError"].is_string()); + run(&mut s, "swatch.new", json!({"name": "Later", "color": "#00ff00", "global": true})); + drawn(&s); + assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.laterswatch"}))["lastError"], Value::Null); +} + /// A painted effect on a group may link its pieces to the document's swatches. #[test] fn a_painted_group_effect_links_to_the_document_s_swatches() { diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index 0eded4070..fee13330e 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -174,7 +174,23 @@ fn weight(key: &Key, out: Option<&Out>) -> usize { }; anchors(&key.path) + out + (key.params.len() + key.paints.len()) / 50 + 1 } -static ERRORS: Mutex>> = Mutex::new(None); +static ERRORS: Mutex>> = Mutex::new(None); + +/// The errors recorded for a live-effect plug-in, one for each kind of outcome that can clear it. +#[derive(Default)] +struct Failures { + /// From a run of the plug-in. + run: Option, + /// From checking the swatches and patterns a painted result names against the document. + references: Option, +} + +/// Which of a plug-in's [`Failures`] an outcome replaces. +#[derive(Clone, Copy)] +enum Origin { + Run, + References, +} /// Where an installation ([`crate::Plugin::serial`]) stands as a live effect in this process. enum Runs { @@ -227,21 +243,30 @@ fn end_run(serial: u64, cancelled: bool) { } } -/// The last error of live-effect plug-in `id` (cleared when it next succeeds). +/// The last error of live-effect plug-in `id`: a refused reference if there is one, else a failed +/// run's. A run that succeeds clears a failed run's error, and a reference check that passes clears +/// a refused reference's. pub fn last_error(id: &str) -> Option { - ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned() + let errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); + let failures = errors.as_ref()?.get(id)?; + failures.references.clone().or_else(|| failures.run.clone()) } -/// Records how a run of plug-in `id` went: a failure as its last error (a painted effect's named -/// after the plug-in, [`painted::named`]), a success clearing it. -fn note(id: &str, plugin: &crate::Plugin, r: std::result::Result<(), &Error>) { +/// Records how a run of plug-in `id`, or a check of what its result names, went: a failure as that +/// origin's error (a painted effect's named after the plug-in, [`painted::named`]), a success +/// clearing that origin's error only. +fn note(id: &str, plugin: &crate::Plugin, origin: Origin, r: std::result::Result<(), &Error>) { + let message = r.err().map(|e| if plugin.manifest().paint_output { painted::named(&plugin.manifest().name, e) } else { e.to_string() }); let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); let errors = errors.get_or_insert_with(HashMap::new); - match r { - Ok(()) => errors.remove(id), - Err(e) if plugin.manifest().paint_output => errors.insert(id.to_string(), painted::named(&plugin.manifest().name, e)), - Err(e) => errors.insert(id.to_string(), e.to_string()), - }; + let failures = errors.entry(id.to_string()).or_default(); + match origin { + Origin::Run => failures.run = message, + Origin::References => failures.references = message, + } + if failures.run.is_none() && failures.references.is_none() { + errors.remove(id); + } } /// Does live-effect plug-in `id` return painted pieces (its manifest declares `paintOutput`)? @@ -294,7 +319,7 @@ pub fn apply_painted( fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> { let checked = painted::check_references(pieces, doc); if doc.is_some() { - note(id, plugin, checked.as_ref().map(|_| ())); + note(id, plugin, Origin::References, checked.as_ref().map(|_| ())); } checked.ok() } @@ -334,7 +359,7 @@ fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillR if cancelled { return None; } - note(id, &plugin, r.as_ref().map(|_| ())); + note(id, &plugin, Origin::Run, r.as_ref().map(|_| ())); let out = r.ok(); let size = weight(&key, out.as_ref()); if size > CACHE_ANCHORS / 4 { @@ -377,6 +402,7 @@ fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, mod tests { use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{FillLayer, StrokeLayer}; + use vectorcraft_geom::{Point, SubPath}; use super::*; @@ -400,6 +426,34 @@ mod tests { assert_eq!((none.fills, none.strokes, none.item), (vec![], vec![], json!("object"))); } + /// A result taken from the cache passes its reference check without clearing the error of a + /// failed run, and the next run that succeeds clears that error. + #[test] + fn a_passed_reference_check_keeps_a_failed_run_s_error() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.lasterror"; + let manifest = format!(r#"{{"id":"{ID}","name":"Test","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#); + // Traps on a long input. + let first = "(if (i32.gt_u (local.get $il) (i32.const 4096)) (then unreachable))"; + let src = crate::wat::with_imports(&manifest, "", first, r#"{"objects":[]}"#); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + let doc = Document::new(100.0, 100.0); + let line = |n: usize| PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::>(), false)); + let draw = + |path: &PathData| apply_painted(ID, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints::default(), Some(&doc)); + let short = line(2); + assert!(draw(&short).is_some()); + assert_eq!(last_error(ID), None); + assert!(draw(&line(1000)).is_none()); + let failed = last_error(ID).unwrap(); + assert!(draw(&short).is_some(), "from the cache"); + assert_eq!(last_error(ID), Some(failed)); + assert!(draw(&line(3)).is_some(), "a new run"); + assert_eq!(last_error(ID), None); + registry::remove(ID); + } + /// A first run that is cancelled leaves the next run to be the first: the launch marker names /// the plug-in during that one. #[test] diff --git a/docs/plugins.md b/docs/plugins.md index 351c139cb..d2473ba33 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -233,7 +233,9 @@ Every run gets a **fresh instance**, so no state survives between calls except w [settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that -fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). +fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). A run that succeeds clears the +error of a failed run. A check that finds every swatch and pattern a painted result names clears only the error of a +refused swatch or pattern. wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts From f29c75a2310029579f5e410ce84b7f2e7fb22b61 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 19:12:40 -0800 Subject: [PATCH 15/18] Plug-ins: hit tests re-evaluate live art when a swatch or color group changes A painted live effect's pieces name swatches, so a kept evaluation could outlive a swatch that was renamed or removed and answer a click with art the document no longer draws. The kept evaluation now holds the document's swatches and color groups and is stale when they change. --- crates/doc/src/hit.rs | 22 ++++++++++++++++++---- crates/doc/src/hit/tests.rs | 32 +++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index f099b5f95..14bda9a00 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -128,11 +128,14 @@ fn live_inputs() -> u64 { } /// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by -/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`]. +/// name and art), patterns, swatches and color groups and the [`LiveInputs`] as they were, the art +/// and its [`art_size`]. struct EvaluatedLive { node: Node, symbols: Vec<(String, Arc)>, patterns: Vec, + swatches: crate::Shared>, + swatch_groups: crate::Shared>, inputs: u64, art: Rc>, size: usize, @@ -146,6 +149,8 @@ impl EvaluatedLive { && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art)) && self.node == *n && self.patterns == doc.patterns + && self.swatches == doc.swatches + && self.swatch_groups == doc.swatch_groups } } @@ -184,8 +189,8 @@ thread_local! { /// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while /// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of -/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's -/// symbols or patterns hold a placed document ([`holds_placed`]). +/// `doc`, its swatches or color groups, or the [`LiveInputs`] have changed since, and on every +/// click while it or the document's symbols or patterns hold a placed document ([`holds_placed`]). fn live_art(doc: &Document, n: &Node) -> Rc> { let inputs = live_inputs(); let kept = EVALUATED.with_borrow_mut(|kept| { @@ -206,7 +211,16 @@ fn live_art(doc: &Document, n: &Node) -> Rc> { return art; } let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect(); - let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size }; + let e = EvaluatedLive { + node: n.clone(), + symbols, + patterns: doc.patterns.clone(), + swatches: doc.swatches.clone(), + swatch_groups: doc.swatch_groups.clone(), + inputs, + art: art.clone(), + size, + }; EVALUATED.with_borrow_mut(|kept| { kept.insert(0, e); kept.truncate(LIVE_KEPT); diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index d6f4cafe7..cb5b19d1a 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -605,11 +605,41 @@ fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() { fn a_kept_evaluation_is_stale_after_the_live_inputs_change() { let d = Document::new(100.0, 100.0); let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 }; + let e = kept_evaluation(&d, &n); assert!(e.is_of(&d, &n, 7)); assert!(!e.is_of(&d, &n, 8)); } +/// An evaluation of `n` in `d`, with inputs 7, holding no symbols, patterns or art. +fn kept_evaluation(d: &Document, n: &Node) -> EvaluatedLive { + EvaluatedLive { + node: n.clone(), + symbols: vec![], + patterns: vec![], + swatches: d.swatches.clone(), + swatch_groups: d.swatch_groups.clone(), + inputs: 7, + art: std::rc::Rc::new(vec![]), + size: 0, + } +} + +/// A kept evaluation is stale once a swatch changes, in a color group too: a painted live effect's +/// pieces name swatches. +#[test] +fn a_kept_evaluation_is_stale_after_a_swatch_changes() { + let mut d = Document::new(100.0, 100.0); + let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let e = kept_evaluation(&d, &n); + assert!(e.is_of(&d, &n, 7)); + d.swatches.first_mut().unwrap().name = "Renamed".into(); + assert!(!e.is_of(&d, &n, 7)); + let mut d = Document::new(100.0, 100.0); + let e = kept_evaluation(&d, &n); + d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Added".into(), swatches: vec![] }); + assert!(!e.is_of(&d, &n, 7)); +} + /// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a /// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the /// clipping path and the art. From 5d715a50511aa55e410f56379ab48a97026dad3c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 19:35:50 -0800 Subject: [PATCH 16/18] Plug-ins: an effect that paints nothing clears a refused reference's error Only a painted effect's references are checked, so a refusal recorded for a painted effect stayed in plugin.info after an effect that paints nothing replaced it under the same id. --- crates/engine/src/tests/plugins.rs | 18 ++++++++++++++++++ crates/plugins/src/effect.rs | 12 ++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 682895abc..b52b18d00 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -457,6 +457,24 @@ fn a_refused_swatch_s_error_clears_once_the_document_has_the_swatch() { assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.laterswatch"}))["lastError"], Value::Null); } +/// An effect that paints nothing has no references to check, so replacing a refused painted effect +/// with one clears the refusal's error once it runs. +#[test] +fn a_replacement_that_paints_nothing_clears_a_refused_swatch_s_error() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let mut gone = solid("#00ff00"); + gone["swatch"] = json!("Absent"); + let output = json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.replaced", true), &output)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.replaced"})); + drawn(&s); + assert!(run(&mut s, "plugin.info", json!({"id": "test.engine.replaced"}))["lastError"].is_string()); + install_wat(&mut s, &tpl::returning(&painting("test.engine.replaced", false), &output)); + drawn(&s); + assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.replaced"}))["lastError"], Value::Null); +} + /// A painted effect on a group may link its pieces to the document's swatches. #[test] fn a_painted_group_effect_links_to_the_document_s_swatches() { diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index fee13330e..6cbc8d3dd 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -254,14 +254,22 @@ pub fn last_error(id: &str) -> Option { /// Records how a run of plug-in `id`, or a check of what its result names, went: a failure as that /// origin's error (a painted effect's named after the plug-in, [`painted::named`]), a success -/// clearing that origin's error only. +/// clearing that origin's error. A run of an effect that paints nothing clears a refused +/// reference's error too. fn note(id: &str, plugin: &crate::Plugin, origin: Origin, r: std::result::Result<(), &Error>) { let message = r.err().map(|e| if plugin.manifest().paint_output { painted::named(&plugin.manifest().name, e) } else { e.to_string() }); let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); let errors = errors.get_or_insert_with(HashMap::new); let failures = errors.entry(id.to_string()).or_default(); match origin { - Origin::Run => failures.run = message, + Origin::Run => { + failures.run = message; + // Nothing checks the references of an effect that paints nothing, such as one that + // replaced a painted effect under this id. + if !plugin.manifest().paint_output { + failures.references = None; + } + } Origin::References => failures.references = message, } if failures.run.is_none() && failures.references.is_none() { From 08f38a506831bf6c0d7e23e3758e15a4f842d82a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 19:45:26 -0800 Subject: [PATCH 17/18] Updates: take craft-update from craft-libs main at 1b34eba41 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 591389407..3444b4223 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index fd8527fd9..7d1944df1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63 # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } From e2fea88c77cefcd0a5ca8d7cdabf1aaf84afde68 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 19:50:57 -0800 Subject: [PATCH 18/18] Plug-ins: lastError reports the latest of a refused reference and a failed run --- crates/plugins/src/effect.rs | 43 ++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index 6cbc8d3dd..01cedbcc0 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -183,6 +183,8 @@ struct Failures { run: Option, /// From checking the swatches and patterns a painted result names against the document. references: Option, + /// Was the latest failure a refused reference? + references_latest: bool, } /// Which of a plug-in's [`Failures`] an outcome replaces. @@ -243,13 +245,14 @@ fn end_run(serial: u64, cancelled: bool) { } } -/// The last error of live-effect plug-in `id`: a refused reference if there is one, else a failed -/// run's. A run that succeeds clears a failed run's error, and a reference check that passes clears -/// a refused reference's. +/// The last error of live-effect plug-in `id`: the latest failure that still stands. A run that +/// succeeds clears a failed run's error, and a reference check that passes clears a refused +/// reference's. pub fn last_error(id: &str) -> Option { let errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); let failures = errors.as_ref()?.get(id)?; - failures.references.clone().or_else(|| failures.run.clone()) + let (latest, other) = if failures.references_latest { (&failures.references, &failures.run) } else { (&failures.run, &failures.references) }; + latest.clone().or_else(|| other.clone()) } /// Records how a run of plug-in `id`, or a check of what its result names, went: a failure as that @@ -261,6 +264,9 @@ fn note(id: &str, plugin: &crate::Plugin, origin: Origin, r: std::result::Result let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); let errors = errors.get_or_insert_with(HashMap::new); let failures = errors.entry(id.to_string()).or_default(); + if message.is_some() { + failures.references_latest = matches!(origin, Origin::References); + } match origin { Origin::Run => { failures.run = message; @@ -462,6 +468,35 @@ mod tests { registry::remove(ID); } + /// With a refused reference and a failed run both standing, the latest of the two is reported. + #[test] + fn the_latest_of_a_refused_reference_and_a_failed_run_is_reported() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.latesterror"; + let manifest = format!(r#"{{"id":"{ID}","name":"Test","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#); + let mut fill = serde_json::to_value(Paint::solid(Color::WHITE)).unwrap(); + fill["swatch"] = json!("Missing"); + let square = PathData::single(SubPath::polyline(&[(0.0, 0.0), (9.0, 0.0), (9.0, 9.0)].map(|(x, y)| Point::new(x, y)), true)); + let output = json!({"objects": [{"path": square, "fills": [fill]}]}).to_string(); + // Traps on a long input. + let first = "(if (i32.gt_u (local.get $il) (i32.const 4096)) (then unreachable))"; + let src = crate::wat::with_imports(&manifest, "", first, &output); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + let doc = Document::new(100.0, 100.0); + let line = |n: usize| PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::>(), false)); + let draw = + |path: &PathData| apply_painted(ID, &json!({}), path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints::default(), Some(&doc)); + let short = line(2); + assert!(draw(&short).is_none()); + assert!(last_error(ID).unwrap().contains("swatch")); + assert!(draw(&line(1000)).is_none()); + assert!(last_error(ID).unwrap().contains("trapped")); + assert!(draw(&short).is_none(), "from the cache, refused again"); + assert!(last_error(ID).unwrap().contains("swatch")); + registry::remove(ID); + } + /// A first run that is cancelled leaves the next run to be the first: the launch marker names /// the plug-in during that one. #[test]