mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 11:54:03 +00:00
Building a type input's runs and paragraphs stops at the first one past what is left of the text and input totals, instead of building all of them before the size check.
1270 lines
56 KiB
Rust
1270 lines
56 KiB
Rust
//! 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, Mutex};
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::cms::{self, Cms, CmykLut, lab};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{
|
||
Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes,
|
||
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<f64> = 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<NodeId>,
|
||
/// `"compound"` (true) or `"path"` (false).
|
||
pub compound: Option<bool>,
|
||
/// `Some("")` clears the name.
|
||
pub name: Option<String>,
|
||
pub path: Option<PathData>,
|
||
pub rule: Option<FillRule>,
|
||
/// Fill paints in paint order (bottom first).
|
||
pub fills: Option<Vec<Paint>>,
|
||
pub strokes: Option<Vec<OutStroke>>,
|
||
pub opacity: Option<f32>,
|
||
/// 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<Vec<Effect>>,
|
||
/// Makes the object a guide (`true`, as View › Guides › Make Guides does) or a plain path.
|
||
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
|
||
/// default: 1 pt, butt cap, miter join, miter limit 10).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct OutStroke {
|
||
pub paint: Paint,
|
||
pub width: Option<f64>,
|
||
pub join: Option<LineJoin>,
|
||
pub cap: Option<LineCap>,
|
||
pub miter_limit: Option<f64>,
|
||
}
|
||
|
||
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<Vec<Effect>, 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<Rect>) -> 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<Value>, Vec<Value>) {
|
||
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<Value> {
|
||
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<u8> {
|
||
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 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<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 {
|
||
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)
|
||
}
|
||
|
||
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<PathData> {
|
||
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<Paint> {
|
||
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<String, Value>, 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<OutObject> {
|
||
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<Option<&Vec<Value>>> {
|
||
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::<Result<_>>()?);
|
||
}
|
||
if let Some(strokes) = list("strokes")? {
|
||
out.strokes = Some(strokes.iter().enumerate().map(|(i, s)| decode_stroke(s, &format!("{at}.strokes[{i}]"))).collect::<Result<_>>()?);
|
||
}
|
||
if let Some(effects) = list("effects")? {
|
||
out.effects = Some(effects.iter().enumerate().map(|(i, e)| decode_effect(e, &format!("{at}.effects[{i}]"))).collect::<Result<_>>()?);
|
||
}
|
||
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<Option<bool>> {
|
||
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<OutStroke> {
|
||
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<f64>| -> Result<Option<f64>> {
|
||
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<Effect> {
|
||
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<OutObject>,
|
||
/// `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<Option<Value>>,
|
||
}
|
||
|
||
/// 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, 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<Vec<OutObject>> {
|
||
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<NodeId, Value>) -> Result<Vec<OutObject>> {
|
||
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<NodeId> = 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::<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)
|
||
}
|
||
|
||
/// 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,
|
||
/// without control characters and at most 300 of them.
|
||
pub(crate) fn declined(v: &Value) -> Option<Error> {
|
||
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<Paint> {
|
||
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<OutStroke>) {
|
||
let idx: Vec<usize> = 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<Node>], path: PathData) -> Vec<Arc<Node>> {
|
||
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::<Vec<_>>())), (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::<Result<Vec<_>>>()?;
|
||
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, 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> {
|
||
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 && !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, 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<NodeId, Node>,
|
||
delete: HashSet<NodeId>,
|
||
before: HashMap<NodeId, Vec<Node>>,
|
||
after: HashMap<NodeId, Vec<Node>>,
|
||
/// New objects that go on top of a container (`None`: the top-level list).
|
||
top: Option<(Option<NodeId>, Vec<Node>)>,
|
||
}
|
||
|
||
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<NodeId>, list: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||
let mut out: Option<Vec<Arc<Node>>> = 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<Arc<Node>> = 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<OutObject>, parent: Option<NodeId>, admit: AdmitEffects) -> Result<Vec<NodeId>> {
|
||
let wanted: HashSet<NodeId> = inputs.iter().copied().collect();
|
||
let mut originals: HashMap<NodeId, Node> = 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<Node> = vec![];
|
||
let mut prev: Option<NodeId> = 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<OutObject>) -> PathData {
|
||
PathData::new(objects.into_iter().filter_map(|o| o.path).flat_map(|p| p.subpaths).collect())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|