Merge main into fix/plugin-stale-grant

This commit is contained in:
dotCooCoo
2026-11-08 20:38:46 -08:00
45 changed files with 1850 additions and 67 deletions
+3
View File
@@ -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 }
+110 -13
View File
@@ -174,7 +174,25 @@ fn weight(key: &Key, out: Option<&Out>) -> usize {
};
anchors(&key.path) + out + (key.params.len() + key.paints.len()) / 50 + 1
}
static ERRORS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
static ERRORS: Mutex<Option<HashMap<String, Failures>>> = 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<String>,
/// From checking the swatches and patterns a painted result names against the document.
references: Option<String>,
/// Was the latest failure a refused reference?
references_latest: bool,
}
/// 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 +245,42 @@ 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`: 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<String> {
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)?;
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` 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. 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);
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();
if message.is_some() {
failures.references_latest = matches!(origin, Origin::References);
}
match origin {
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() {
errors.remove(id);
}
}
/// Does live-effect plug-in `id` return painted pieces (its manifest declares `paintOutput`)?
@@ -294,7 +333,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 +373,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 +416,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 +440,63 @@ 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::<Vec<_>>(), 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);
}
/// 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::<Vec<_>>(), 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]
+3 -2
View File
@@ -18,7 +18,8 @@
//! a module does can panic or hang the host.
//!
//! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with
//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns
//! their geometry, fills, strokes (gradients included) and opacity, and, for a filter that asks
//! for them ([`InputKind`]), type, images and symbol instances to read. An **object filter** returns
//! the objects to keep, change or add, applied to the document as one edit
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
//! time it is drawn, from an appearance stack, and at `api` 2 may paint what it returns
@@ -49,7 +50,7 @@ pub mod wat;
pub use dialog::ParamUi;
pub use host::{LogLevel, LogLine, Report};
pub use manifest::{Budget, Command, Kind, Manifest, Param, ParamSpec};
pub use manifest::{Budget, Command, InputKind, Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
/// What the host puts before a plug-in's id to name what it adds: its live effect
+132 -1
View File
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::dialog::ParamUi;
use crate::permissions::{self, API_LEVEL, PROGRESS_API, Permission};
use crate::permissions::{self, API_LEVEL, INPUTS_API, PROGRESS_API, Permission};
use crate::{Error, Result};
/// Longest manifest the host reads.
@@ -54,6 +54,39 @@ impl Kind {
}
}
/// A kind of object a filter may ask to be given besides paths and compound paths (its manifest's
/// `inputs`, plug-in API level 2). The filter reads these objects and returns each one as it was
/// given: it can't change or delete them ([`crate::objects::decode_output_with`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InputKind {
/// Point, area and path type: its text runs with their character attributes, and its
/// paragraph attributes.
Type,
/// An embedded or linked image: its size, color mode and, when small, its pixels.
Image,
/// A symbol instance: the symbol it shows and its transform.
SymbolInstance,
}
impl InputKind {
/// Every kind, in the order the docs list them.
pub const ALL: [InputKind; 3] = [InputKind::Type, InputKind::Image, InputKind::SymbolInstance];
/// Its name in `inputs`, which is also the `type` of such an object in a filter's input.
pub fn name(self) -> &'static str {
match self {
InputKind::Type => "type",
InputKind::Image => "image",
InputKind::SymbolInstance => "symbolInstance",
}
}
/// The kind named `name`.
pub fn parse(name: &str) -> Option<InputKind> {
InputKind::ALL.into_iter().find(|k| k.name() == name)
}
}
/// How much one run of a plug-in may compute.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -282,6 +315,11 @@ pub struct Manifest {
/// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<Vec<String>>,
/// The kinds of object a filter asks to be given besides paths and compound paths, by
/// [`InputKind::name`]: only a filter at `api` 2 or later 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<String>,
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
pub params: Vec<(String, Param)>,
@@ -327,6 +365,7 @@ impl Manifest {
author: String::new(),
api: 1,
permissions: None,
inputs: Vec::new(),
params: Vec::new(),
extensions: Vec::new(),
budget: Budget::Light,
@@ -370,6 +409,7 @@ impl Manifest {
let permission = host.name();
return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}")));
}
self.validate_inputs()?;
if self.paint_output && (self.kind != Kind::Effect || self.api < 2) {
return Err(Error::Manifest("`paintOutput` is only for a live effect (`kind` \"effect\") at `api` 2".into()));
}
@@ -405,6 +445,34 @@ impl Manifest {
Ok(())
}
/// `inputs` names each [`InputKind`] at most once, and only a filter at `api` 2 or later lists
/// any, with a `permissions` list that holds `doc.read`: installing it then asks the user to
/// grant them ([`Manifest::asks_permissions`]).
fn validate_inputs(&self) -> Result<()> {
if self.inputs.is_empty() {
return Ok(());
}
if self.kind != Kind::Filter {
return Err(Error::Manifest("only a filter declares `inputs`".into()));
}
for (i, name) in self.inputs.iter().enumerate() {
let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD);
if InputKind::parse(name).is_none() {
return Err(Error::Manifest(format!("`inputs` names an unknown object kind `{shown}`: use type, image or symbolInstance")));
}
if self.inputs.get(..i).is_some_and(|before| before.contains(name)) {
return Err(Error::Manifest(format!("`inputs` lists `{shown}` twice")));
}
}
if self.api < INPUTS_API {
return Err(Error::Manifest("`inputs` needs `api` 2 or later".into()));
}
if !self.permissions.as_ref().is_some_and(|p| p.iter().any(|n| n == Permission::DocRead.name())) {
return Err(Error::Manifest("`inputs` needs the permission `doc.read`".into()));
}
Ok(())
}
/// A filter or a live effect lists at most [`MAX_COMMANDS`] commands, each with a distinct
/// id, a printable label and parameters declared as the manifest's own are. A heavy filter
/// lists none. The error names the command: its id, or its place in the list.
@@ -446,6 +514,11 @@ impl Manifest {
Ok(())
}
/// The kinds of object besides paths and compound paths it asks to be given (`inputs`).
pub fn input_kinds(&self) -> Vec<InputKind> {
self.inputs.iter().filter_map(|n| InputKind::parse(n)).collect()
}
/// An exporter or an importer lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16
/// lower-case letters and digits; other kinds list none.
fn validate_extensions(&self) -> Result<()> {
@@ -604,6 +677,17 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
d.deserialize_any(V)
}
/// Reads `inputs`: a list of names (`null`: none). Anything else is an error that names the key, as
/// [`Manifest::validate_inputs`] names a wrong entry.
fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<String>, D::Error> {
let wrong = || serde::de::Error::custom("`inputs` must be a list of object kinds: type, image or symbolInstance");
match Value::deserialize(d)? {
Value::Null => Ok(Vec::new()),
Value::Array(names) => names.into_iter().map(|n| if let Value::String(s) = n { Ok(s) } else { Err(wrong()) }).collect(),
_ => Err(wrong()),
}
}
/// Reads the `commands` list one entry at a time from the manifest itself, so each command's
/// `params` keep their declaration order and a repeated parameter is refused. A malformed entry
/// is an error that names it (its id when the id comes before the fault, otherwise its place in
@@ -940,6 +1024,53 @@ mod tests {
}
}
/// A filter at `api` 2 may ask for type, images and symbol instances (`inputs`), each kind once,
/// with a `permissions` list holding `doc.read`, so installing it asks for approval. Left out
/// or empty, it asks for none and isn't written back. Anything else is refused naming `inputs`.
#[test]
fn a_filter_at_api_2_lists_the_inputs_it_reads() {
let m = Manifest::parse(
br#"{"id":"org.example.inspect","name":"Inspect","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type","image","symbolInstance"]}"#,
)
.unwrap();
assert_eq!(m.input_kinds(), InputKind::ALL);
assert!(m.asks_permissions());
assert_eq!(serde_json::to_value(&m).unwrap()["inputs"], json!(["type", "image", "symbolInstance"]));
let some = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.read"],"inputs":["image"]}"#).unwrap();
assert_eq!(some.input_kinds(), [InputKind::Image]);
assert!(some.asks_permissions());
for none in [
r#"{"id":"a","name":"x","kind":"filter"}"#,
r#"{"id":"a","name":"x","kind":"filter","inputs":[]}"#,
r#"{"id":"a","name":"x","kind":"filter","inputs":null}"#,
r#"{"id":"a","name":"x","kind":"effect","inputs":[]}"#,
] {
let m = Manifest::parse(none.as_bytes()).unwrap();
assert!(m.input_kinds().is_empty(), "{none}");
assert!(serde_json::to_value(&m).unwrap().get("inputs").is_none(), "{none}");
}
for (bad, says) in [
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["text"]}"#, "`inputs` names an unknown object kind `text`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["Type"]}"#, "unknown object kind `Type`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["image","image"]}"#, "`inputs` lists `image` twice"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":"type"}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":[1]}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":{"type":true}}"#, "`inputs` must be a list"),
(r#"{"id":"a","name":"x","kind":"filter","inputs":["type"]}"#, "`inputs` needs `api` 2"),
(r#"{"id":"a","name":"x","kind":"filter","api":1,"inputs":["type"]}"#, "`inputs` needs `api` 2"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["doc.write"],"inputs":["type"]}"#, "needs the permission `doc.read`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"inputs":["type"]}"#, "`inputs` needs the permission `doc.read`"),
(r#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":[],"inputs":["image"]}"#, "`inputs` needs the permission `doc.read`"),
(r#"{"id":"a","name":"x","kind":"effect","api":2,"inputs":["type"]}"#, "only a filter declares `inputs`"),
(r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"api":2,"inputs":["image"]}"#, "only a filter declares `inputs`"),
] {
let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string();
assert!(e.contains(says), "{bad}: {e}");
}
let long = format!(r#"{{"id":"a","name":"x","kind":"filter","api":2,"inputs":[{}]}}"#, vec![r#""type""#; 1000].join(","));
assert!(Manifest::parse(long.as_bytes()).unwrap_err().to_string().contains("twice"));
}
/// A live effect at `api` 2 may declare `"paintOutput": true`, and only that value; any other
/// kind or level, and `false`, `null` or another type, is refused naming the key. Without the
/// key, at any level, the effect returns geometry only.
+504 -21
View File
@@ -6,14 +6,19 @@
//! in-range coordinates, capped counts, clamped colours) before it reaches the document.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Cms, CmykLut, lab};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
use vectorcraft_doc::cmyk::oversized_tiff;
use vectorcraft_doc::{
Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes,
TextKind, TextObject,
};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
use crate::{Error, Result};
use crate::{Error, InputKind, Result};
/// Coordinates a plug-in may return, in points (the document's own canvas limit).
pub const MAX_COORD: f64 = 4.0e6;
@@ -32,6 +37,20 @@ pub const MITER_LIMIT: std::ops::RangeInclusive<f64> = 1.0..=500.0;
pub const MAX_EFFECT_ID: usize = 128;
/// Largest data a plug-in may keep with a document (`documentData`), in bytes of JSON.
pub const MAX_DOCUMENT_DATA: usize = 1 << 20;
/// Most text a filter is given of one type object, in bytes of UTF-8 over all its runs: longer
/// type comes with `truncated` and no runs or paragraphs.
pub const MAX_INPUT_TEXT: usize = 256 * 1024;
/// Most runs, and most paragraphs, a filter is given of one type object (as [`MAX_INPUT_TEXT`]).
pub const MAX_INPUT_RUNS: usize = 4_096;
/// Largest pixels a filter is given of one embedded image, in characters of base64: a larger
/// image comes with `pixelsOmitted`.
pub const MAX_INPUT_PIXELS: usize = 1 << 20;
/// Most pixels a filter is given over all the images of one input, in characters of base64: an
/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`.
pub const MAX_INPUT_PIXELS_TOTAL: usize = 16 << 20;
/// Most text a filter is given over all the type of one input, in bytes of the JSON of their runs
/// and paragraphs: type that doesn't fit what the type before it left comes with `truncated`.
pub const MAX_INPUT_TEXT_TOTAL: usize = 16 << 20;
/// One object returned by a plug-in. `None` fields keep the object's value (new objects take the
/// defaults: a path, non-zero rule, no fills or strokes, full opacity).
@@ -56,6 +75,9 @@ pub struct OutObject {
pub guide: Option<bool>,
/// Locks or unlocks the object (as Object › Lock and Unlock do).
pub locked: Option<bool>,
/// A read-only input object (type, an image or a symbol instance) returned as it was given:
/// it stays as it is, and new objects listed after it go above it.
pub kept: bool,
}
/// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's
@@ -158,8 +180,350 @@ pub fn encode(n: &Node) -> Option<Value> {
/// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths;
/// others are left out).
pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec<u8> {
let objects: Vec<Value> = ids.iter().filter_map(|id| doc.node(*id)).filter_map(encode).collect();
serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default()
encode_input_with(doc, ids, &[], usize::MAX).map(|(bytes, _)| bytes).unwrap_or_default()
}
/// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are
/// in it as [`encode_read_only`] gives them, within [`MAX_INPUT_PIXELS_TOTAL`] and
/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order, and within `capacity`, the most bytes the
/// input may be. Every object counts toward `capacity` in order, ids, bounds, transforms and names
/// included: pixels or text that don't fit what is left are omitted (`pixelsOmitted`,
/// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`]
/// naming it and its size, a path or compound path that doesn't fit is the limit the runtime gives
/// an input past its share, and more than [`MAX_OBJECTS`] read-only objects, more than an output
/// may list, is an [`Error::Limit`] too. Also returns those objects by id, which the filter
/// must return as they were ([`decode_output_with`]).
pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL })
}
/// The input with no objects; each object adds its JSON and a comma.
const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}";
/// What an input's objects may still be given of its totals, as they are encoded in order:
/// characters of base64 for pixels, bytes of JSON for runs and paragraphs, and bytes of the whole
/// input.
#[derive(Clone, Copy, Debug)]
struct Allowance {
pixels: usize,
text: usize,
input: usize,
}
impl Allowance {
const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX };
}
/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map
/// it returns, and written into the input from there.
fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
let mut read_only = HashMap::new();
let symbols = doc.symbol_boxes();
let mut out = b"{\"objects\":[".to_vec();
let mut first = true;
for n in ids.iter().filter_map(|id| doc.node(*id)) {
let path;
let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) {
// The output must return every read-only object, and lists at most `MAX_OBJECTS`.
Some(_) if read_only.len() >= MAX_OBJECTS => return Err(too_many_read_only()),
Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left)? {
Some(o) => (read_only.entry(n.id).insert_entry(o).into_mut(), true),
None => continue,
},
None => match encode(n) {
Some(o) => {
path = o;
(&path, false)
}
None => continue,
},
};
let start = out.len();
if !std::mem::take(&mut first) {
out.push(b',');
}
if serde_json::to_writer(&mut out, o).is_err() {
return Ok((Vec::new(), HashMap::new()));
}
let size = out.len() - start;
// A path or compound path past what is left fails the run as the runtime would fail it,
// before the rest of the selection is written.
if size > left.input {
return Err(if checked { too_large(n, size, left.input) } else { Error::Limit(crate::runtime::SELECTION_TOO_LARGE.into()) });
}
left.input -= size;
}
out.extend_from_slice(b"]}");
Ok((out, read_only))
}
/// The error for read-only object `n`, which needs `size` bytes of input where `left` are left.
fn too_large(n: &Node, size: usize, left: usize) -> Error {
Error::Limit(format!("memory budget (object {} needs {size} bytes of input, {left} left)", n.id.0))
}
/// The error for an input with more read-only objects than an output may list.
fn too_many_read_only() -> Error {
Error::Limit(format!("object budget (more than {MAX_OBJECTS} read-only objects)"))
}
/// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others).
pub fn input_kind(n: &Node) -> Option<InputKind> {
match &n.kind {
NodeKind::Text(_) => Some(InputKind::Type),
NodeKind::Image(_) => Some(InputKind::Image),
NodeKind::SymbolInstance { .. } => Some(InputKind::SymbolInstance),
_ => None,
}
}
fn affine_json(xf: Affine) -> Value {
json!(xf.as_coeffs())
}
/// Type, an image or a symbol instance of `doc` as a filter that asks for its kind reads it (`None`
/// for other objects). Each has `{id, type, name?, bounds, transform}`, `transform` mapping its own
/// space (text space, pixel space, the symbol's art) into the document, and:
/// - type (`"type"`): `text: {kind: "point"|"area"|"path", vertical, runs: [{text, style, inline?}],
/// paragraphs: [style]}` with the native character and paragraph attributes, one paragraph style
/// for each paragraph, and `truncated`: true, with no runs or paragraphs, past [`MAX_INPUT_TEXT`]
/// or [`MAX_INPUT_RUNS`];
/// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`,
/// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`]
/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top (a 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<Value> {
let mut left = Allowance::FULL;
encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left).ok().flatten()
}
/// The bytes the `pixelsBase64` key adds to an image's JSON besides the base64 itself.
const PIXELS_KEY: usize = ",\"pixelsBase64\":\"\"".len();
/// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the
/// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that
/// don't fit what is left of their totals or of the input are omitted (`pixelsOmitted`,
/// `truncated`), and an image's size is checked against it before anything is decoded. The object's
/// other fields are measured first, with a comma before it: when they alone don't fit what is left
/// of the input, it is an [`Error::Limit`] naming the object and that size.
fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Result<Option<Value>> {
let Some(kind) = input_kind(n) else { return Ok(None) };
let mut o = json!({"id": n.id.0, "type": kind.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))});
// Each flag is measured as `false`, its longer value.
match &n.kind {
NodeKind::Text(t) => {
o["transform"] = affine_json(t.xf);
o["text"] = text_json(t, None).0;
o["truncated"] = json!(false);
}
NodeKind::Image(im) => {
o["transform"] = affine_json(im.xf);
o["pixelSize"] = json!([im.width, im.height]);
o["colorMode"] = json!(doc.image_blob(im).map_or("unknown", color_mode));
o["linked"] = json!(im.link.is_some());
o["pixelsOmitted"] = json!(false);
}
NodeKind::SymbolInstance { symbol, xf } => {
o["transform"] = affine_json(*xf);
o["symbolId"] = json!(symbol);
if !doc.symbols.iter().any(|s| s.name == *symbol) {
o["missing"] = json!(true);
}
o["overrides"] = json!({});
}
_ => return Ok(None),
}
if let Some(name) = &n.name {
o["name"] = json!(name);
}
let fields = serde_json::to_vec(&o).map_or(usize::MAX, |b| b.len()).saturating_add(1);
if fields > left.input {
return Err(too_large(n, fields, left.input));
}
let room = left.input - fields;
match &n.kind {
NodeKind::Text(t) => {
let mut budget = left.text.min(room);
let (text, truncated) = text_json(t, Some(&mut budget));
left.text -= left.text.min(room) - budget;
o["text"] = text;
o["truncated"] = json!(truncated);
}
NodeKind::Image(im) => {
let blob = doc.image_blob(im);
let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY));
// A linked image's pixels are its file's: never given (nor its path).
let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max));
o["pixelsOmitted"] = json!(pixels.is_none());
if let Some(p) = pixels {
let b64 = craft_vectorcraft_format::base64::encode(&p);
left.pixels = left.pixels.saturating_sub(b64.len());
o["pixelsBase64"] = json!(b64);
}
}
_ => {}
}
Ok(Some(o))
}
/// Counts the bytes written to it and fails past `left`, so nothing is held to find a size.
struct Measure {
bytes: usize,
left: usize,
}
impl std::io::Write for Measure {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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<T: serde::Serialize>(item: &T, list: &mut Vec<Value>, used: &mut usize, limit: usize) -> bool {
let comma = usize::from(!list.is_empty());
let Some(left) = limit.checked_sub(used.saturating_add(comma)) else { return false };
if serde_json::to_writer(Measure { bytes: 0, left }, item).is_err() {
return false;
}
let Ok(value) = serde_json::to_value(item) else { return false };
// A `Value` writes a number in its widest form, so the bytes given are counted from it.
let mut measure = Measure { bytes: 0, left };
if serde_json::to_writer(&mut measure, &value).is_err() {
return false;
}
*used += comma + measure.bytes;
list.push(value);
true
}
/// The text of type `t` as a filter reads it, and whether it was too long to give
/// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives
/// is taken from, with the commas between them). With `left` `None`, its kind and direction with no
/// runs or paragraphs.
fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) {
let kind = match t.kind {
TextKind::Point => "point",
TextKind::Area { .. } => "area",
TextKind::OnPath { .. } => "path",
};
let (mut runs, mut paras): (Vec<Value>, Vec<Value>) = (Vec::new(), Vec::new());
let mut truncated = false;
if let Some(left) = left {
let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len()));
let paragraphs = t.paragraph_count();
// The text alone is never more bytes than its JSON, so type that can't fit isn't built.
truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS;
if !truncated {
// Each run and paragraph is measured before it is built, with the comma before it, so
// no more than `left` is held before the type is found too long.
let (limit, mut used) = (*left, 0usize);
let fits = t.runs.iter().all(|r| push_within(r, &mut runs, &mut used, limit))
&& (0..paragraphs).all(|i| push_within(&t.para_at(i), &mut paras, &mut used, limit));
if fits {
*left -= used;
} else {
(runs, paras, truncated) = (Vec::new(), Vec::new(), true);
}
}
}
(json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated)
}
/// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or
/// `"unknown"` when it can't be read, is a TIFF too large for the decoder to count, or only a
/// linked image's preview is there.
fn color_mode(b: &ImageBlob) -> &'static str {
if oversized_tiff(&b.bytes).is_some() {
return "unknown";
}
if b.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &b.bytes) || **p == *b.bytes) {
return "unknown";
}
if b.is_cmyk() {
return "cmyk";
}
if b.gray_source().is_some() {
return "gray";
}
let readable =
image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok());
if readable.is_some() { "rgb" } else { "unknown" }
}
/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max`
/// characters of base64; the size is checked, and read from the header, before anything is decoded.
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<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() {
return None;
}
if b.is_cmyk() {
return cmyk_rgba(b, w, h);
}
let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok();
if reader()?.into_dimensions().ok()? != (w, h) {
return None;
}
let mut r = reader()?;
let mut limits = image::Limits::default();
// The decoder's own buffers besides the RGBA it gives.
limits.max_alloc = Some(64 << 20);
r.limits(limits);
let img = r.decode().ok()?.into_rgba8();
(img.dimensions() == (w, h)).then(|| img.into_raw())
}
/// The inks → XYZ table of [`cmyk_rgba`] and the color settings it was built with.
static CMYK_DISPLAY: Mutex<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.
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();
let mut slot = CMYK_DISPLAY.lock().unwrap_or_else(|e| e.into_inner());
match &*slot {
Some((owner, lut)) if Arc::ptr_eq(owner, &cms) => lut.clone(),
_ => {
let lut = Arc::new(CmykLut::build(|c| {
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(c));
[x, y, z, 0.0]
}));
*slot = Some((cms, lut.clone()));
lut
}
}
};
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let mut out = Vec::with_capacity(inks.data.len());
// Runs of one color convert once.
let mut last: Option<([u8; 4], [u8; 4])> = None;
for p in inks.data.as_chunks::<4>().0 {
let rgba = match last {
Some((k, v)) if k == *p => v,
_ => {
let [x, y, z, _] = lut.apply(p.map(|v| f32::from(v) / 255.0));
let [r, g, b] = lab::xyz_to_srgb([x, y, z]).map(q);
last = Some((*p, [r, g, b, 255]));
[r, g, b, 255]
}
};
out.extend_from_slice(&rgba);
}
Some(out)
}
pub(crate) fn out_err(at: &str, msg: impl std::fmt::Display) -> Error {
@@ -397,8 +761,16 @@ pub struct Output {
/// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}`
/// when the plug-in declined (the message reaches the user).
pub fn decode_output(bytes: &[u8]) -> Result<Output> {
decode_output_with(bytes, &HashMap::new())
}
/// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list
/// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by
/// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left
/// out, is refused naming the object and the first field that differs, before anything changes.
pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> Result<Output> {
let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
let objects = decode_objects(&v)?;
let objects = decode_objects(&v, read_only)?;
let document_data = match v.get_mut("documentData").map(Value::take) {
None => None,
Some(Value::Null) => Some(None),
@@ -417,11 +789,12 @@ pub fn decode_output(bytes: &[u8]) -> Result<Output> {
/// declined (the message reaches the user). A live effect's other keys don't matter.
pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
decode_objects(&v)
decode_objects(&v, &HashMap::new())
}
/// The objects of output `v`, or its decline.
fn decode_objects(v: &Value) -> Result<Vec<OutObject>> {
/// The objects of output `v`, or its decline; entries for `read_only` objects as
/// [`decode_output_with`] reads them.
fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<OutObject>> {
if let Some(e) = declined(v) {
return Err(e);
}
@@ -430,7 +803,91 @@ fn decode_objects(v: &Value) -> Result<Vec<OutObject>> {
return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len())));
}
let mut anchors = 0;
objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect()
let mut returned: HashSet<NodeId> = HashSet::new();
let out = objects
.iter()
.enumerate()
.map(|(i, o)| match output_id(o).and_then(|id| Some((id, read_only.get(&id)?))) {
Some((id, given)) => {
let at = format!("objects[{i}] (id {})", id.0);
if !returned.insert(id) {
return Err(out_err(&at, "is listed twice"));
}
check_unchanged(o, given, &at)?;
Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() })
}
None => decode_object(o, &format!("objects[{i}]"), &mut anchors),
})
.collect::<Result<Vec<_>>>()?;
let mut missing: Vec<NodeId> = read_only.keys().filter(|id| !returned.contains(id)).copied().collect();
missing.sort();
if let Some(id) = missing.first() {
return Err(out_err(
&format!("objects (id {})", id.0),
"a read-only object was left out: return it, as {\"id\": …} alone or as it was given",
));
}
Ok(out)
}
/// The id entry `o` gives, a whole number written either way (`17` or `17.0`).
fn output_id(o: &Value) -> Option<NodeId> {
let id = o.get("id")?;
id.as_u64().or_else(|| id.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=9_007_199_254_740_992.0).contains(f)).map(|f| f as u64)).map(NodeId)
}
/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone,
/// or `given` itself, numbers compared by value. Otherwise the error names the first field that
/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as
/// [`crate::permissions::display_text`] shows a manifest string, since the entry's keys are the
/// plug-in's.
fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> {
if o.as_object().is_some_and(|m| m.len() == 1) {
return Ok(());
}
let changed = match (o, given) {
(Value::Object(x), Value::Object(y)) => {
let mut keys: Vec<&String> = x.keys().collect();
keys.sort();
keys.into_iter().find_map(|k| match y.get(k) {
Some(q) => first_difference(&x[k], q, k),
None => Some(k.clone()),
})
}
_ => None,
};
match changed.or_else(|| first_difference(o, given, "")) {
None => Ok(()),
Some(field) => {
let field = crate::permissions::display_text(&field, crate::permissions::MAX_SHOWN_FIELD);
Err(out_err(at, format!("read-only field '{field}' changed")))
}
}
}
/// The path of the first place `a` and `b` differ (`""`: they differ as a whole), `None` when they
/// are the same JSON with numbers compared by value.
fn first_difference(a: &Value, b: &Value, at: &str) -> Option<String> {
let key = |k: &str| if at.is_empty() { k.to_string() } else { format!("{at}.{k}") };
match (a, b) {
(Value::Number(x), Value::Number(y)) => (x.as_f64() != y.as_f64()).then(|| at.into()),
(Value::Object(x), Value::Object(y)) => {
let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect();
keys.sort();
keys.dedup();
keys.into_iter().find_map(|k| match (x.get(k), y.get(k)) {
(Some(p), Some(q)) => first_difference(p, q, &key(k)),
_ => Some(key(k)),
})
}
(Value::Array(x), Value::Array(y)) => {
if x.len() != y.len() {
return Some(at.into());
}
x.iter().zip(y).enumerate().find_map(|(i, (p, q))| first_difference(p, q, &format!("{at}[{i}]")))
}
(a, b) => (a != b).then(|| at.into()),
}
}
/// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it,
@@ -622,25 +1079,39 @@ pub fn selectable(n: &Node) -> bool {
/// The paths and compound paths a filter reads from the selection `selected`, in paint order:
/// selected ones, and those inside selected groups and layers; hidden, locked, guide (compound
/// guides too) and clipping paths are left out (so are paths inside compound paths, blends and
/// other live objects).
/// guides too) and clipping paths are left out, and so are compound paths that clip a clip group
/// and paths inside a group that clips one (and paths inside compound paths, blends and other
/// live objects).
pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec<NodeId> {
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, out: &mut Vec<NodeId>) {
for n in list {
filter_targets_with(doc, selected, &[])
}
/// [`filter_targets`] for a filter that asks for `kinds` too: type, images and symbol instances of
/// those kinds join the paths in paint order, under the same rules (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<NodeId> {
/// `source`: `list` is inside the clipping object of a clip group.
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, clips: bool, source: bool, kinds: &[InputKind], out: &mut Vec<NodeId>) {
for (i, n) in list.iter().enumerate() {
if !n.visible || n.locked {
continue;
}
let picked = inside || sel.contains(&n.id);
let clipping = source || (clips && i == 0);
match &n.kind {
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out),
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !clipping && !is_guide(n) => {
out.push(n.id)
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), clipping, kinds, out),
_ if picked && !clipping && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id),
_ => {}
}
}
}
let sel: HashSet<NodeId> = selected.iter().copied().collect();
let mut out = vec![];
visit(&doc.layers, &sel, false, &mut out);
visit(&doc.layers, &sel, false, false, false, kinds, &mut out);
out
}
@@ -740,7 +1211,14 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" };
out_err(&at, format!("{} {why}", id.0))
})?;
write(doc, &mut n, o, &at, admit)?;
// Type, images and symbol instances are only read: writing one would make it a path.
if input_kind(&n).is_some() && !o.kept {
return Err(out_err(&at, format!("{} is read-only", id.0)));
}
let kept = o.kept;
if !kept {
write(doc, &mut n, o, &at, admit)?;
}
if !run.is_empty() {
let nodes = std::mem::take(&mut run);
match prev {
@@ -748,7 +1226,9 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
None => plan.before.insert(id, nodes),
};
}
plan.replace.insert(id, n);
if !kept {
plan.replace.insert(id, n);
}
prev = Some(id);
result.push(id);
}
@@ -774,7 +1254,10 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
None => plan.top = Some((parent, run)),
}
}
// Input objects the output left out are deleted.
// Input objects the output left out are deleted, except read-only ones, which it must return.
if let Some(n) = originals.values().filter(|n| input_kind(n).is_some()).min_by_key(|n| n.id) {
return Err(out_err(&format!("objects (id {})", n.id.0), "a read-only object was left out"));
}
plan.delete = originals.into_keys().collect();
if let Some(list) = plan.list(None, &doc.layers) {
doc.layers = list;
+470
View File
@@ -310,6 +310,476 @@ fn bad_stroke_styles_and_effect_records_are_refused() {
}
}
/// A `w` × `h` PNG whose pixels vary with their column, half transparent.
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([(x * 100) as u8, (y * 7) as u8, 30, 128]));
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// An image object `w` × `h` pixels showing blob `key`, linked to `link`.
fn image_node(d: &mut Document, key: &str, (w, h): (u32, u32), link: Option<&str>) -> Node {
let im = vectorcraft_doc::ImageObject {
key: key.into(),
width: w,
height: h,
xf: Affine::new([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]),
link: link.map(vectorcraft_doc::LinkInfo::new),
placement: Default::default(),
pixels: None,
};
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
}
/// [`doc`] with, above its paths: point type "T" in two paragraphs, an embedded 2 × 1 image "E", a
/// linked image "L", an instance "S" of symbol "Star" and an instance "G" of a symbol the document
/// doesn't have. Returns those five too.
fn mixed() -> (Document, Vec<NodeId>, NodeId, [NodeId; 5]) {
use vectorcraft_doc::{CharStyle, ParaStyle, Symbol, TextObject};
let (mut d, ids, layer) = doc();
let mut t = TextObject::point(Point::new(5.0, 50.0), "Hi\nthere", CharStyle::default());
t.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { space_before: 6.0, ..Default::default() }]);
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
d.images.insert("e".into(), ImageBlob::png(png(2, 1)));
d.images.insert("l".into(), ImageBlob::png(png(4, 4)));
let embedded = image_node(&mut d, "e", (2, 1), None);
let linked = image_node(&mut d, "l", (4, 4), Some("C:/private/photo.png"));
let art = Arc::new(Node::path(d.alloc_id(), square(0.0), Appearance::default()));
d.symbols.push(Symbol { name: "Star".into(), art, dynamic: false });
let star = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::translate((100.0, 0.0)) });
let ghost = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
let mut extra = [NodeId(0); 5];
for (slot, (mut n, name)) in extra.iter_mut().zip([text, embedded, linked, star, ghost].into_iter().zip(["T", "E", "L", "S", "G"])) {
n.name = Some(name.into());
*slot = d.insert(Some(layer), usize::MAX, n).unwrap();
}
(d, ids, layer, extra)
}
/// A filter that doesn't list `inputs` is given what it was before: the selected paths alone, in
/// the same bytes. One that lists kinds is given those objects too, in paint order, under the same
/// rules (hidden and locked ones left out, selected ones or ones in a selected layer).
#[test]
fn legacy_filter_input_bytes_are_unchanged() {
let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed();
assert_eq!(filter_targets(&d, &[layer]), ids);
assert_eq!(filter_targets_with(&d, &[layer], &[]), ids);
let every: Vec<NodeId> = ids.iter().copied().chain([text, embedded, linked, star, ghost]).collect();
let (bytes, read_only) = encode_input_with(&d, &every, &[], usize::MAX).unwrap();
assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out");
assert!(read_only.is_empty());
let all = filter_targets_with(&d, &[layer], &InputKind::ALL);
assert_eq!(all, every);
let images: Vec<NodeId> = ids.iter().copied().chain([embedded, linked]).collect();
assert_eq!(filter_targets_with(&d, &[layer], &[InputKind::Image]), images);
assert_eq!(filter_targets_with(&d, &[star, ids[0]], &InputKind::ALL), [ids[0], star], "selected directly");
d.node_mut(text).unwrap().visible = false;
d.node_mut(star).unwrap().locked = true;
assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat());
let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance], usize::MAX).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect();
assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]);
assert_eq!(read_only.len(), 2);
}
/// Type comes with its runs and their character attributes, one paragraph style per paragraph, its
/// kind and transform; type past [`MAX_INPUT_TEXT`] or [`MAX_INPUT_RUNS`] comes without text.
#[test]
fn declared_type_input_has_bounded_runs_and_paragraph_styles() {
use vectorcraft_doc::{CharStyle, ParaStyle, TextRun};
let (mut d, _, _, [text, ..]) = mixed();
let o = encode_read_only(&d, d.node(text).unwrap()).unwrap();
assert_eq!((&o["type"], &o["name"], &o["truncated"]), (&json!("type"), &json!("T"), &json!(false)));
assert_eq!(o["transform"], json!([1.0, 0.0, 0.0, 1.0, 5.0, 50.0]));
assert_eq!((&o["text"]["kind"], &o["text"]["vertical"]), (&json!("point"), &json!(false)));
assert_eq!(o["text"]["runs"], json!([{"text": "Hi\nthere", "style": serde_json::to_value(CharStyle::default()).unwrap()}]));
let spaced = ParaStyle { space_before: 6.0, ..Default::default() };
assert_eq!(o["text"]["paragraphs"], json!([serde_json::to_value(ParaStyle::default()).unwrap(), serde_json::to_value(spaced).unwrap()]));
assert!(o.get("path").is_none() && o.get("fills").is_none(), "not made into outlines");
let set_runs = |d: &mut Document, runs: Vec<TextRun>| {
if let NodeKind::Text(t) = &mut d.node_mut(text).unwrap().kind {
t.runs = runs;
t.paras.clear();
}
};
let run = |s: String| TextRun::new(s, CharStyle::default());
for (runs, truncated) in [
(vec![run("x".repeat(MAX_INPUT_TEXT))], false),
(vec![run("x".repeat(MAX_INPUT_TEXT - 1)), run("é".into())], true),
(vec![run("\n".repeat(MAX_INPUT_RUNS - 1))], false),
(vec![run("\n".repeat(MAX_INPUT_RUNS))], true),
((0..MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), false),
((0..=MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), true),
] {
let n = runs.len();
set_runs(&mut d, runs);
let o = encode_read_only(&d, d.node(text).unwrap()).unwrap();
assert_eq!(o["truncated"], json!(truncated), "{n} runs");
let given = o["text"]["runs"].as_array().unwrap().len();
assert_eq!((given, o["text"]["paragraphs"].as_array().unwrap().is_empty()), (if truncated { 0 } else { n }, truncated), "{n} runs");
}
}
/// An embedded image comes with its pixels when they are small; a linked image never does, and
/// nothing of its file's path is given. The color mode is read from the image.
#[test]
fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission() {
let (mut d, _, layer, [_, embedded, linked, ..]) = mixed();
let e = encode_read_only(&d, d.node(embedded).unwrap()).unwrap();
assert_eq!((&e["type"], &e["pixelSize"], &e["colorMode"]), (&json!("image"), &json!([2, 1]), &json!("rgb")));
assert_eq!((&e["linked"], &e["pixelsOmitted"], &e["transform"]), (&json!(false), &json!(false), &json!([2.0, 0.0, 0.0, 2.0, 40.0, 60.0])));
let pixels = craft_vectorcraft_format::base64::decode(e["pixelsBase64"].as_str().unwrap()).unwrap();
assert_eq!(pixels, [0, 0, 30, 128, 100, 0, 30, 128], "straight RGBA, row by row");
let l = encode_read_only(&d, d.node(linked).unwrap()).unwrap();
assert_eq!((&l["linked"], &l["pixelsOmitted"], &l["pixelSize"]), (&json!(true), &json!(true), &json!([4, 4])));
assert!(l.get("pixelsBase64").is_none());
assert!(!l.to_string().contains("private"), "the link's path isn't given: {l}");
// Past the limit (443 × 443 fits it; 444 × 443 doesn't), and pixels of another size, are omitted.
for ((w, h), omitted) in [((443, 443), false), ((444, 443), true)] {
d.images.insert("big".into(), ImageBlob::png(png(w, h)));
let n = image_node(&mut d, "big", (w, h), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
assert_eq!((&o["pixelsOmitted"], o.get("pixelsBase64").is_some()), (&json!(omitted), !omitted), "{w} × {h}");
}
let n = image_node(&mut d, "e", (3, 1), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "not the size it says");
// A size whose pixel count overflows is omitted too.
let n = image_node(&mut d, "e", (u32::MAX, u32::MAX), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "too large to count");
// Gray pixels, and data that isn't an image.
let mut gray = vec![];
image::GrayImage::from_pixel(2, 2, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap();
for (blob, mode) in [(ImageBlob::png(gray), "gray"), (ImageBlob::png(b"not an image".to_vec()), "unknown")] {
d.images.insert("x".into(), blob);
let n = image_node(&mut d, "x", (2, 2), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["colorMode"], json!(mode));
}
}
/// A CMYK image's pixels are its inks as the working CMYK profile shows them on the canvas: black
/// ink alone is the profile's black, not RGB black.
#[test]
fn cmyk_image_pixels_go_through_the_working_cmyk_profile() {
use vectorcraft_color::cms::{self, lab};
let (mut d, _, layer, _) = mixed();
let inks = vectorcraft_doc::cmyk::Inks::new(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 0]).unwrap();
d.images.insert("k".into(), ImageBlob::cmyk_tiff(&inks).unwrap());
let n = image_node(&mut d, "k", (2, 1), None);
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
assert_eq!((&o["colorMode"], &o["pixelsOmitted"]), (&json!("cmyk"), &json!(false)));
let pixels = craft_vectorcraft_format::base64::decode(o["pixelsBase64"].as_str().unwrap()).unwrap();
let shown = |c: [f32; 4]| lab::xyz_to_srgb(lab::lab_to_xyz(cms::active().cmyk_display_lab(c))).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
let ([r, g, b], [r2, g2, b2]) = (shown([0.0, 0.0, 0.0, 1.0]), shown([0.0; 4]));
assert_eq!(pixels, [r, g, b, 255, r2, g2, b2, 255]);
assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile");
}
/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose
/// symbol the document lacks says so.
#[test]
fn declared_symbol_instance_keeps_identity_and_transform() {
let (d, _, _, [.., star, ghost]) = mixed();
let s = encode_read_only(&d, d.node(star).unwrap()).unwrap();
assert_eq!((&s["type"], &s["symbolId"], &s["overrides"]), (&json!("symbolInstance"), &json!("Star"), &json!({})));
assert_eq!(s["transform"], json!([1.0, 0.0, 0.0, 1.0, 100.0, 0.0]));
assert!(s.get("missing").is_none() && s.get("path").is_none());
let g = encode_read_only(&d, d.node(ghost).unwrap()).unwrap();
assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true)));
}
/// Pixels and type count against totals over the whole input, in order: an image whose pixels
/// don't fit what the images before it left comes with `pixelsOmitted` (its size checked before it
/// is decoded), and type whose runs and paragraphs don't fit comes with `truncated`. The read-only
/// objects returned are the ones in the input.
#[test]
fn input_totals_cover_images_and_type_in_order() {
let (mut d, _, layer, [text, embedded, ..]) = mixed();
let copy = |d: &mut Document, id: NodeId| {
let mut n = d.node(id).unwrap().clone();
n.id = d.alloc_id();
d.insert(Some(layer), usize::MAX, n).unwrap()
};
let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded));
let one = encode_read_only(&d, d.node(text).unwrap()).unwrap();
// The runs and paragraphs with the commas between them, without their brackets.
let size: usize = ["runs", "paragraphs"].iter().map(|k| one["text"][k].to_string().len() - 2).sum();
// A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't.
let ids = [text, text2, embedded, image2, image3];
let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size, input: usize::MAX }).unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let objects = v["objects"].as_array().unwrap();
assert_eq!(objects.len(), 5);
let flags: Vec<&Value> = objects.iter().map(|o| if o["type"] == "type" { &o["truncated"] } else { &o["pixelsOmitted"] }).collect();
assert_eq!(flags, [&json!(false), &json!(true), &json!(false), &json!(false), &json!(true)]);
assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none());
assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]);
for o in objects {
assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap())));
}
// Within the full totals every one of them is given.
let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap();
let all = String::from_utf8(bytes).unwrap();
assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}");
}
/// Every object counts toward the input's capacity in order, its id, bounds, transform and name
/// included: a symbol instance before type leaves the type less room, so text that fits the exact
/// size of the whole input comes with `truncated` one byte under it, and an image's pixels are
/// omitted the same way.
#[test]
fn object_fields_count_toward_the_input_capacity() {
let (d, ids, _, [text, embedded, _, star, _]) = mixed();
for (order, flag) in [([ids[0], star, text], "truncated"), ([ids[0], star, embedded], "pixelsOmitted")] {
let (bytes, _) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap();
let (exact, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len()).unwrap();
assert_eq!(exact, bytes, "{flag}: the exact size fits");
let (under, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len() - 1).unwrap();
let v: Value = serde_json::from_slice(&under).unwrap();
assert_eq!(v["objects"][2][flag], json!(true), "{flag}: one byte under");
assert!(under.len() < bytes.len() - 1, "{flag}");
}
}
/// Type with fractional colors counts the bytes the input gives it: one byte under its exact size
/// it comes with `truncated`, not a limit.
#[test]
fn fractional_colors_count_as_the_input_writes_them() {
use vectorcraft_doc::{CharStyle, TextObject};
let (mut d, ids, layer, _) = mixed();
let style = CharStyle { fill: Paint::solid(Color::rgb(0.1, 0.2, 0.3)), ..CharStyle::default() };
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Hi", style))));
let text = d.insert(Some(layer), usize::MAX, t).unwrap();
let order = [ids[0], text];
let (bytes, _) = encode_input_with(&d, &order, &[InputKind::Type], usize::MAX).unwrap();
let (under, _) = encode_input_with(&d, &order, &[InputKind::Type], bytes.len() - 1).unwrap();
let v: Value = serde_json::from_slice(&under).unwrap();
assert_eq!(v["objects"][1]["truncated"], json!(true));
}
/// A read-only object whose fields other than its pixels and text alone don't fit what is left of
/// the input fails the run with a limit naming it and its size, rather than being left out.
#[test]
fn an_object_too_large_to_give_is_a_limit_naming_it() {
let (mut d, ids, _, [_, _, linked, star, _]) = mixed();
d.node_mut(linked).unwrap().name = Some("n".repeat(4096));
let (fits, _) = encode_input_with(&d, &[ids[0], star], &InputKind::ALL, usize::MAX).unwrap();
let order = [ids[0], star, linked];
let e = encode_input_with(&d, &order, &InputKind::ALL, fits.len() + 4096).unwrap_err();
assert!(matches!(e, Error::Limit(_)), "{e}");
// Measured with `pixelsOmitted` false and a comma before it.
let mut o = encode_read_only(&d, d.node(linked).unwrap()).unwrap();
o["pixelsOmitted"] = json!(false);
let size = o.to_string().len() + 1;
assert_eq!(e.to_string(), format!("plug-in exceeded its memory budget (object {} needs {size} bytes of input, 4096 left)", linked.0));
// A filter that doesn't list its kind is given the rest.
let (bytes, read_only) = encode_input_with(&d, &order, &[InputKind::SymbolInstance], fits.len() + 4096).unwrap();
assert_eq!((bytes, read_only.len()), (fits, 1));
}
/// A path or compound path that doesn't fit what is left of the input fails the run with the limit
/// the runtime gives an input past its share, before anything after it is written; the exact size
/// still fits.
#[test]
fn a_path_past_the_input_capacity_is_the_runtime_limit() {
let (d, ids, _, [text, ..]) = mixed();
let (bytes, _) = encode_input_with(&d, &ids, &[], usize::MAX).unwrap();
assert_eq!(encode_input_with(&d, &ids, &[], bytes.len()).unwrap().0, bytes, "the exact size fits");
let e = encode_input_with(&d, &[ids[0], ids[1], ids[2], text], &[InputKind::Type], bytes.len() - 1).unwrap_err();
assert_eq!(e.to_string(), "plug-in exceeded its memory budget (the selection is too large to hand over)");
}
/// An output lists at most [`MAX_OBJECTS`] objects and must return every read-only one, so an input
/// with more read-only objects than that is a limit; exactly that many are given.
#[test]
fn more_read_only_objects_than_an_output_lists_is_a_limit() {
let (mut d, _, layer, [.., ghost]) = mixed();
let mut ids = vec![ghost];
for _ in 1..MAX_OBJECTS {
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
}
let (_, read_only) = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap();
assert_eq!(read_only.len(), MAX_OBJECTS);
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
let e = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap_err();
assert_eq!(e.to_string(), format!("plug-in exceeded its object budget (more than {MAX_OBJECTS} read-only objects)"));
// A filter that doesn't list the kind is given none of them.
assert!(encode_input_with(&d, &ids, &[], usize::MAX).unwrap().1.is_empty());
}
/// Objects that clip a clip group are left out, and so are those inside a group that clips one;
/// those it clips are given. This covers a filter that lists no `inputs` too.
#[test]
fn objects_that_clip_a_clip_group_are_not_inputs() {
let (mut d, _, layer, [text, embedded, ..]) = mixed();
let (t, e) = (Arc::new(d.node(text).unwrap().clone()), Arc::new(d.node(embedded).unwrap().clone()));
let mut copy = |n: &Arc<Node>| {
let mut c = (**n).clone();
c.id = d.alloc_id();
Arc::new(c)
};
let (t_source, e_source, t_inner, e_inner, t_clipped) = (copy(&t), copy(&e), copy(&t), copy(&e), copy(&t));
let inner = Node::group(d.alloc_id(), vec![t_inner.clone(), e_inner.clone()]);
let mut by_group = Node::group(d.alloc_id(), vec![Arc::new(inner), t_clipped.clone()]);
by_group.set_clips(true);
let mut by_type = Node::group(d.alloc_id(), vec![t_source.clone(), e_source.clone()]);
by_type.set_clips(true);
let by_group = d.insert(Some(layer), usize::MAX, by_group).unwrap();
let by_type = d.insert(Some(layer), usize::MAX, by_type).unwrap();
let path = |d: &mut Document, x: f64| Arc::new(Node::path(d.alloc_id(), square(x), Appearance::default()));
let (part, clipped_by_compound, in_source, clipped_by_paths) = (path(&mut d, 0.0), path(&mut d, 10.0), path(&mut d, 20.0), path(&mut d, 30.0));
let compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![part], rule: FillRule::NonZero });
let mut by_compound = Node::group(d.alloc_id(), vec![Arc::new(compound), clipped_by_compound.clone()]);
by_compound.set_clips(true);
let source = Node::group(d.alloc_id(), vec![in_source]);
let mut by_paths = Node::group(d.alloc_id(), vec![Arc::new(source), clipped_by_paths.clone()]);
by_paths.set_clips(true);
let by_compound = d.insert(Some(layer), usize::MAX, by_compound).unwrap();
let by_paths = d.insert(Some(layer), usize::MAX, by_paths).unwrap();
let ids = |list: &[NodeId]| filter_targets_with(&d, list, &InputKind::ALL);
assert_eq!(ids(&[by_type]), [e_source.id], "the type clips; the image is clipped");
assert_eq!(ids(&[by_group]), [t_clipped.id], "the group's type and image clip");
assert_eq!(ids(&[by_compound]), [clipped_by_compound.id], "the compound path clips");
assert_eq!(ids(&[by_paths]), [clipped_by_paths.id], "the group's path clips");
assert_eq!(filter_targets(&d, &[by_compound]), [clipped_by_compound.id], "a filter without inputs");
assert_eq!(filter_targets(&d, &[by_paths]), [clipped_by_paths.id], "a filter without inputs");
}
/// A TIFF with more strips and planes than the decoder can count comes as `"unknown"`, without its
/// pixels, and never reaches the decoder.
#[test]
fn a_tiff_too_large_to_count_is_unknown_and_has_no_pixels() {
let entries: [(u16, u16, u32); 9] =
[(256, 4, 1), (257, 4, 1 << 31 | 1), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
tiff.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
for (tag, ty, v) in entries {
tiff.extend(tag.to_le_bytes());
tiff.extend(ty.to_le_bytes());
tiff.extend(1u32.to_le_bytes());
tiff.extend(v.to_le_bytes());
}
tiff.extend([0; 4]);
let (mut d, _, layer, _) = mixed();
d.images.insert("big".into(), ImageBlob::new("image/tiff", tiff));
let big = image_node(&mut d, "big", (1, 1), None);
let big = d.insert(Some(layer), usize::MAX, big).unwrap();
let (bytes, _) = encode_input_with(&d, &[big], &[InputKind::Image], usize::MAX).unwrap();
let input: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(input["objects"][0]["colorMode"], "unknown");
assert_eq!(input["objects"][0]["pixelsOmitted"], true);
}
/// A field a plug-in adds to a read-only object is named in the refusal with its control and
/// direction characters escaped, and cut short.
#[test]
fn a_changed_field_is_named_escaped_and_short() {
let (d, _, layer, [text, ..]) = mixed();
let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]);
let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type], usize::MAX).unwrap();
let mut v: Value = serde_json::from_slice(&bytes).unwrap();
let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap();
let key = format!("\u{202E}{}", "k".repeat(500));
v["objects"][at][key.as_str()] = json!(1);
let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string();
let shown = e.split('\'').nth(1).unwrap_or_default();
assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}");
assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}");
}
/// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by
/// value), and they stay as they are, new objects listed after one going just above it. Any change,
/// an object left out or listed twice, and a new object of such a kind are refused before anything
/// changes.
#[test]
fn read_only_kind_field_and_omission_refuse() {
let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed();
let targets = filter_targets_with(&d, &[layer], &InputKind::ALL);
let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL, usize::MAX).unwrap();
assert_eq!(read_only.len(), 5);
let input: Value = serde_json::from_slice(&bytes).unwrap();
let before = d.clone();
let out = decode_output_with(&bytes, &read_only).unwrap();
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
apply_output(&mut d, &targets, out.objects, None, &keep).unwrap();
assert_eq!(d, before, "an exact echo changes nothing");
// A filter that doesn't list `inputs` could never hand them back.
assert!(decode_output(&bytes).is_err());
// Numbers written another way are the same numbers.
let mut echo = input.clone();
echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]);
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok());
// So is an id written as a float; a fractional one names no object.
let id = input["objects"][3]["id"].as_u64().unwrap();
let mut echo = input.clone();
echo["objects"][3]["id"] = json!(id as f64);
let out = decode_output_with(echo.to_string().as_bytes(), &read_only).unwrap();
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
echo["objects"][3]["id"] = json!(id as f64 + 0.5);
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_err());
// `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type.
let ro = |id: NodeId| json!({"id": id.0});
let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]});
let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap();
assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]);
assert_eq!(made.len(), 6);
assert!(ids.iter().all(|id| d.node(*id).is_none()));
assert_eq!(d.node(text), before.node(text), "the type is as it was");
// Refused, naming the object and the field.
let t = text.0;
let changed = |f: &dyn Fn(&mut Value)| {
let mut v = input.clone();
f(&mut v);
v
};
for (out, says) in [
(
changed(&|v| v["objects"][3]["text"]["runs"][0]["text"] = json!("Bye")),
format!("objects[3] (id {t}): read-only field 'text.runs[0].text' changed"),
),
(changed(&|v| v["objects"][3]["type"] = json!("path")), format!("(id {t}): read-only field 'type' changed")),
(changed(&|v| v["objects"][3]["opacity"] = json!(0.5)), format!("(id {t}): read-only field 'opacity' changed")),
(changed(&|v| v["objects"][3]["transform"][4] = json!(6)), format!("(id {t}): read-only field 'transform[4]' changed")),
(
changed(&|v| {
v["objects"][4].as_object_mut().unwrap().remove("pixelsBase64");
}),
format!("(id {}): read-only field 'pixelsBase64' changed", embedded.0),
),
(changed(&|v| v["objects"][6]["symbolId"] = json!("Moon")), format!("(id {}): read-only field 'symbolId' changed", star.0)),
(
changed(&|v| {
v["objects"].as_array_mut().unwrap().remove(3);
}),
format!("objects (id {t}): a read-only object was left out"),
),
(changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"id": ghost.0}))), format!("objects[8] (id {}): is listed twice", ghost.0)),
(
changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"type": "type", "path": square(0.0)}))),
"`type` must be \"path\" or \"compound\"".to_string(),
),
] {
let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string();
assert!(e.contains(&says), "{says}: {e}");
}
// Writing one or leaving one out is refused when applying too, whatever decoded the output.
let mut d = before.clone();
let write = vec![OutObject { id: Some(text), name: Some("Renamed".into()), ..OutObject::default() }];
let e = apply_output(&mut d, &[text], write, None, &keep).unwrap_err().to_string();
assert!(e.contains("is read-only"), "{e}");
let e = apply_output(&mut d, &[text], vec![], None, &keep).unwrap_err().to_string();
assert!(e.contains("a read-only object was left out"), "{e}");
assert_eq!(d, before);
}
/// A filter's `documentData`: absent keeps what the plug-in keeps, `null` forgets it, any other JSON
/// of at most [`MAX_DOCUMENT_DATA`] bytes is kept, and more refuses the whole output.
#[test]
+3
View File
@@ -22,6 +22,9 @@ pub const API_LEVEL: u32 = 2;
/// `settings` and `cache` permissions they need.
pub const PROGRESS_API: u32 = 2;
/// The API level that adds a filter's `inputs` ([`crate::InputKind`]).
pub const INPUTS_API: u32 = 2;
/// Most permissions a manifest may list.
pub const MAX_PERMISSIONS: usize = 16;
+4 -1
View File
@@ -32,6 +32,9 @@ pub const HEAVY_FUEL: u64 = 1_000_000_000;
/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds).
pub const HEAVY_WALL_TIME_MS: u64 = 300_000;
/// The limit a run reports when its input and parameters together are past half its memory.
pub(crate) const SELECTION_TOO_LARGE: &str = "memory budget (the selection is too large to hand over)";
/// A heavy run's budget as people read it: (minutes it may take, times the instructions a light
/// run gets).
pub fn heavy_terms() -> (u64, u64) {
@@ -311,7 +314,7 @@ impl Plugin {
fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result<Vec<u8>> {
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));