//! The object model plug-ins read and write: paths and compound paths as JSON. //! //! The host hands a plug-in `{"objects": [...]}` (see [`encode`]) and reads the same shape back //! ([`decode`]). Geometry and paint use the native file format's forms (`PathData`, `Paint`), so //! nothing is lost on the way through; everything a plug-in returns is validated (finite, //! in-range coordinates, capped counts, clamped colours) before it reaches the document. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, }; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; 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; /// Most objects one output may hold. pub const MAX_OBJECTS: usize = 100_000; /// Most anchor points one output may hold, over all objects. pub const MAX_ANCHORS: usize = 4_000_000; /// Most fills or strokes per object, gradient stops per gradient and freeform points or lines /// per freeform gradient. pub const MAX_ITEMS: usize = 256; /// Thickest stroke a plug-in may set, in points. pub const MAX_STROKE_WIDTH: f64 = 1000.0; /// The range of a stroke's miter limit (as the Stroke panel's). pub const MITER_LIMIT: std::ops::RangeInclusive = 1.0..=500.0; /// Longest effect id a plug-in may return, in bytes. 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). #[derive(Clone, Debug, Default, PartialEq)] pub struct OutObject { /// The input object this updates; `None`: a new object. pub id: Option, /// `"compound"` (true) or `"path"` (false). pub compound: Option, /// `Some("")` clears the name. pub name: Option, pub path: Option, pub rule: Option, /// Fill paints in paint order (bottom first). pub fills: Option>, pub strokes: Option>, pub opacity: Option, /// The object's effects, in order, replacing its own (not its fills' or strokes'). The host /// admits them before they reach the document ([`AdmitEffects`]). pub effects: Option>, /// Makes the object a guide (`true`, as View › Guides › Make Guides does) or a plain path. pub guide: Option, /// Locks or unlocks the object (as Object › Lock and Unlock do). pub locked: Option, /// A read-only input object (type, an image or a symbol instance) returned as it was given: /// it stays as it is, and new objects listed after it go above it. pub kept: bool, } /// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's /// default: 1 pt, butt cap, miter join, miter limit 10). #[derive(Clone, Debug, PartialEq)] pub struct OutStroke { pub paint: Paint, pub width: Option, pub join: Option, pub cap: Option, pub miter_limit: Option, } impl OutStroke { /// A stroke of `paint` that keeps everything else. pub fn paint(paint: Paint) -> Self { Self { paint, width: None, join: None, cap: None, miter_limit: None } } } /// How the host admits the effect records a filter returns for object `n` (written with the /// output, its effects still its own): the records to give it, or why they are refused. The /// plug-in crate checks their form; which effects exist and what parameters they take is the /// host's to say. pub type AdmitEffects<'a> = &'a dyn Fn(&[Effect], &Node) -> std::result::Result, String>; /// The name plug-ins use for line join `j`, as SVG's `stroke-linejoin`. fn join_name(j: LineJoin) -> &'static str { match j { LineJoin::Miter => "miter", LineJoin::Round => "round", LineJoin::Bevel => "bevel", } } /// The name plug-ins use for line cap `c`, as SVG's `stroke-linecap`. fn cap_name(c: LineCap) -> &'static str { match c { LineCap::Butt => "butt", LineCap::Round => "round", LineCap::Square => "square", } } fn rule_name(r: FillRule) -> &'static str { match r { FillRule::NonZero => "nonzero", FillRule::EvenOdd => "evenodd", } } fn rect_json(r: Option) -> Value { r.map_or(Value::Null, |r| json!([r.x0, r.y0, r.x1, r.y1])) } fn paint_json(p: &Paint) -> Value { match p { Paint::None => Value::Null, p => serde_json::to_value(p).unwrap_or(Value::Null), } } /// The geometry of a path or compound path (a compound's members' subpaths in order) and its fill /// rule. `None` for other objects. pub fn geometry(n: &Node) -> Option<(PathData, FillRule, bool)> { match &n.kind { NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule, false)), NodeKind::Compound { children, rule } => { Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule, true)) } _ => None, } } /// The fills and strokes of appearance `ap` as plug-ins see them, each in paint order: a fill is /// its paint, a stroke `{paint, width, join, cap, miterLimit}`. Filters and live effects are given /// the same. pub(crate) fn paint_lists(ap: &Appearance) -> (Vec, Vec) { let fills = ap.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect(); let stroke = |s: &StrokeLayer| json!({"paint": paint_json(&s.paint), "width": s.width, "join": join_name(s.join), "cap": cap_name(s.cap), "miterLimit": s.miter_limit}); let strokes = ap.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(stroke(s)) } else { None }).collect(); (fills, strokes) } /// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path, /// fillRule, fills, strokes, effects, opacity, bounds}`. pub fn encode(n: &Node) -> Option { let (path, rule, compound) = geometry(n)?; let (fills, strokes) = paint_lists(&n.appearance); let mut o = json!({ "id": n.id.0, "type": if compound { "compound" } else { "path" }, "path": path, "fillRule": rule_name(rule), "fills": fills, "strokes": strokes, "effects": n.appearance.effects, "opacity": n.opacity, "bounds": rect_json(path.bounds()), }); if let Some(name) = &n.name { o["name"] = json!(name); } Some(o) } /// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths; /// others are left out). pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { encode_input_with(doc, ids, &[], 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`), and a read-only object whose other fields alone don't fit is an /// [`Error::Limit`] naming it and its size. Also returns those objects by id, which the filter /// must return as they were ([`decode_output_with`]). pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec, HashMap)> { encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL }) } /// The input with no objects; each object adds its JSON and a comma. const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}"; /// What an input's objects may still be given of its totals, as they are encoded in order: /// characters of base64 for pixels, bytes of JSON for runs and paragraphs, and bytes of the whole /// input. #[derive(Clone, Copy, Debug)] struct Allowance { pixels: usize, text: usize, input: usize, } impl Allowance { const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX }; } /// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map /// it returns, and written into the input from there. fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec, HashMap)> { let mut read_only = HashMap::new(); let symbols = doc.symbol_boxes(); let mut out = b"{\"objects\":[".to_vec(); let mut first = true; for n in ids.iter().filter_map(|id| doc.node(*id)) { let path; let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) { 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; if checked && size > left.input { return Err(too_large(n, size, left.input)); } left.input = left.input.saturating_sub(size); } out.extend_from_slice(b"]}"); Ok((out, read_only)) } /// The error for read-only object `n`, which needs `size` bytes of input where `left` are left. fn too_large(n: &Node, size: usize, left: usize) -> Error { Error::Limit(format!("memory budget (object {} needs {size} bytes of input, {left} left)", n.id.0)) } /// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). pub fn input_kind(n: &Node) -> Option { match &n.kind { NodeKind::Text(_) => Some(InputKind::Type), NodeKind::Image(_) => Some(InputKind::Image), NodeKind::SymbolInstance { .. } => Some(InputKind::SymbolInstance), _ => None, } } fn affine_json(xf: Affine) -> Value { json!(xf.as_coeffs()) } /// Type, an image or a symbol instance of `doc` as a filter that asks for its kind reads it (`None` /// for other objects). Each has `{id, type, name?, bounds, transform}`, `transform` mapping its own /// space (text space, pixel space, the symbol's art) into the document, and: /// - type (`"type"`): `text: {kind: "point"|"area"|"path", vertical, runs: [{text, style, inline?}], /// paragraphs: [style]}` with the native character and paragraph attributes, one paragraph style /// for each paragraph, and `truncated`: true, with no runs or paragraphs, past [`MAX_INPUT_TEXT`] /// or [`MAX_INPUT_RUNS`]; /// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`, /// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`] /// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top. A linked image's file /// and path are never read or given; /// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when /// the document has no such symbol, and `overrides: {}`. pub fn encode_read_only(doc: &Document, n: &Node) -> Option { let mut left = Allowance::FULL; encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left).ok().flatten() } /// The bytes the `pixelsBase64` key adds to an image's JSON besides the base64 itself. const PIXELS_KEY: usize = ",\"pixelsBase64\":\"\"".len(); /// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the /// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that /// don't fit what is left of their totals or of the input are omitted (`pixelsOmitted`, /// `truncated`), and an image's size is checked against it before anything is decoded. The object's /// other fields are measured first, with a comma before it: when they alone don't fit what is left /// of the input, it is an [`Error::Limit`] naming the object and that size. fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Result> { let Some(kind) = input_kind(n) else { return Ok(None) }; let mut o = json!({"id": n.id.0, "type": kind.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); // Each flag is measured as `false`, its longer value. match &n.kind { NodeKind::Text(t) => { o["transform"] = affine_json(t.xf); o["text"] = text_json(t, None).0; o["truncated"] = json!(false); } NodeKind::Image(im) => { o["transform"] = affine_json(im.xf); o["pixelSize"] = json!([im.width, im.height]); o["colorMode"] = json!(doc.image_blob(im).map_or("unknown", color_mode)); o["linked"] = json!(im.link.is_some()); o["pixelsOmitted"] = json!(false); } NodeKind::SymbolInstance { symbol, xf } => { o["transform"] = affine_json(*xf); o["symbolId"] = json!(symbol); if !doc.symbols.iter().any(|s| s.name == *symbol) { o["missing"] = json!(true); } o["overrides"] = json!({}); } _ => return Ok(None), } if let Some(name) = &n.name { o["name"] = json!(name); } let fields = serde_json::to_vec(&o).map_or(usize::MAX, |b| b.len()).saturating_add(1); if fields > left.input { return Err(too_large(n, fields, left.input)); } let room = left.input - fields; match &n.kind { NodeKind::Text(t) => { let mut budget = left.text.min(room); let (text, truncated) = text_json(t, Some(&mut budget)); left.text -= left.text.min(room) - budget; o["text"] = text; o["truncated"] = json!(truncated); } NodeKind::Image(im) => { let blob = doc.image_blob(im); let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY)); // A linked image's pixels are its file's: never given (nor its path). let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max)); o["pixelsOmitted"] = json!(pixels.is_none()); if let Some(p) = pixels { let b64 = craft_vectorcraft_format::base64::encode(&p); left.pixels = left.pixels.saturating_sub(b64.len()); o["pixelsBase64"] = json!(b64); } } _ => {} } Ok(Some(o)) } /// The text of type `t` as a filter reads it, and whether it was too long to give /// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives /// is taken from, with the commas between them). With `left` `None`, its kind and direction with no /// runs or paragraphs. fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) { let kind = match t.kind { TextKind::Point => "point", TextKind::Area { .. } => "area", TextKind::OnPath { .. } => "path", }; let (mut runs, mut paras): (Vec, Vec) = (Vec::new(), Vec::new()); let mut truncated = false; if let Some(left) = left { let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); let paragraphs = t.paragraph_count(); // The text alone is never more bytes than its JSON, so type that can't fit isn't built. truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; if !truncated { let value = |v: std::result::Result| v.unwrap_or(Value::Null); runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); let commas = runs.len().saturating_sub(1) + paras.len().saturating_sub(1); let size = runs.iter().chain(¶s).fold(commas, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); match left.checked_sub(size) { Some(rest) => *left = rest, None => { (runs, paras, truncated) = (Vec::new(), Vec::new(), true); } } } } (json!({"kind": kind, "vertical": t.vertical, "runs": runs, "paragraphs": paras}), truncated) } /// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or /// `"unknown"` when it can't be read or only a linked image's preview is there. fn color_mode(b: &ImageBlob) -> &'static str { if b.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &b.bytes) || **p == *b.bytes) { return "unknown"; } if b.is_cmyk() { return "cmyk"; } if b.gray_source().is_some() { return "gray"; } let readable = image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); if readable.is_some() { "rgb" } else { "unknown" } } /// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max` /// characters of base64; the size is checked, and read from the header, before anything is decoded. fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option> { let bytes = u64::from(w) * u64::from(h) * 4; if bytes == 0 || bytes.div_ceil(3) * 4 > max as u64 { return None; } let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); if reader()?.into_dimensions().ok()? != (w, h) { return None; } let mut r = reader()?; let mut limits = image::Limits::default(); // The decoder's own buffers besides the RGBA it gives. limits.max_alloc = Some(64 << 20); r.limits(limits); let img = r.decode().ok()?.into_rgba8(); (img.dimensions() == (w, h)).then(|| img.into_raw()) } fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { Error::Output(format!("{at}: {msg}")) } fn check_point(p: Point, at: &str) -> Result<()> { if p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD { Ok(()) } else { Err(out_err(at, format!("coordinate out of range (limit ±{MAX_COORD} pt)"))) } } /// A path from plug-in output: native `PathData` JSON, every coordinate finite and in range, empty /// subpaths dropped; `anchors` counts towards [`MAX_ANCHORS`]. fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result { let mut path: PathData = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; path.subpaths.retain(|sp| !sp.anchors.is_empty()); *anchors = anchors.saturating_add(path.anchor_count()); if *anchors > MAX_ANCHORS { return Err(out_err(at, format!("too many anchor points (limit {MAX_ANCHORS})"))); } for (_, _, a) in path.anchors() { for p in [a.p, a.h_in, a.h_out] { check_point(p, at)?; } } if path.is_empty() { return Err(out_err(at, "the path has no anchor points")); } Ok(path) } fn unit(v: f32) -> f32 { v.clamp(0.0, 1.0) } /// `c` with its components in range. pub(crate) fn clamp_color(c: Color) -> Color { match c { Color::Rgb { r, g, b } => Color::Rgb { r: unit(r), g: unit(g), b: unit(b) }, Color::Cmyk { c, m, y, k } => Color::Cmyk { c: unit(c), m: unit(m), y: unit(y), k: unit(k) }, Color::Gray { k } => Color::Gray { k: unit(k) }, Color::Lab { l, a, b } => Color::Lab { l: l.clamp(0.0, 100.0), a: a.clamp(-128.0, 127.0), b: b.clamp(-128.0, 127.0) }, } } /// A paint from plug-in output (`null`: none), with colours, opacities and offsets clamped and /// gradient and pattern placements checked. fn decode_paint(v: &Value, at: &str) -> Result { if v.is_null() { return Ok(Paint::None); } let mut p: Paint = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; match &mut p { Paint::None => {} Paint::Solid { color, tint, .. } => { *color = clamp_color(*color); *tint = unit(*tint); } Paint::Gradient(g) => { let stops = &mut g.gradient.stops; if stops.is_empty() || stops.len() > MAX_ITEMS { return Err(out_err(at, format!("a gradient needs 1-{MAX_ITEMS} stops"))); } for s in stops.iter_mut() { s.offset = unit(s.offset); s.opacity = unit(s.opacity); s.midpoint = s.midpoint.clamp(0.13, 0.87); s.tint = unit(s.tint); s.color = clamp_color(s.color); } stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); g.angle = g.angle.rem_euclid(360.0); if let Some(geom) = &mut g.geom { check_point(geom.start, at)?; check_point(geom.end, at)?; if let Some(f) = geom.focal { check_point(f, at)?; } geom.aspect = if geom.aspect.is_finite() { geom.aspect.clamp(0.01, 100.0) } else { 1.0 }; } if let Some(ff) = &mut g.freeform { if ff.points.len() > MAX_ITEMS || ff.lines.len() > MAX_ITEMS || ff.lines.iter().any(|l| l.len() > MAX_ITEMS) { return Err(out_err(at, format!("a freeform gradient holds at most {MAX_ITEMS} points and lines"))); } let n = ff.points.len(); if ff.lines.iter().flatten().any(|i| *i >= n) { return Err(out_err(at, "a freeform gradient line names a point it doesn't have")); } for pt in &mut ff.points { check_point(pt.at, at)?; pt.color = clamp_color(pt.color); pt.opacity = unit(pt.opacity); pt.spread = unit(pt.spread); } } } Paint::Pattern { xf, .. } => { if !xf.as_coeffs().iter().all(|c| c.is_finite() && c.abs() <= MAX_COORD) { return Err(out_err(at, "pattern transform out of range")); } } } Ok(p) } fn opt<'a>(o: &'a serde_json::Map, key: &str) -> Option<&'a Value> { o.get(key).filter(|v| !v.is_null()) } /// Object `v` of a plug-in's output (`at` names it in errors), its anchors added to `anchors` and /// held to [`MAX_ANCHORS`]: each key it has checked, numbers clamped into range, as [`decode`] reads /// them. fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result { let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?; let mut out = OutObject::default(); if let Some(id) = opt(o, "id") { out.id = Some(NodeId(id.as_u64().ok_or_else(|| out_err(at, "`id` must be the id of an input object"))?)); } if let Some(t) = opt(o, "type") { out.compound = Some(match t.as_str() { Some("path") => false, Some("compound") => true, _ => return Err(out_err(at, "`type` must be \"path\" or \"compound\"")), }); } if let Some(n) = o.get("name") { let name = match n { Value::Null => String::new(), n => n.as_str().ok_or_else(|| out_err(at, "`name` must be a string"))?.chars().filter(|c| !c.is_control()).take(256).collect(), }; out.name = Some(name); } if let Some(p) = opt(o, "path") { out.path = Some(decode_path(p, &format!("{at}.path"), anchors)?); } if let Some(r) = opt(o, "fillRule") { out.rule = Some(match r.as_str().map(str::to_ascii_lowercase).as_deref() { Some("nonzero") => FillRule::NonZero, Some("evenodd") => FillRule::EvenOdd, _ => return Err(out_err(at, "`fillRule` must be \"nonzero\" or \"evenodd\"")), }); } let list = |key: &str| -> Result>> { match opt(o, key) { None => Ok(None), Some(Value::Array(a)) if a.len() <= MAX_ITEMS => Ok(Some(a)), Some(_) => Err(out_err(at, format!("`{key}` must be an array of at most {MAX_ITEMS} entries"))), } }; if let Some(fills) = list("fills")? { out.fills = Some(fills.iter().enumerate().map(|(i, f)| decode_paint(f, &format!("{at}.fills[{i}]"))).collect::>()?); } if let Some(strokes) = list("strokes")? { out.strokes = Some(strokes.iter().enumerate().map(|(i, s)| decode_stroke(s, &format!("{at}.strokes[{i}]"))).collect::>()?); } if let Some(effects) = list("effects")? { out.effects = Some(effects.iter().enumerate().map(|(i, e)| decode_effect(e, &format!("{at}.effects[{i}]"))).collect::>()?); } if let Some(op) = opt(o, "opacity") { out.opacity = Some(unit(op.as_f64().ok_or_else(|| out_err(at, "`opacity` must be a number"))? as f32)); } let flag = |key: &str| -> Result> { opt(o, key).map(|v| v.as_bool().ok_or_else(|| out_err(at, format!("`{key}` must be true or false")))).transpose() }; out.guide = flag("guide")?; out.locked = flag("locked")?; Ok(out) } /// A stroke from plug-in output: `{paint, width?, join?, cap?, miterLimit?}`, its weight clamped /// to 0–[`MAX_STROKE_WIDTH`] and its miter limit into [`MITER_LIMIT`]. fn decode_stroke(v: &Value, at: &str) -> Result { let s = v.as_object().ok_or_else(|| out_err(at, "a stroke must be {\"paint\", \"width\"?, \"join\"?, \"cap\"?, \"miterLimit\"?}"))?; let number = |key: &str, range: std::ops::RangeInclusive| -> Result> { let Some(v) = opt(s, key) else { return Ok(None) }; let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| out_err(at, format!("`{key}` must be a number")))?; Ok(Some(x.clamp(*range.start(), *range.end()))) }; let join = match opt(s, "join").map(|j| j.as_str()) { None => None, Some(Some("miter")) => Some(LineJoin::Miter), Some(Some("round")) => Some(LineJoin::Round), Some(Some("bevel")) => Some(LineJoin::Bevel), Some(_) => return Err(out_err(at, "`join` must be \"miter\", \"round\" or \"bevel\"")), }; let cap = match opt(s, "cap").map(|c| c.as_str()) { None => None, Some(Some("butt")) => Some(LineCap::Butt), Some(Some("round")) => Some(LineCap::Round), Some(Some("square")) => Some(LineCap::Square), Some(_) => return Err(out_err(at, "`cap` must be \"butt\", \"round\" or \"square\"")), }; Ok(OutStroke { paint: decode_paint(s.get("paint").unwrap_or(&Value::Null), at)?, width: number("width", 0.0..=MAX_STROKE_WIDTH)?, join, cap, miter_limit: number("miterLimit", MITER_LIMIT)?, }) } /// An effect record from plug-in output: `{id, params?, visible?}`, in form only (the host says /// whether the effect exists and takes the parameters). `params` stays as given, so a record /// handed back unchanged equals the object's own. fn decode_effect(v: &Value, at: &str) -> Result { let e = v.as_object().ok_or_else(|| out_err(at, "an effect must be {\"id\", \"params\"?, \"visible\"?}"))?; let id = e.get("id").and_then(Value::as_str).filter(|id| (1..=MAX_EFFECT_ID).contains(&id.len()) && !id.chars().any(char::is_control)); let id = id.ok_or_else(|| out_err(at, format!("`id` must be an effect id of 1-{MAX_EFFECT_ID} bytes")))?; let params = match e.get("params") { None => Value::Null, Some(p @ (Value::Null | Value::Object(_))) => p.clone(), Some(_) => return Err(out_err(at, "`params` must be an object")), }; let visible = match e.get("visible") { None | Some(Value::Null) => true, Some(Value::Bool(b)) => *b, Some(_) => return Err(out_err(at, "`visible` must be true or false")), }; Ok(Effect { id: id.to_string(), params, visible }) } /// What a filter returned: its objects, and what it keeps with the document. #[derive(Clone, Debug, Default, PartialEq)] pub struct Output { pub objects: Vec, /// `documentData`, as `Document::set_plugin_data` takes it: `None` when the output has none /// (what the plug-in keeps stays as it is), `Some(None)` for `null` (it forgets it), else what /// it keeps from now on, at most [`MAX_DOCUMENT_DATA`] bytes of JSON. pub document_data: Option>, } /// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}` /// when the plug-in declined (the message reaches the user). pub fn decode_output(bytes: &[u8]) -> Result { decode_output_with(bytes, &HashMap::new()) } /// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list /// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by /// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left /// out, is refused naming the object and the first field that differs, before anything changes. pub fn decode_output_with(bytes: &[u8], read_only: &HashMap) -> Result { let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; let objects = decode_objects(&v, read_only)?; let document_data = match v.get_mut("documentData").map(Value::take) { None => None, Some(Value::Null) => Some(None), Some(data) => { let size = serde_json::to_vec(&data).map_or(usize::MAX, |b| b.len()); if size > MAX_DOCUMENT_DATA { return Err(Error::Output(format!("`documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA})"))); } Some(Some(data)) } }; Ok(Output { objects, document_data }) } /// Parses a plug-in's objects: `{"objects": [...]}`, or `{"error": "message"}` when the plug-in /// declined (the message reaches the user). A live effect's other keys don't matter. pub fn decode(bytes: &[u8]) -> Result> { let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; decode_objects(&v, &HashMap::new()) } /// The objects of output `v`, or its decline; entries for `read_only` objects as /// [`decode_output_with`] reads them. fn decode_objects(v: &Value, read_only: &HashMap) -> Result> { if let Some(e) = declined(v) { return Err(e); } let objects = v.get("objects").and_then(Value::as_array).ok_or_else(|| Error::Output("expected {\"objects\": [...]}".into()))?; if objects.len() > MAX_OBJECTS { return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len()))); } let mut anchors = 0; let mut returned: HashSet = HashSet::new(); let out = objects .iter() .enumerate() .map(|(i, o)| { let id = o.get("id").and_then(Value::as_u64).map(NodeId); match id.and_then(|id| Some((id, read_only.get(&id)?))) { Some((id, given)) => { let at = format!("objects[{i}] (id {})", id.0); if !returned.insert(id) { return Err(out_err(&at, "is listed twice")); } check_unchanged(o, given, &at)?; Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() }) } None => decode_object(o, &format!("objects[{i}]"), &mut anchors), } }) .collect::>>()?; let mut missing: Vec = read_only.keys().filter(|id| !returned.contains(id)).copied().collect(); missing.sort(); if let Some(id) = missing.first() { return Err(out_err( &format!("objects (id {})", id.0), "a read-only object was left out: return it, as {\"id\": …} alone or as it was given", )); } Ok(out) } /// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone, /// or `given` itself, numbers compared by value. Otherwise the error names the first field that /// differs (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as /// [`crate::permissions::display_text`] shows a manifest string, since the entry's keys are the /// plug-in's. fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> { if o.as_object().is_some_and(|m| m.len() == 1) { return Ok(()); } let changed = match (o, given) { (Value::Object(x), Value::Object(y)) => { let mut keys: Vec<&String> = x.keys().collect(); keys.sort(); keys.into_iter().find_map(|k| match y.get(k) { Some(q) => first_difference(&x[k], q, k), None => Some(k.clone()), }) } _ => None, }; match changed.or_else(|| first_difference(o, given, "")) { None => Ok(()), Some(field) => { let field = crate::permissions::display_text(&field, crate::permissions::MAX_SHOWN_FIELD); Err(out_err(at, format!("read-only field '{field}' changed"))) } } } /// The path of the first place `a` and `b` differ (`""`: they differ as a whole), `None` when they /// are the same JSON with numbers compared by value. fn first_difference(a: &Value, b: &Value, at: &str) -> Option { let key = |k: &str| if at.is_empty() { k.to_string() } else { format!("{at}.{k}") }; match (a, b) { (Value::Number(x), Value::Number(y)) => (x.as_f64() != y.as_f64()).then(|| at.into()), (Value::Object(x), Value::Object(y)) => { let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect(); keys.sort(); keys.dedup(); keys.into_iter().find_map(|k| match (x.get(k), y.get(k)) { (Some(p), Some(q)) => first_difference(p, q, &key(k)), _ => Some(key(k)), }) } (Value::Array(x), Value::Array(y)) => { if x.len() != y.len() { return Some(at.into()); } x.iter().zip(y).enumerate().find_map(|(i, (p, q))| first_difference(p, q, &format!("{at}[{i}]"))) } (a, b) => (a != b).then(|| at.into()), } } /// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it, /// without control characters and at most 300 of them. pub(crate) fn declined(v: &Value) -> Option { let e = v.get("error").filter(|e| !e.is_null())?; Some(Error::Failed(printable(e, 300))) } /// A plug-in's text the user reads: `v` as a string (any other JSON as its text), without control /// characters, at most `max` characters of it. pub(crate) fn printable(v: &Value, max: usize) -> String { let s = v.as_str().map_or_else(|| v.to_string(), str::to_string); s.chars().filter(|c| !c.is_control()).take(max).collect() } /// The paints of `ap`'s fills (`fill`) or strokes, in paint order. fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> { ap.items.iter().filter(|i| i.is_fill() == fill).map(AppearanceItem::paint).collect() } /// `p` without swatch links: a plug-in's colours are its own, so only paints it returned /// unchanged keep theirs. fn unlinked(mut p: Paint) -> Paint { match &mut p { Paint::Solid { swatch, tint, .. } => { *swatch = None; *tint = 1.0; } Paint::Gradient(g) => { g.swatch = None; for s in &mut g.gradient.stops { s.swatch = None; s.tint = 1.0; } } Paint::None | Paint::Pattern { .. } => {} } p } /// `p` as it may enter `doc`: unchanged from `was` (the paint in the same slot before), or /// without swatch links; a pattern must exist in the document. fn admit_paint(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result { if let Paint::Pattern { pattern, .. } = &p && doc.pattern(pattern).is_none() { return Err(out_err(at, format!("the document has no pattern {pattern:?}"))); } Ok(if was == Some(&p) { p } else { unlinked(p) }) } /// Sets the paints of `ap`'s fills (`fill`) or strokes to `new` (a fill keeps everything but its /// paint), in paint order: extra items are removed (from the top), missing ones added above the /// last item of their kind (fills at the bottom, strokes on top when the object has none). fn set_items(ap: &mut Appearance, fill: bool, new: Vec) { let idx: Vec = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect(); for &i in idx.iter().skip(new.len()).rev() { ap.remove_item(i); } let kept = new.len().min(idx.len()); let mut at = idx.get(..kept).and_then(<[usize]>::last).map_or(if fill { 0 } else { ap.items.len() }, |i| i + 1); for (k, o) in new.into_iter().enumerate() { match idx.get(k).and_then(|&i| ap.items.get_mut(i)) { Some(AppearanceItem::Fill(f)) => f.paint = o.paint, Some(AppearanceItem::Stroke(s)) => style(s, o), None => { let item = if fill { AppearanceItem::Fill(FillLayer::new(o.paint)) } else { let mut s = StrokeLayer::new(Paint::None, 1.0); style(&mut s, o); AppearanceItem::Stroke(s) }; ap.insert_item(at, item); at += 1; } } } } /// Gives stroke `s` the paint of `o` and each of its weight, join, cap and miter limit it has. fn style(s: &mut StrokeLayer, o: OutStroke) { s.paint = o.paint; s.width = o.width.unwrap_or(s.width); s.join = o.join.unwrap_or(s.join); s.cap = o.cap.unwrap_or(s.cap); s.miter_limit = o.miter_limit.unwrap_or(s.miter_limit); } /// A compound path's members: one path per subpath of `path`, keeping the ids of `old` members. fn members(doc: &mut Document, old: &[Arc], path: PathData) -> Vec> { path.subpaths .into_iter() .enumerate() .map(|(i, sp)| { let id = old.get(i).map_or_else(|| doc.alloc_id(), |c| c.id); Arc::new(Node::path(id, PathData::single(sp), Appearance::default())) }) .collect() } /// Writes `o` into `n` (an input object, or a fresh node for a new one); its effect records once /// `admit` lets them in. fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str, admit: AdmitEffects) -> Result<()> { let (cur_path, cur_rule, cur_compound) = geometry(n).unwrap_or((PathData::default(), FillRule::NonZero, false)); let compound = o.compound.unwrap_or(cur_compound); let rule = o.rule.unwrap_or(cur_rule); match (&mut n.kind, compound) { (NodeKind::Path { path, rule: r, live, .. }, false) => { // A path handed back as it was stays the live shape it was. if let Some(p) = o.path.filter(|p| p != path) { *path = p; *live = None; } *r = rule; } (NodeKind::Compound { children, rule: r }, true) => { if let Some(p) = o.path { *children = members(doc, children, p); } *r = rule; } (NodeKind::Compound { .. }, false) => { n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false }; } (_, true) => n.kind = NodeKind::Compound { children: members(doc, &[], o.path.unwrap_or(cur_path)), rule }, (_, false) => n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false }, } for (fill, new) in [(true, o.fills.map(|f| f.into_iter().map(OutStroke::paint).collect::>())), (false, o.strokes)] { let Some(new) = new else { continue }; let was = paints(&n.appearance, fill); let admitted = new .into_iter() .enumerate() .map(|(i, s)| { let at = format!("{at}.{}[{i}]", if fill { "fills" } else { "strokes" }); Ok(OutStroke { paint: admit_paint(doc, s.paint.clone(), was.get(i).copied(), &at)?, ..s }) }) .collect::>>()?; set_items(&mut n.appearance, fill, admitted); } if let Some(op) = o.opacity { n.opacity = op; } if let Some(name) = o.name { n.name = Some(name).filter(|s| !s.trim().is_empty()); } if let Some(effects) = o.effects { n.appearance.effects = admit(&effects, n).map_err(|why| out_err(&format!("{at}.effects"), why))?; } if let Some(on) = o.guide { set_guide(n, on); } if let Some(locked) = o.locked { n.locked = locked; } Ok(()) } /// Makes path or compound path `n` a guide or a plain path, as Make Guides and Release Guides do: a /// compound path's members carry the flag. fn set_guide(n: &mut Node, on: bool) { match &mut n.kind { NodeKind::Path { guide, clipping: false, .. } => *guide = on, NodeKind::Compound { children, .. } => { for c in children.iter_mut() { set_guide(Arc::make_mut(c), on); } } _ => {} } } /// Whether `n` is a guide: a guide path, or a compound path of guide paths. fn is_guide(n: &Node) -> bool { match &n.kind { NodeKind::Path { guide, .. } => *guide, NodeKind::Compound { children, .. } => !children.is_empty() && children.iter().all(|c| is_guide(c)), _ => false, } } /// Whether a filter's result `n` is selected after the run: locked objects and guides aren't, as /// Object › Lock and Make Guides leave them deselected. pub fn selectable(n: &Node) -> bool { !n.locked && !is_guide(n) } /// 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). pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec { filter_targets_with(doc, selected, &[]) } /// [`filter_targets`] for a filter that asks for `kinds` too: type, images and symbol instances of /// those kinds join the paths in paint order, under the same rules (type that clips a clip group is /// left out, and so is anything inside a symbol, a placed document or a live object). pub fn filter_targets_with(doc: &Document, selected: &[NodeId], kinds: &[InputKind]) -> Vec { fn visit(list: &[Arc], sel: &HashSet, inside: bool, clips: bool, kinds: &[InputKind], out: &mut Vec) { for (i, n) in list.iter().enumerate() { if !n.visible || n.locked { continue; } let picked = inside || sel.contains(&n.id); match &n.kind { NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id), NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), kinds, out), _ if picked && !(clips && i == 0) && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id), _ => {} } } } let sel: HashSet = selected.iter().copied().collect(); let mut out = vec![]; visit(&doc.layers, &sel, false, false, kinds, &mut out); out } /// The tree edits of [`apply_output`], made in one pass over the document. #[derive(Default)] struct Plan { replace: HashMap, delete: HashSet, before: HashMap>, after: HashMap>, /// New objects that go on top of a container (`None`: the top-level list). top: Option<(Option, Vec)>, } impl Plan { fn pending(&self) -> bool { !(self.replace.is_empty() && self.delete.is_empty() && self.before.is_empty() && self.after.is_empty() && self.top.is_none()) } /// The edited `list` (the children of `parent`), `None` when nothing in it changed. fn list(&mut self, parent: Option, list: &[Arc]) -> Option>> { let mut out: Option>> = None; for (i, c) in list.iter().enumerate() { if !self.pending() { if let Some(o) = &mut out { o.extend(list.iter().skip(i).cloned()); } return out; } let mut emitted: Vec> = self.before.remove(&c.id).unwrap_or_default().into_iter().map(Arc::new).collect(); let mut changed = !emitted.is_empty(); if self.delete.remove(&c.id) { changed = true; } else if let Some(r) = self.replace.remove(&c.id) { emitted.push(Arc::new(r)); changed = true; } else if let Some(ch) = match &c.kind { NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => self.list(Some(c.id), children), _ => None, } { let mut m = (**c).clone(); if let Some(slot) = m.children_mut() { *slot = ch; } emitted.push(Arc::new(m)); changed = true; } else { emitted.push(c.clone()); } if let Some(a) = self.after.remove(&c.id) { emitted.extend(a.into_iter().map(Arc::new)); changed = true; } if changed && out.is_none() { out = Some(list.iter().take(i).cloned().collect()); } if let Some(o) = &mut out { o.extend(emitted); } } if self.top.as_ref().is_some_and(|(p, _)| *p == parent) && let Some((_, nodes)) = self.top.take() { out.get_or_insert_with(|| list.to_vec()).extend(nodes.into_iter().map(Arc::new)); } out } } /// Applies an object filter's output to `doc`. /// /// `inputs` are the objects the plug-in was given, in paint order ([`filter_targets`]). Output /// objects with an `id` update that input object (keys they leave out keep their values); objects /// without one are new and go just above the object listed before them, or, listed before any /// input object, just below the first one listed after them (at the first input object's place /// when the output keeps none; on top of `parent` when there were no inputs). Input objects missing /// from the output are deleted. An object's effect records replace its own once `admit` lets them /// in. Returns the resulting objects in output order (to select). pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, parent: Option, admit: AdmitEffects) -> Result> { let wanted: HashSet = inputs.iter().copied().collect(); let mut originals: HashMap = HashMap::new(); doc.walk(|n| { if wanted.contains(&n.id) { originals.insert(n.id, n.clone()); } }); let mut plan = Plan::default(); let mut result = Vec::with_capacity(out.len()); // The run of new objects since the last input object listed, and that object. let mut run: Vec = vec![]; let mut prev: Option = None; for (i, o) in out.into_iter().enumerate() { let at = format!("objects[{i}]"); match o.id { Some(id) => { let mut n = originals.remove(&id).ok_or_else(|| { 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)) })?; // 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 { Some(p) => plan.after.insert(p, nodes), None => plan.before.insert(id, nodes), }; } if !kept { plan.replace.insert(id, n); } prev = Some(id); result.push(id); } None => { if o.path.is_none() { return Err(out_err(&at, "a new object needs a `path`")); } let mut n = Node::path(doc.alloc_id(), PathData::default(), Appearance::default()); write(doc, &mut n, o, &at, admit)?; result.push(n.id); run.push(n); } } } if !run.is_empty() { match prev.or(inputs.first().copied()) { Some(p) if prev.is_some() => { plan.after.insert(p, run); } Some(first) => { plan.before.insert(first, run); } None => plan.top = Some((parent, run)), } } // 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; } if plan.pending() { return Err(Error::Output("the objects to change are no longer in the document".into())); } Ok(result) } /// The geometry of an effect's output: every returned object's path, in order. pub fn output_geometry(objects: Vec) -> PathData { PathData::new(objects.into_iter().filter_map(|o| o.path).flat_map(|p| p.subpaths).collect()) } #[cfg(test)] mod tests;