Plug-ins: a filter's read-only objects stay in its input and failed decodes count

A filter run kept a second copy of every read-only object as JSON values.
The run now keeps where each object is in the input, and checks an output
entry against it by parsing that one object, unless the entry is `{"id": …}`
alone.

An image that reaches the decoder now counts toward the 16 MiB pixel total
even when decoding fails, so broken images use up the same total as images
that decode.

The plugin.run text, docs/mcp.md and the progress line state the paint
order and that rule, and docs/plugins.md states that a CMYK image's pixels
are sRGB. New tests cover the per-image 1 MiB, the 16 MiB pixel total and
the 16 MiB text total at and one past their values, memory-budget failures
before the plug-in starts, images that are not decoded, CMYK images
included, and leading per run.
This commit is contained in:
dotCooCoo
2026-11-08 20:54:02 -08:00
parent 6b978fea4f
commit 0e1aa5cc2e
7 changed files with 365 additions and 74 deletions
+5 -6
View File
@@ -41,7 +41,6 @@
//! images or symbol instances of those kinds, to read only: its output must return each of them
//! unchanged ([`objects::decode_output_with`]).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use serde_json::{Map, Value, json};
@@ -134,9 +133,9 @@ pub struct FilterRun {
params: Vec<u8>,
/// The objects the plug-in is given, and the container new objects go in.
inputs: Vec<NodeId>,
/// The type, images and symbol instances among them (a filter that asks for them), as given:
/// its output must return each one unchanged.
read_only: HashMap<NodeId, Value>,
/// The type, images and symbol instances among them (a filter that asks for them): its output
/// must return each one as `input` gives it. These are their places in `input`.
read_only: objects::ReadOnlySpans,
parent: Option<NodeId>,
/// The document it was made from, and that document's revision then.
document: (u64, u64),
@@ -270,7 +269,7 @@ impl FilterRun {
fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> {
let m = self.plugin.manifest();
let may = m.permissions();
let out = objects::decode_output_with(output, &self.read_only).map_err(err)?;
let out = objects::decode_output_with(output, &self.input, &self.read_only).map_err(err)?;
if out.document_data.is_some() && !may.contains(&Permission::State) {
return Err(not_granted(&m.name, Permission::State));
}
@@ -908,7 +907,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Plug-in",
[],
None,
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, 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}",
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, in paint order from the bottom, with at most 16 MiB of pixels and 16 MiB of type runs per run (an object that doesn't fit what the objects before it left comes with pixelsOmitted or truncated; an image that reaches the decoder counts toward the pixels even when decoding fails); 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
),
+63
View File
@@ -1603,6 +1603,69 @@ fn read_only_kind_field_and_omission_refuse_without_undo() {
assert!(e.contains("`inputs` needs `api` 2"), "{e}");
}
/// A run past its memory budget fails before the plug-in starts, with the document and undo
/// history as they were: a read-only object whose other fields don't fit, a path and a compound
/// path that don't fit, and parameters past the input's half of the cap, with objects selected or
/// with none. The module has one page of memory, the most it may have, so a run that started would
/// report that the plug-in ran out of memory instead.
#[test]
fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, Node, NodeKind};
use vectorcraft_geom::FillRule;
use vectorcraft_plugins::{Limits, Plugin, registry};
let id = "test.engine.inputs.budget";
let params: Vec<String> = (0..9).map(|i| format!(r#""p{i}":{{"type":"string","maxLength":4096}}"#)).collect();
let manifest = format!(
r#"{{"id":"{id}","name":"Budget","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"],"params":{{{}}}}}"#,
params.join(",")
);
let module = wat::parse_str(tpl::module(&manifest, "unreachable", "", 1)).unwrap();
// One page of memory: the input and the parameters share 32 KiB.
registry::install(Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap());
let mut s = session();
let small = rect(&mut s, 10.0, "#ff0000");
let long = rect(&mut s, 30.0, "#00ff00");
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap());
let mut compound = NodeId(0);
s.edit("Set up", |d, _| {
d.node_mut(t).unwrap().name = Some("n".repeat(40_000));
let points: Vec<Point> = (0..4_000).map(|i| Point::new(f64::from(i), 0.0)).collect();
let outline = PathData::single(SubPath::polyline(&points, false));
if let NodeKind::Path { path, .. } = &mut d.node_mut(long).unwrap().kind {
*path = outline.clone();
}
let member = Node::path(d.alloc_id(), outline, Appearance::default());
let n = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![Arc::new(member)], rule: FillRule::NonZero });
let layer = d.layers[0].id;
compound = d.insert(Some(layer), usize::MAX, n).unwrap();
Ok(())
})
.unwrap();
let padding: Value = (0..9).map(|i| (format!("p{i}"), json!("x".repeat(4096)))).collect::<serde_json::Map<_, _>>().into();
let too_large = "memory budget (the selection is too large to hand over)";
let named = format!("memory budget (object {} needs", t.0);
let with_padding = |ids: Vec<u64>| {
let mut p = padding.clone();
p["id"] = json!(id);
p["ids"] = json!(ids);
p
};
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
for (p, says) in [
(json!({"id": id, "ids": [t.0]}), named.as_str()),
(json!({"id": id, "ids": [long.0]}), too_large),
(json!({"id": id, "ids": [compound.0]}), too_large),
(with_padding(vec![small.0]), too_large),
(with_padding(vec![]), too_large),
] {
let e = s.execute("plugin.run", &p).unwrap_err().to_string();
assert!(e.contains(says), "{says}: {e}");
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before, "{says}");
}
assert!(registry::remove(id));
}
/// 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
+69 -49
View File
@@ -6,6 +6,7 @@
//! in-range coordinates, capped counts, clamped colours) before it reaches the document.
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
@@ -46,7 +47,8 @@ pub const MAX_INPUT_RUNS: usize = 4_096;
/// 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`.
/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`, and an
/// image the host starts to decode takes its size from the total even when decoding fails.
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`.
@@ -191,12 +193,15 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec<u8> {
/// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`]
/// naming it and its size, a path or compound path that doesn't fit is the limit the runtime gives
/// an input past its share, and more than [`MAX_OBJECTS`] read-only objects, more than an output
/// may list, is an [`Error::Limit`] too. Also returns those objects by id, which the filter
/// must return as they were ([`decode_output_with`]).
pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
/// may list, is an [`Error::Limit`] too. Also returns where those objects are in the input, which
/// the filter must return as they were ([`decode_output_with`]).
pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec<u8>, ReadOnlySpans)> {
encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL })
}
/// Where each read-only object of an input is in the input's bytes.
pub type ReadOnlySpans = HashMap<NodeId, Range<usize>>;
/// The input with no objects; each object adds its JSON and a comma.
const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}";
@@ -214,27 +219,23 @@ impl Allowance {
const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX };
}
/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map
/// it returns, and written into the input from there.
fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
/// [`encode_input_with`] within `left` of the totals. Each object is held only while it is written
/// into the input.
fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, ReadOnlySpans)> {
let mut read_only = HashMap::new();
let symbols = doc.symbol_boxes();
let mut out = b"{\"objects\":[".to_vec();
let mut first = true;
for n in ids.iter().filter_map(|id| doc.node(*id)) {
let path;
let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) {
let (o, checked) = 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),
Some(o) => (o, true),
None => continue,
},
None => match encode(n) {
Some(o) => {
path = o;
(&path, false)
}
Some(o) => (o, false),
None => continue,
},
};
@@ -242,9 +243,13 @@ fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut
if !std::mem::take(&mut first) {
out.push(b',');
}
if serde_json::to_writer(&mut out, o).is_err() {
let object = out.len();
if serde_json::to_writer(&mut out, &o).is_err() {
return Ok((Vec::new(), HashMap::new()));
}
if checked {
read_only.insert(n.id, object..out.len());
}
let size = out.len() - start;
// A path or compound path past what is left fails the run as the runtime would fail it,
// before the rest of the selection is written.
@@ -291,7 +296,8 @@ fn affine_json(xf: Affine) -> Value {
/// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`,
/// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`]
/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top (a CMYK image's inks
/// through the working CMYK profile). A linked image's file and path are never read or given;
/// through the working CMYK profile, as sRGB). A linked image's file and path are never read or
/// given;
/// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when
/// the document has no such symbol, and `overrides: {}`.
pub fn encode_read_only(doc: &Document, n: &Node) -> Option<Value> {
@@ -354,13 +360,13 @@ fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left
NodeKind::Image(im) => {
let blob = doc.image_blob(im);
let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY));
let mut charged = 0;
// 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));
let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max, &mut charged));
left.pixels = left.pixels.saturating_sub(charged);
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["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p));
}
}
_ => {}
@@ -458,20 +464,34 @@ fn color_mode(b: &ImageBlob) -> &'static str {
if readable.is_some() { "rgb" } else { "unknown" }
}
#[cfg(test)]
thread_local! {
/// How many images [`rgba_pixels`] has started to decode on this thread.
static DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max`
/// characters of base64; the size is checked, and read from the header, before anything is decoded.
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> {
/// `charged` gets the characters of base64 an image that reaches the decoder takes from the total,
/// which it takes even when decoding fails.
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize, charged: &mut usize) -> Option<Vec<u8>> {
let bytes = u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)?;
if bytes == 0 || bytes.div_ceil(3).saturating_mul(4) > max as u64 || oversized_tiff(&b.bytes).is_some() {
let cost = bytes.div_ceil(3).saturating_mul(4);
if bytes == 0 || cost > max as u64 || oversized_tiff(&b.bytes).is_some() {
return None;
}
if b.is_cmyk() {
return cmyk_rgba(b, w, h);
}
let cmyk = b.is_cmyk();
let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok();
if reader()?.into_dimensions().ok()? != (w, h) {
let size = if cmyk { b.header_size()? } else { reader()?.into_dimensions().ok()? };
if size != (w, h) {
return None;
}
*charged = cost as usize;
#[cfg(test)]
DECODES.with(|n| n.set(n.get() + 1));
if cmyk {
return cmyk_rgba(b, w, h);
}
let mut r = reader()?;
let mut limits = image::Limits::default();
// The decoder's own buffers besides the RGBA it gives.
@@ -484,13 +504,10 @@ fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> {
/// The inks → XYZ table of [`cmyk_rgba`] and the color settings it was built with.
static CMYK_DISPLAY: Mutex<Option<(Arc<Cms>, Arc<CmykLut>)>> = Mutex::new(None);
/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels (checked from its
/// header first): its inks shown through the working CMYK profile, by a table built as
/// `vectorcraft-render`'s `InkLut` builds its own and kept for the active color settings.
/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels: its inks shown
/// through the working CMYK profile, by a table built as `vectorcraft-render`'s `InkLut` builds its
/// own and kept for the active color settings.
fn cmyk_rgba(b: &ImageBlob, w: u32, h: u32) -> Option<Vec<u8>> {
if b.header_size()? != (w, h) {
return None;
}
let inks = b.cmyk().filter(|i| (i.width, i.height) == (w, h))?;
let lut = {
let cms = cms::active();
@@ -761,16 +778,18 @@ pub struct Output {
/// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}`
/// when the plug-in declined (the message reaches the user).
pub fn decode_output(bytes: &[u8]) -> Result<Output> {
decode_output_with(bytes, &HashMap::new())
decode_output_with(bytes, &[], &HashMap::new())
}
/// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list
/// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by
/// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left
/// out, is refused naming the object and the first field that differs, before anything changes.
pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> Result<Output> {
/// [`decode_output`] for a filter given `read_only` objects, at those places in `input`
/// ([`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. Each object is parsed from `input` only to compare it, one at
/// a time, and an object that can't be read from there is an error.
pub fn decode_output_with(bytes: &[u8], input: &[u8], read_only: &ReadOnlySpans) -> Result<Output> {
let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
let objects = decode_objects(&v, read_only)?;
let objects = decode_objects(&v, input, read_only)?;
let document_data = match v.get_mut("documentData").map(Value::take) {
None => None,
Some(Value::Null) => Some(None),
@@ -789,12 +808,12 @@ pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> R
/// declined (the message reaches the user). A live effect's other keys don't matter.
pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
decode_objects(&v, &HashMap::new())
decode_objects(&v, &[], &HashMap::new())
}
/// The objects of output `v`, or its decline; entries for `read_only` objects as
/// [`decode_output_with`] reads them.
fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<OutObject>> {
fn decode_objects(v: &Value, input: &[u8], read_only: &ReadOnlySpans) -> Result<Vec<OutObject>> {
if let Some(e) = declined(v) {
return Err(e);
}
@@ -808,12 +827,16 @@ fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<O
.iter()
.enumerate()
.map(|(i, o)| match output_id(o).and_then(|id| Some((id, read_only.get(&id)?))) {
Some((id, given)) => {
Some((id, span)) => {
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)?;
// `{"id": …}` alone keeps the object, so there is nothing to compare it with.
if o.as_object().is_none_or(|m| m.len() != 1) {
let given = serde_json::from_slice(input.get(span.clone()).unwrap_or_default()).map_err(|e| Error::Io(e.to_string()))?;
check_unchanged(o, &given, &at)?;
}
Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() })
}
None => decode_object(o, &format!("objects[{i}]"), &mut anchors),
@@ -836,15 +859,12 @@ fn output_id(o: &Value) -> Option<NodeId> {
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
/// Checks entry `o` of a filter's output for a read-only object it was `given`: `given` itself,
/// numbers compared by value. Otherwise the error names the first field that differs
/// (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as
/// [`crate::permissions::display_text`] shows a manifest string, since the entry's keys are the
/// plug-in's.
fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> {
if o.as_object().is_some_and(|m| m.len() == 1) {
return Ok(());
}
let changed = match (o, given) {
(Value::Object(x), Value::Object(y)) => {
let mut keys: Vec<&String> = x.keys().collect();
+215 -8
View File
@@ -481,6 +481,212 @@ fn cmyk_image_pixels_go_through_the_working_cmyk_profile() {
assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile");
}
/// The image object for `key` in `d`'s layer, `size` pixels.
fn placed(d: &mut Document, layer: NodeId, key: &str, size: (u32, u32)) -> NodeId {
let n = image_node(d, key, size, None);
d.insert(Some(layer), usize::MAX, n).unwrap()
}
/// The largest pixels one image may have fill exactly [`MAX_INPUT_PIXELS`] characters of base64
/// (512 × 384); one pixel more doesn't fit.
#[test]
fn an_image_at_the_limit_is_given_and_one_pixel_more_is_omitted() {
let (mut d, _, layer, _) = mixed();
for ((w, h), omitted) in [((512, 384), false), ((1, 196_609), true)] {
d.images.insert("limit".into(), ImageBlob::png(png(w, h)));
let id = placed(&mut d, layer, "limit", (w, h));
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
assert_eq!(o["pixelsOmitted"], json!(omitted), "{w} × {h}");
assert_eq!(o.get("pixelsBase64").and_then(Value::as_str).map(str::len), (!omitted).then_some(MAX_INPUT_PIXELS), "{w} × {h}");
}
}
/// Sixteen images at the per-image limit fill [`MAX_INPUT_PIXELS_TOTAL`] exactly and are all given;
/// the next one in paint order, even a small one, comes with `pixelsOmitted`.
#[test]
fn images_fill_the_pixel_total_and_the_next_is_omitted() {
let (mut d, _, layer, [_, embedded, ..]) = mixed();
d.images.insert("limit".into(), ImageBlob::png(png(512, 384)));
let mut ids: Vec<NodeId> = (0..16).map(|_| placed(&mut d, layer, "limit", (512, 384))).collect();
ids.push(embedded);
let (bytes, _) = encode_input_with(&d, &ids, &[InputKind::Image], usize::MAX).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let objects = v["objects"].as_array().unwrap();
assert_eq!(objects.len(), 17);
let given: usize = objects.iter().filter_map(|o| o.get("pixelsBase64")?.as_str()).map(str::len).sum();
assert_eq!(given, MAX_INPUT_PIXELS_TOTAL);
let omitted: Vec<bool> = objects.iter().map(|o| o["pixelsOmitted"] == json!(true)).collect();
assert_eq!(omitted, [vec![false; 16], vec![true]].concat());
}
/// Type whose runs and paragraphs together fill [`MAX_INPUT_TEXT_TOTAL`] exactly is all given, and
/// type after it in paint order comes with `truncated`. With one byte more, the last type that
/// doesn't fit comes with `truncated` and what it leaves still goes to the type after it.
#[test]
fn type_fills_the_text_total_and_what_follows_is_truncated() {
use vectorcraft_doc::{CharStyle, TextObject};
let (mut d, _, layer, _) = mixed();
let place = |d: &mut Document, len: usize| {
let t = TextObject::point(Point::new(0.0, 0.0), &"x".repeat(len), CharStyle::default());
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
d.insert(Some(layer), usize::MAX, n).unwrap()
};
let one = place(&mut d, 1);
let o = encode_read_only(&d, d.node(one).unwrap()).unwrap();
// Everything but the text itself, with the commas between runs and paragraphs and without
// their brackets.
let overhead: usize = ["runs", "paragraphs"].iter().map(|k| o["text"][k].to_string().len() - 2).sum::<usize>() - 1;
let full = overhead + MAX_INPUT_TEXT;
let count = MAX_INPUT_TEXT_TOTAL / full;
let rest = MAX_INPUT_TEXT_TOTAL - count * full - overhead;
assert!((1..=MAX_INPUT_TEXT).contains(&rest));
for (filler, truncated) in [(rest, [false, true]), (rest + 1, [true, false])] {
let mut ids: Vec<NodeId> = (0..count).map(|_| place(&mut d, MAX_INPUT_TEXT)).collect();
ids.push(place(&mut d, filler));
ids.push(one);
let (bytes, _) = encode_input_with(&d, &ids, &[InputKind::Type], usize::MAX).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let objects = v["objects"].as_array().unwrap();
assert_eq!(objects.len(), count + 2);
assert!(objects[..count].iter().all(|o| o["truncated"] == json!(false)), "filler {filler}");
assert_eq!([&objects[count]["truncated"], &objects[count + 1]["truncated"]], truncated.map(|t| json!(t)).each_ref(), "filler {filler}");
}
}
/// A CMYK TIFF `w` × `h` pixels of no ink.
fn cmyk_blob(w: u32, h: u32) -> ImageBlob {
let inks = vectorcraft_doc::cmyk::Inks::new(w, h, vec![0; (w * h * 4) as usize]).unwrap();
ImageBlob::cmyk_tiff(&inks).unwrap()
}
/// `blob`, a little-endian TIFF, with its strip offset past the end of the file: its header still
/// reads and its pixels don't.
fn without_strips(blob: &ImageBlob) -> ImageBlob {
let mut bytes = blob.bytes.to_vec();
assert_eq!(&bytes[..2], b"II");
let word = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) as usize;
let ifd = word(&bytes, 4);
let count = usize::from(u16::from_le_bytes([bytes[ifd], bytes[ifd + 1]]));
let entry = (0..count).map(|i| ifd + 2 + i * 12).find(|&e| bytes[e..e + 2] == 273u16.to_le_bytes()).unwrap();
let past = (bytes.len() as u32 + 4096).to_le_bytes();
bytes[entry + 8..entry + 12].copy_from_slice(&past);
ImageBlob::png(bytes)
}
/// An image that can't be given isn't decoded: past the per-image limit, past what is left of the
/// total, once the total is used up, when its header disagrees with its object, and when it is
/// linked, a CMYK image as much as any other. One that fits is decoded once.
#[test]
fn an_image_that_cannot_be_given_is_not_decoded() {
let (mut d, _, layer, [_, embedded, linked, ..]) = mixed();
d.images.insert("big".into(), ImageBlob::png(png(1, 196_609)));
d.images.insert("k".into(), cmyk_blob(2, 1));
let big = placed(&mut d, layer, "big", (1, 196_609));
let wrong = placed(&mut d, layer, "e", (3, 1));
let cmyk = placed(&mut d, layer, "k", (2, 1));
let cmyk_wrong = placed(&mut d, layer, "k", (3, 1));
let within = |pixels: usize| Allowance { pixels, ..Allowance::FULL };
for (id, left, omitted, decodes, what) in [
(big, Allowance::FULL, true, 0, "past the per-image limit"),
(embedded, within(11), true, 0, "past what is left of the total"),
(embedded, within(0), true, 0, "the total used up"),
(wrong, Allowance::FULL, true, 0, "another size than its object"),
(linked, Allowance::FULL, true, 0, "linked"),
(embedded, within(12), false, 1, "fits"),
(cmyk, within(11), true, 0, "CMYK past what is left of the total"),
(cmyk_wrong, Allowance::FULL, true, 0, "CMYK of another size than its object"),
(cmyk, within(12), false, 1, "CMYK that fits"),
] {
DECODES.with(|n| n.set(0));
let (bytes, _) = encode_input_within(&d, &[id], &[InputKind::Image], left).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["objects"][0]["pixelsOmitted"], json!(omitted), "{what}");
assert_eq!(DECODES.with(|n| n.get()), decodes, "{what}");
}
}
/// An image that reaches the decoder and fails takes its size from the total, a broken CMYK image
/// as much as a broken PNG: the next image doesn't fit what the broken one left.
#[test]
fn an_image_that_fails_to_decode_takes_from_the_total() {
let (mut d, _, layer, [_, embedded, ..]) = mixed();
let mut png_data = png(2, 1);
let idat = png_data.windows(4).position(|w| w == b"IDAT").unwrap();
png_data[idat + 6] ^= 0xFF;
for (what, blob, expect_cmyk) in [("PNG", ImageBlob::png(png_data), false), ("CMYK", without_strips(&cmyk_blob(2, 1)), true)] {
assert_eq!(blob.is_cmyk(), expect_cmyk, "{what}");
d.images.insert("broken".into(), blob);
let bad = placed(&mut d, layer, "broken", (2, 1));
let omitted = |pixels: usize| {
DECODES.with(|n| n.set(0));
let left = Allowance { pixels, ..Allowance::FULL };
let (bytes, _) = encode_input_within(&d, &[bad, embedded], &[InputKind::Image], left).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
(v["objects"][0]["pixelsOmitted"].clone(), v["objects"][1]["pixelsOmitted"].clone(), DECODES.with(|n| n.get()))
};
assert_eq!(omitted(12), (json!(true), json!(true), 1), "{what}: one decode fits 12 characters");
assert_eq!(omitted(24), (json!(true), json!(false), 2), "{what}: the next image fits what is left");
}
}
/// Runs in one paragraph keep their own leading: each run comes with its character attributes, and
/// the paragraph is one paragraph style.
#[test]
fn runs_in_one_paragraph_keep_their_own_leading() {
use vectorcraft_doc::{CharStyle, TextObject, TextRun};
let (mut d, _, layer, _) = mixed();
let mut t = TextObject::point(Point::new(0.0, 0.0), "", CharStyle::default());
let leading = CharStyle { leading: Some(14.0), ..CharStyle::default() };
t.runs = vec![TextRun::new("a".to_string(), leading), TextRun::new("b".to_string(), CharStyle::default())];
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
assert_eq!(o["text"]["runs"][0]["style"]["leading"], json!(14.0));
assert_eq!((&o["text"]["runs"][1]["text"], &o["text"]["runs"][1]["style"]["leading"]), (&json!("b"), &Value::Null));
assert_eq!(o["text"]["paragraphs"].as_array().unwrap().len(), 1);
}
/// Each read-only object's place in the input holds that object, pixels included, and the input's
/// paths have no place. An output that echoes an object is compared with what that place holds; an
/// input that doesn't hold it refuses the output, as the host's own error rather than the
/// plug-in's.
#[test]
fn read_only_objects_are_read_from_their_place_in_the_input_and_fail_closed() {
let (d, ids, _, [text, embedded, ..]) = mixed();
let order = [ids[0], text, embedded];
let (bytes, read_only) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(read_only.len(), 2);
let at = |id: NodeId| serde_json::from_slice::<Value>(&bytes[read_only[&id].clone()]).unwrap();
assert_eq!(at(text), v["objects"][1]);
assert_eq!(at(embedded), v["objects"][2]);
assert!(at(embedded)["pixelsBase64"].is_string());
let echo = json!({"objects": [v["objects"][1], v["objects"][2]]}).to_string();
assert!(decode_output_with(echo.as_bytes(), &bytes, &read_only).is_ok());
let garbage = vec![b'x'; bytes.len()];
for input in [garbage.as_slice(), b"not json", br#"{"objects":[]}"#, b"{}", b""] {
let e = decode_output_with(echo.as_bytes(), input, &read_only).unwrap_err().to_string();
assert!(!e.starts_with("plug-in"), "{e}");
}
// `{"id": …}` alone needs nothing from the input.
let kept = json!({"objects": [{"id": text.0}, {"id": embedded.0}]}).to_string();
assert!(decode_output_with(kept.as_bytes(), b"", &read_only).is_ok());
}
/// A path's effect parameters can nest deeper than the JSON reader goes. Checking the output reads
/// only the read-only objects from the input, so such a path beside one doesn't fail the run.
#[test]
fn a_path_nested_past_the_json_reader_beside_a_read_only_object_passes_the_check() {
let (mut d, ids, _, [text, ..]) = mixed();
let params = (0..200).fold(json!(1), |inner, _| json!([inner]));
d.node_mut(ids[0]).unwrap().appearance.effects.push(vectorcraft_doc::Effect { id: "x".into(), params, visible: true });
let (bytes, read_only) = encode_input_with(&d, &[ids[0], text], &[InputKind::Type], usize::MAX).unwrap();
assert!(serde_json::from_slice::<Value>(&bytes).is_err(), "the input is deeper than the reader goes");
let echo = serde_json::from_slice::<Value>(&bytes[read_only[&text].clone()]).unwrap();
let out = decode_output_with(json!({"objects": [echo]}).to_string().as_bytes(), &bytes, &read_only).unwrap();
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 1);
}
/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose
/// symbol the document lacks says so.
#[test]
@@ -521,7 +727,7 @@ fn input_totals_cover_images_and_type_in_order() {
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())));
assert!(read_only.contains_key(&NodeId(o["id"].as_u64().unwrap())));
}
// Within the full totals every one of them is given.
let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap();
@@ -689,7 +895,7 @@ fn a_changed_field_is_named_escaped_and_short() {
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 e = decode_output_with(v.to_string().as_bytes(), &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}");
@@ -707,7 +913,7 @@ fn read_only_kind_field_and_omission_refuse() {
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();
let out = decode_output_with(&bytes, &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");
@@ -716,19 +922,20 @@ fn read_only_kind_field_and_omission_refuse() {
// 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());
assert!(decode_output_with(echo.to_string().as_bytes(), &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();
let out = decode_output_with(echo.to_string().as_bytes(), &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());
assert!(decode_output_with(echo.to_string().as_bytes(), &bytes, &read_only).is_err());
// `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type.
let ro = |id: NodeId| json!({"id": id.0});
let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]});
let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap();
let made =
apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &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()));
@@ -767,7 +974,7 @@ fn read_only_kind_field_and_omission_refuse() {
"`type` must be \"path\" or \"compound\"".to_string(),
),
] {
let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string();
let e = decode_output_with(out.to_string().as_bytes(), &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.
+6 -5
View File
@@ -6644,11 +6644,12 @@ them again at startup (`plugin.remove` deletes them there):
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) also lists `doc.read` in its `permissions`, so
`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of
pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`). Every object counts
toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included: pixels and runs that
don't fit come the same way, and an object whose other fields don't fit makes `plugin.run` return a memory-budget error
naming the object and its size, with nothing changed. A path or compound path that doesn't fit makes it return the
`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, in paint order from the
bottom, with at most 16 MiB of pixels and 16 MiB of type runs over one run (an object that doesn't fit what the objects
before it left comes with `pixelsOmitted` or `truncated`; an image that reaches the decoder counts toward the pixels
even when decoding fails). Every object counts toward the input's half of the plug-in's memory cap, ids, bounds,
transforms and names included: pixels and runs that don't fit come the same way, and an object whose other fields don't
fit makes `plugin.run` return a memory-budget error naming the object and its size, with nothing changed. A path or compound path that doesn't fit makes it return the
memory-budget error `the selection is too large to hand over`, and more than 100,000 of these objects make it return
an object-budget error. Its output returns
each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the
+6 -5
View File
@@ -522,11 +522,12 @@ 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`. 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.
`pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile, as sRGB. The pixels of
all the images in one input take at most 16 MiB in base64, and an image the host starts to decode takes its size
from those 16 MiB even when decoding fails. 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 `{}`.
+1 -1
View File
@@ -1571,7 +1571,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged.
- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged.
- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **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. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.