mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 18:15:49 +00:00
Plug-ins: a filter's read-only objects stay in its input and failed decodes count
A filter run kept a second copy of every read-only object as JSON values.
The run now keeps where each object is in the input, and checks an output
entry against it by parsing that one object, unless the entry is `{"id": …}`
alone.
An image that reaches the decoder now counts toward the 16 MiB pixel total
even when decoding fails, so broken images use up the same total as images
that decode.
The plugin.run text, docs/mcp.md and the progress line state the paint
order and that rule, and docs/plugins.md states that a CMYK image's pixels
are sRGB. New tests cover the per-image 1 MiB, the 16 MiB pixel total and
the 16 MiB text total at and one past their values, memory-budget failures
before the plug-in starts, images that are not decoded, CMYK images
included, and leading per run.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
//! in-range coordinates, capped counts, clamped colours) before it reaches the document.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -46,7 +47,8 @@ pub const MAX_INPUT_RUNS: usize = 4_096;
|
||||
/// image comes with `pixelsOmitted`.
|
||||
pub const MAX_INPUT_PIXELS: usize = 1 << 20;
|
||||
/// Most pixels a filter is given over all the images of one input, in characters of base64: an
|
||||
/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`.
|
||||
/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`, and an
|
||||
/// image the host starts to decode takes its size from the total even when decoding fails.
|
||||
pub const MAX_INPUT_PIXELS_TOTAL: usize = 16 << 20;
|
||||
/// Most text a filter is given over all the type of one input, in bytes of the JSON of their runs
|
||||
/// and paragraphs: type that doesn't fit what the type before it left comes with `truncated`.
|
||||
@@ -191,12 +193,15 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec<u8> {
|
||||
/// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`]
|
||||
/// naming it and its size, a path or compound path that doesn't fit is the limit the runtime gives
|
||||
/// an input past its share, and more than [`MAX_OBJECTS`] read-only objects, more than an output
|
||||
/// may list, is an [`Error::Limit`] too. Also returns those objects by id, which the filter
|
||||
/// must return as they were ([`decode_output_with`]).
|
||||
pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
|
||||
/// may list, is an [`Error::Limit`] too. Also returns where those objects are in the input, which
|
||||
/// the filter must return as they were ([`decode_output_with`]).
|
||||
pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec<u8>, ReadOnlySpans)> {
|
||||
encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL })
|
||||
}
|
||||
|
||||
/// Where each read-only object of an input is in the input's bytes.
|
||||
pub type ReadOnlySpans = HashMap<NodeId, Range<usize>>;
|
||||
|
||||
/// The input with no objects; each object adds its JSON and a comma.
|
||||
const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}";
|
||||
|
||||
@@ -214,27 +219,23 @@ impl Allowance {
|
||||
const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX };
|
||||
}
|
||||
|
||||
/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map
|
||||
/// it returns, and written into the input from there.
|
||||
fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> {
|
||||
/// [`encode_input_with`] within `left` of the totals. Each object is held only while it is written
|
||||
/// into the input.
|
||||
fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, ReadOnlySpans)> {
|
||||
let mut read_only = HashMap::new();
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut out = b"{\"objects\":[".to_vec();
|
||||
let mut first = true;
|
||||
for n in ids.iter().filter_map(|id| doc.node(*id)) {
|
||||
let path;
|
||||
let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) {
|
||||
let (o, checked) = match input_kind(n).filter(|k| kinds.contains(k)) {
|
||||
// The output must return every read-only object, and lists at most `MAX_OBJECTS`.
|
||||
Some(_) if read_only.len() >= MAX_OBJECTS => return Err(too_many_read_only()),
|
||||
Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left)? {
|
||||
Some(o) => (read_only.entry(n.id).insert_entry(o).into_mut(), true),
|
||||
Some(o) => (o, true),
|
||||
None => continue,
|
||||
},
|
||||
None => match encode(n) {
|
||||
Some(o) => {
|
||||
path = o;
|
||||
(&path, false)
|
||||
}
|
||||
Some(o) => (o, false),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
@@ -242,9 +243,13 @@ fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut
|
||||
if !std::mem::take(&mut first) {
|
||||
out.push(b',');
|
||||
}
|
||||
if serde_json::to_writer(&mut out, o).is_err() {
|
||||
let object = out.len();
|
||||
if serde_json::to_writer(&mut out, &o).is_err() {
|
||||
return Ok((Vec::new(), HashMap::new()));
|
||||
}
|
||||
if checked {
|
||||
read_only.insert(n.id, object..out.len());
|
||||
}
|
||||
let size = out.len() - start;
|
||||
// A path or compound path past what is left fails the run as the runtime would fail it,
|
||||
// before the rest of the selection is written.
|
||||
@@ -291,7 +296,8 @@ fn affine_json(xf: Affine) -> Value {
|
||||
/// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`,
|
||||
/// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`]
|
||||
/// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top (a CMYK image's inks
|
||||
/// through the working CMYK profile). A linked image's file and path are never read or given;
|
||||
/// through the working CMYK profile, as sRGB). A linked image's file and path are never read or
|
||||
/// given;
|
||||
/// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when
|
||||
/// the document has no such symbol, and `overrides: {}`.
|
||||
pub fn encode_read_only(doc: &Document, n: &Node) -> Option<Value> {
|
||||
@@ -354,13 +360,13 @@ fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left
|
||||
NodeKind::Image(im) => {
|
||||
let blob = doc.image_blob(im);
|
||||
let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY));
|
||||
let mut charged = 0;
|
||||
// A linked image's pixels are its file's: never given (nor its path).
|
||||
let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max));
|
||||
let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max, &mut charged));
|
||||
left.pixels = left.pixels.saturating_sub(charged);
|
||||
o["pixelsOmitted"] = json!(pixels.is_none());
|
||||
if let Some(p) = pixels {
|
||||
let b64 = craft_vectorcraft_format::base64::encode(&p);
|
||||
left.pixels = left.pixels.saturating_sub(b64.len());
|
||||
o["pixelsBase64"] = json!(b64);
|
||||
o["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -458,20 +464,34 @@ fn color_mode(b: &ImageBlob) -> &'static str {
|
||||
if readable.is_some() { "rgb" } else { "unknown" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many images [`rgba_pixels`] has started to decode on this thread.
|
||||
static DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max`
|
||||
/// characters of base64; the size is checked, and read from the header, before anything is decoded.
|
||||
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> {
|
||||
/// `charged` gets the characters of base64 an image that reaches the decoder takes from the total,
|
||||
/// which it takes even when decoding fails.
|
||||
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize, charged: &mut usize) -> Option<Vec<u8>> {
|
||||
let bytes = u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)?;
|
||||
if bytes == 0 || bytes.div_ceil(3).saturating_mul(4) > max as u64 || oversized_tiff(&b.bytes).is_some() {
|
||||
let cost = bytes.div_ceil(3).saturating_mul(4);
|
||||
if bytes == 0 || cost > max as u64 || oversized_tiff(&b.bytes).is_some() {
|
||||
return None;
|
||||
}
|
||||
if b.is_cmyk() {
|
||||
return cmyk_rgba(b, w, h);
|
||||
}
|
||||
let cmyk = b.is_cmyk();
|
||||
let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok();
|
||||
if reader()?.into_dimensions().ok()? != (w, h) {
|
||||
let size = if cmyk { b.header_size()? } else { reader()?.into_dimensions().ok()? };
|
||||
if size != (w, h) {
|
||||
return None;
|
||||
}
|
||||
*charged = cost as usize;
|
||||
#[cfg(test)]
|
||||
DECODES.with(|n| n.set(n.get() + 1));
|
||||
if cmyk {
|
||||
return cmyk_rgba(b, w, h);
|
||||
}
|
||||
let mut r = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
// The decoder's own buffers besides the RGBA it gives.
|
||||
@@ -484,13 +504,10 @@ fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> {
|
||||
/// The inks → XYZ table of [`cmyk_rgba`] and the color settings it was built with.
|
||||
static CMYK_DISPLAY: Mutex<Option<(Arc<Cms>, Arc<CmykLut>)>> = Mutex::new(None);
|
||||
|
||||
/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels (checked from its
|
||||
/// header first): its inks shown through the working CMYK profile, by a table built as
|
||||
/// `vectorcraft-render`'s `InkLut` builds its own and kept for the active color settings.
|
||||
/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels: its inks shown
|
||||
/// through the working CMYK profile, by a table built as `vectorcraft-render`'s `InkLut` builds its
|
||||
/// own and kept for the active color settings.
|
||||
fn cmyk_rgba(b: &ImageBlob, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
if b.header_size()? != (w, h) {
|
||||
return None;
|
||||
}
|
||||
let inks = b.cmyk().filter(|i| (i.width, i.height) == (w, h))?;
|
||||
let lut = {
|
||||
let cms = cms::active();
|
||||
@@ -761,16 +778,18 @@ pub struct Output {
|
||||
/// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}`
|
||||
/// when the plug-in declined (the message reaches the user).
|
||||
pub fn decode_output(bytes: &[u8]) -> Result<Output> {
|
||||
decode_output_with(bytes, &HashMap::new())
|
||||
decode_output_with(bytes, &[], &HashMap::new())
|
||||
}
|
||||
|
||||
/// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list
|
||||
/// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by
|
||||
/// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left
|
||||
/// out, is refused naming the object and the first field that differs, before anything changes.
|
||||
pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> Result<Output> {
|
||||
/// [`decode_output`] for a filter given `read_only` objects, at those places in `input`
|
||||
/// ([`encode_input_with`]): it must list each of them exactly once, as `{"id": …}` alone or exactly
|
||||
/// as it was given (numbers compared by value), and such an entry is [`OutObject::kept`]. Any other
|
||||
/// entry for one of them, and one left out, is refused naming the object and the first field that
|
||||
/// differs, before anything changes. Each object is parsed from `input` only to compare it, one at
|
||||
/// a time, and an object that can't be read from there is an error.
|
||||
pub fn decode_output_with(bytes: &[u8], input: &[u8], read_only: &ReadOnlySpans) -> Result<Output> {
|
||||
let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
|
||||
let objects = decode_objects(&v, read_only)?;
|
||||
let objects = decode_objects(&v, input, read_only)?;
|
||||
let document_data = match v.get_mut("documentData").map(Value::take) {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
@@ -789,12 +808,12 @@ pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> R
|
||||
/// declined (the message reaches the user). A live effect's other keys don't matter.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
|
||||
decode_objects(&v, &HashMap::new())
|
||||
decode_objects(&v, &[], &HashMap::new())
|
||||
}
|
||||
|
||||
/// The objects of output `v`, or its decline; entries for `read_only` objects as
|
||||
/// [`decode_output_with`] reads them.
|
||||
fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<OutObject>> {
|
||||
fn decode_objects(v: &Value, input: &[u8], read_only: &ReadOnlySpans) -> Result<Vec<OutObject>> {
|
||||
if let Some(e) = declined(v) {
|
||||
return Err(e);
|
||||
}
|
||||
@@ -808,12 +827,16 @@ fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<O
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, o)| match output_id(o).and_then(|id| Some((id, read_only.get(&id)?))) {
|
||||
Some((id, given)) => {
|
||||
Some((id, span)) => {
|
||||
let at = format!("objects[{i}] (id {})", id.0);
|
||||
if !returned.insert(id) {
|
||||
return Err(out_err(&at, "is listed twice"));
|
||||
}
|
||||
check_unchanged(o, given, &at)?;
|
||||
// `{"id": …}` alone keeps the object, so there is nothing to compare it with.
|
||||
if o.as_object().is_none_or(|m| m.len() != 1) {
|
||||
let given = serde_json::from_slice(input.get(span.clone()).unwrap_or_default()).map_err(|e| Error::Io(e.to_string()))?;
|
||||
check_unchanged(o, &given, &at)?;
|
||||
}
|
||||
Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() })
|
||||
}
|
||||
None => decode_object(o, &format!("objects[{i}]"), &mut anchors),
|
||||
@@ -836,15 +859,12 @@ fn output_id(o: &Value) -> Option<NodeId> {
|
||||
id.as_u64().or_else(|| id.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=9_007_199_254_740_992.0).contains(f)).map(|f| f as u64)).map(NodeId)
|
||||
}
|
||||
|
||||
/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone,
|
||||
/// or `given` itself, numbers compared by value. Otherwise the error names the first field that
|
||||
/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as
|
||||
/// Checks entry `o` of a filter's output for a read-only object it was `given`: `given` itself,
|
||||
/// numbers compared by value. Otherwise the error names the first field that differs
|
||||
/// (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as
|
||||
/// [`crate::permissions::display_text`] shows a manifest string, since the entry's keys are the
|
||||
/// plug-in's.
|
||||
fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> {
|
||||
if o.as_object().is_some_and(|m| m.len() == 1) {
|
||||
return Ok(());
|
||||
}
|
||||
let changed = match (o, given) {
|
||||
(Value::Object(x), Value::Object(y)) => {
|
||||
let mut keys: Vec<&String> = x.keys().collect();
|
||||
|
||||
@@ -481,6 +481,212 @@ fn cmyk_image_pixels_go_through_the_working_cmyk_profile() {
|
||||
assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile");
|
||||
}
|
||||
|
||||
/// The image object for `key` in `d`'s layer, `size` pixels.
|
||||
fn placed(d: &mut Document, layer: NodeId, key: &str, size: (u32, u32)) -> NodeId {
|
||||
let n = image_node(d, key, size, None);
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap()
|
||||
}
|
||||
|
||||
/// The largest pixels one image may have fill exactly [`MAX_INPUT_PIXELS`] characters of base64
|
||||
/// (512 × 384); one pixel more doesn't fit.
|
||||
#[test]
|
||||
fn an_image_at_the_limit_is_given_and_one_pixel_more_is_omitted() {
|
||||
let (mut d, _, layer, _) = mixed();
|
||||
for ((w, h), omitted) in [((512, 384), false), ((1, 196_609), true)] {
|
||||
d.images.insert("limit".into(), ImageBlob::png(png(w, h)));
|
||||
let id = placed(&mut d, layer, "limit", (w, h));
|
||||
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
|
||||
assert_eq!(o["pixelsOmitted"], json!(omitted), "{w} × {h}");
|
||||
assert_eq!(o.get("pixelsBase64").and_then(Value::as_str).map(str::len), (!omitted).then_some(MAX_INPUT_PIXELS), "{w} × {h}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sixteen images at the per-image limit fill [`MAX_INPUT_PIXELS_TOTAL`] exactly and are all given;
|
||||
/// the next one in paint order, even a small one, comes with `pixelsOmitted`.
|
||||
#[test]
|
||||
fn images_fill_the_pixel_total_and_the_next_is_omitted() {
|
||||
let (mut d, _, layer, [_, embedded, ..]) = mixed();
|
||||
d.images.insert("limit".into(), ImageBlob::png(png(512, 384)));
|
||||
let mut ids: Vec<NodeId> = (0..16).map(|_| placed(&mut d, layer, "limit", (512, 384))).collect();
|
||||
ids.push(embedded);
|
||||
let (bytes, _) = encode_input_with(&d, &ids, &[InputKind::Image], usize::MAX).unwrap();
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let objects = v["objects"].as_array().unwrap();
|
||||
assert_eq!(objects.len(), 17);
|
||||
let given: usize = objects.iter().filter_map(|o| o.get("pixelsBase64")?.as_str()).map(str::len).sum();
|
||||
assert_eq!(given, MAX_INPUT_PIXELS_TOTAL);
|
||||
let omitted: Vec<bool> = objects.iter().map(|o| o["pixelsOmitted"] == json!(true)).collect();
|
||||
assert_eq!(omitted, [vec![false; 16], vec![true]].concat());
|
||||
}
|
||||
|
||||
/// Type whose runs and paragraphs together fill [`MAX_INPUT_TEXT_TOTAL`] exactly is all given, and
|
||||
/// type after it in paint order comes with `truncated`. With one byte more, the last type that
|
||||
/// doesn't fit comes with `truncated` and what it leaves still goes to the type after it.
|
||||
#[test]
|
||||
fn type_fills_the_text_total_and_what_follows_is_truncated() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
let (mut d, _, layer, _) = mixed();
|
||||
let place = |d: &mut Document, len: usize| {
|
||||
let t = TextObject::point(Point::new(0.0, 0.0), &"x".repeat(len), CharStyle::default());
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap()
|
||||
};
|
||||
let one = place(&mut d, 1);
|
||||
let o = encode_read_only(&d, d.node(one).unwrap()).unwrap();
|
||||
// Everything but the text itself, with the commas between runs and paragraphs and without
|
||||
// their brackets.
|
||||
let overhead: usize = ["runs", "paragraphs"].iter().map(|k| o["text"][k].to_string().len() - 2).sum::<usize>() - 1;
|
||||
let full = overhead + MAX_INPUT_TEXT;
|
||||
let count = MAX_INPUT_TEXT_TOTAL / full;
|
||||
let rest = MAX_INPUT_TEXT_TOTAL - count * full - overhead;
|
||||
assert!((1..=MAX_INPUT_TEXT).contains(&rest));
|
||||
for (filler, truncated) in [(rest, [false, true]), (rest + 1, [true, false])] {
|
||||
let mut ids: Vec<NodeId> = (0..count).map(|_| place(&mut d, MAX_INPUT_TEXT)).collect();
|
||||
ids.push(place(&mut d, filler));
|
||||
ids.push(one);
|
||||
let (bytes, _) = encode_input_with(&d, &ids, &[InputKind::Type], usize::MAX).unwrap();
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let objects = v["objects"].as_array().unwrap();
|
||||
assert_eq!(objects.len(), count + 2);
|
||||
assert!(objects[..count].iter().all(|o| o["truncated"] == json!(false)), "filler {filler}");
|
||||
assert_eq!([&objects[count]["truncated"], &objects[count + 1]["truncated"]], truncated.map(|t| json!(t)).each_ref(), "filler {filler}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMYK TIFF `w` × `h` pixels of no ink.
|
||||
fn cmyk_blob(w: u32, h: u32) -> ImageBlob {
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(w, h, vec![0; (w * h * 4) as usize]).unwrap();
|
||||
ImageBlob::cmyk_tiff(&inks).unwrap()
|
||||
}
|
||||
|
||||
/// `blob`, a little-endian TIFF, with its strip offset past the end of the file: its header still
|
||||
/// reads and its pixels don't.
|
||||
fn without_strips(blob: &ImageBlob) -> ImageBlob {
|
||||
let mut bytes = blob.bytes.to_vec();
|
||||
assert_eq!(&bytes[..2], b"II");
|
||||
let word = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) as usize;
|
||||
let ifd = word(&bytes, 4);
|
||||
let count = usize::from(u16::from_le_bytes([bytes[ifd], bytes[ifd + 1]]));
|
||||
let entry = (0..count).map(|i| ifd + 2 + i * 12).find(|&e| bytes[e..e + 2] == 273u16.to_le_bytes()).unwrap();
|
||||
let past = (bytes.len() as u32 + 4096).to_le_bytes();
|
||||
bytes[entry + 8..entry + 12].copy_from_slice(&past);
|
||||
ImageBlob::png(bytes)
|
||||
}
|
||||
|
||||
/// An image that can't be given isn't decoded: past the per-image limit, past what is left of the
|
||||
/// total, once the total is used up, when its header disagrees with its object, and when it is
|
||||
/// linked, a CMYK image as much as any other. One that fits is decoded once.
|
||||
#[test]
|
||||
fn an_image_that_cannot_be_given_is_not_decoded() {
|
||||
let (mut d, _, layer, [_, embedded, linked, ..]) = mixed();
|
||||
d.images.insert("big".into(), ImageBlob::png(png(1, 196_609)));
|
||||
d.images.insert("k".into(), cmyk_blob(2, 1));
|
||||
let big = placed(&mut d, layer, "big", (1, 196_609));
|
||||
let wrong = placed(&mut d, layer, "e", (3, 1));
|
||||
let cmyk = placed(&mut d, layer, "k", (2, 1));
|
||||
let cmyk_wrong = placed(&mut d, layer, "k", (3, 1));
|
||||
let within = |pixels: usize| Allowance { pixels, ..Allowance::FULL };
|
||||
for (id, left, omitted, decodes, what) in [
|
||||
(big, Allowance::FULL, true, 0, "past the per-image limit"),
|
||||
(embedded, within(11), true, 0, "past what is left of the total"),
|
||||
(embedded, within(0), true, 0, "the total used up"),
|
||||
(wrong, Allowance::FULL, true, 0, "another size than its object"),
|
||||
(linked, Allowance::FULL, true, 0, "linked"),
|
||||
(embedded, within(12), false, 1, "fits"),
|
||||
(cmyk, within(11), true, 0, "CMYK past what is left of the total"),
|
||||
(cmyk_wrong, Allowance::FULL, true, 0, "CMYK of another size than its object"),
|
||||
(cmyk, within(12), false, 1, "CMYK that fits"),
|
||||
] {
|
||||
DECODES.with(|n| n.set(0));
|
||||
let (bytes, _) = encode_input_within(&d, &[id], &[InputKind::Image], left).unwrap();
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["objects"][0]["pixelsOmitted"], json!(omitted), "{what}");
|
||||
assert_eq!(DECODES.with(|n| n.get()), decodes, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An image that reaches the decoder and fails takes its size from the total, a broken CMYK image
|
||||
/// as much as a broken PNG: the next image doesn't fit what the broken one left.
|
||||
#[test]
|
||||
fn an_image_that_fails_to_decode_takes_from_the_total() {
|
||||
let (mut d, _, layer, [_, embedded, ..]) = mixed();
|
||||
let mut png_data = png(2, 1);
|
||||
let idat = png_data.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png_data[idat + 6] ^= 0xFF;
|
||||
for (what, blob, expect_cmyk) in [("PNG", ImageBlob::png(png_data), false), ("CMYK", without_strips(&cmyk_blob(2, 1)), true)] {
|
||||
assert_eq!(blob.is_cmyk(), expect_cmyk, "{what}");
|
||||
d.images.insert("broken".into(), blob);
|
||||
let bad = placed(&mut d, layer, "broken", (2, 1));
|
||||
let omitted = |pixels: usize| {
|
||||
DECODES.with(|n| n.set(0));
|
||||
let left = Allowance { pixels, ..Allowance::FULL };
|
||||
let (bytes, _) = encode_input_within(&d, &[bad, embedded], &[InputKind::Image], left).unwrap();
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
(v["objects"][0]["pixelsOmitted"].clone(), v["objects"][1]["pixelsOmitted"].clone(), DECODES.with(|n| n.get()))
|
||||
};
|
||||
assert_eq!(omitted(12), (json!(true), json!(true), 1), "{what}: one decode fits 12 characters");
|
||||
assert_eq!(omitted(24), (json!(true), json!(false), 2), "{what}: the next image fits what is left");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs in one paragraph keep their own leading: each run comes with its character attributes, and
|
||||
/// the paragraph is one paragraph style.
|
||||
#[test]
|
||||
fn runs_in_one_paragraph_keep_their_own_leading() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject, TextRun};
|
||||
let (mut d, _, layer, _) = mixed();
|
||||
let mut t = TextObject::point(Point::new(0.0, 0.0), "", CharStyle::default());
|
||||
let leading = CharStyle { leading: Some(14.0), ..CharStyle::default() };
|
||||
t.runs = vec![TextRun::new("a".to_string(), leading), TextRun::new("b".to_string(), CharStyle::default())];
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
|
||||
assert_eq!(o["text"]["runs"][0]["style"]["leading"], json!(14.0));
|
||||
assert_eq!((&o["text"]["runs"][1]["text"], &o["text"]["runs"][1]["style"]["leading"]), (&json!("b"), &Value::Null));
|
||||
assert_eq!(o["text"]["paragraphs"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Each read-only object's place in the input holds that object, pixels included, and the input's
|
||||
/// paths have no place. An output that echoes an object is compared with what that place holds; an
|
||||
/// input that doesn't hold it refuses the output, as the host's own error rather than the
|
||||
/// plug-in's.
|
||||
#[test]
|
||||
fn read_only_objects_are_read_from_their_place_in_the_input_and_fail_closed() {
|
||||
let (d, ids, _, [text, embedded, ..]) = mixed();
|
||||
let order = [ids[0], text, embedded];
|
||||
let (bytes, read_only) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap();
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(read_only.len(), 2);
|
||||
let at = |id: NodeId| serde_json::from_slice::<Value>(&bytes[read_only[&id].clone()]).unwrap();
|
||||
assert_eq!(at(text), v["objects"][1]);
|
||||
assert_eq!(at(embedded), v["objects"][2]);
|
||||
assert!(at(embedded)["pixelsBase64"].is_string());
|
||||
let echo = json!({"objects": [v["objects"][1], v["objects"][2]]}).to_string();
|
||||
assert!(decode_output_with(echo.as_bytes(), &bytes, &read_only).is_ok());
|
||||
let garbage = vec![b'x'; bytes.len()];
|
||||
for input in [garbage.as_slice(), b"not json", br#"{"objects":[]}"#, b"{}", b""] {
|
||||
let e = decode_output_with(echo.as_bytes(), input, &read_only).unwrap_err().to_string();
|
||||
assert!(!e.starts_with("plug-in"), "{e}");
|
||||
}
|
||||
// `{"id": …}` alone needs nothing from the input.
|
||||
let kept = json!({"objects": [{"id": text.0}, {"id": embedded.0}]}).to_string();
|
||||
assert!(decode_output_with(kept.as_bytes(), b"", &read_only).is_ok());
|
||||
}
|
||||
|
||||
/// A path's effect parameters can nest deeper than the JSON reader goes. Checking the output reads
|
||||
/// only the read-only objects from the input, so such a path beside one doesn't fail the run.
|
||||
#[test]
|
||||
fn a_path_nested_past_the_json_reader_beside_a_read_only_object_passes_the_check() {
|
||||
let (mut d, ids, _, [text, ..]) = mixed();
|
||||
let params = (0..200).fold(json!(1), |inner, _| json!([inner]));
|
||||
d.node_mut(ids[0]).unwrap().appearance.effects.push(vectorcraft_doc::Effect { id: "x".into(), params, visible: true });
|
||||
let (bytes, read_only) = encode_input_with(&d, &[ids[0], text], &[InputKind::Type], usize::MAX).unwrap();
|
||||
assert!(serde_json::from_slice::<Value>(&bytes).is_err(), "the input is deeper than the reader goes");
|
||||
let echo = serde_json::from_slice::<Value>(&bytes[read_only[&text].clone()]).unwrap();
|
||||
let out = decode_output_with(json!({"objects": [echo]}).to_string().as_bytes(), &bytes, &read_only).unwrap();
|
||||
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 1);
|
||||
}
|
||||
|
||||
/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose
|
||||
/// symbol the document lacks says so.
|
||||
#[test]
|
||||
@@ -521,7 +727,7 @@ fn input_totals_cover_images_and_type_in_order() {
|
||||
assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none());
|
||||
assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]);
|
||||
for o in objects {
|
||||
assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap())));
|
||||
assert!(read_only.contains_key(&NodeId(o["id"].as_u64().unwrap())));
|
||||
}
|
||||
// Within the full totals every one of them is given.
|
||||
let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap();
|
||||
@@ -689,7 +895,7 @@ fn a_changed_field_is_named_escaped_and_short() {
|
||||
let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap();
|
||||
let key = format!("\u{202E}{}", "k".repeat(500));
|
||||
v["objects"][at][key.as_str()] = json!(1);
|
||||
let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string();
|
||||
let e = decode_output_with(v.to_string().as_bytes(), &bytes, &read_only).unwrap_err().to_string();
|
||||
let shown = e.split('\'').nth(1).unwrap_or_default();
|
||||
assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}");
|
||||
assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}");
|
||||
@@ -707,7 +913,7 @@ fn read_only_kind_field_and_omission_refuse() {
|
||||
assert_eq!(read_only.len(), 5);
|
||||
let input: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let before = d.clone();
|
||||
let out = decode_output_with(&bytes, &read_only).unwrap();
|
||||
let out = decode_output_with(&bytes, &bytes, &read_only).unwrap();
|
||||
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
|
||||
apply_output(&mut d, &targets, out.objects, None, &keep).unwrap();
|
||||
assert_eq!(d, before, "an exact echo changes nothing");
|
||||
@@ -716,19 +922,20 @@ fn read_only_kind_field_and_omission_refuse() {
|
||||
// Numbers written another way are the same numbers.
|
||||
let mut echo = input.clone();
|
||||
echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]);
|
||||
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok());
|
||||
assert!(decode_output_with(echo.to_string().as_bytes(), &bytes, &read_only).is_ok());
|
||||
// So is an id written as a float; a fractional one names no object.
|
||||
let id = input["objects"][3]["id"].as_u64().unwrap();
|
||||
let mut echo = input.clone();
|
||||
echo["objects"][3]["id"] = json!(id as f64);
|
||||
let out = decode_output_with(echo.to_string().as_bytes(), &read_only).unwrap();
|
||||
let out = decode_output_with(echo.to_string().as_bytes(), &bytes, &read_only).unwrap();
|
||||
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
|
||||
echo["objects"][3]["id"] = json!(id as f64 + 0.5);
|
||||
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_err());
|
||||
assert!(decode_output_with(echo.to_string().as_bytes(), &bytes, &read_only).is_err());
|
||||
// `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type.
|
||||
let ro = |id: NodeId| json!({"id": id.0});
|
||||
let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]});
|
||||
let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap();
|
||||
let made =
|
||||
apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &bytes, &read_only).unwrap().objects, None, &keep).unwrap();
|
||||
assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]);
|
||||
assert_eq!(made.len(), 6);
|
||||
assert!(ids.iter().all(|id| d.node(*id).is_none()));
|
||||
@@ -767,7 +974,7 @@ fn read_only_kind_field_and_omission_refuse() {
|
||||
"`type` must be \"path\" or \"compound\"".to_string(),
|
||||
),
|
||||
] {
|
||||
let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string();
|
||||
let e = decode_output_with(out.to_string().as_bytes(), &bytes, &read_only).unwrap_err().to_string();
|
||||
assert!(e.contains(&says), "{says}: {e}");
|
||||
}
|
||||
// Writing one or leaving one out is refused when applying too, whatever decoded the output.
|
||||
|
||||
Reference in New Issue
Block a user