mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 16:33:22 -06:00
Merge main into fix/plugin-stale-grant
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user