mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
A TIFF with more strips and planes than the decoder can count comes to a filter as "unknown" with no pixels, instead of panicking in the metadata read. An output that writes a read-only object's id as 17.0 now names that object, as other numbers in the object compare by value; a fractional id still names no object.
804 lines
46 KiB
Rust
804 lines
46 KiB
Rust
use super::*;
|
||
use vectorcraft_geom::SubPath;
|
||
|
||
fn square(x: f64) -> PathData {
|
||
PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)], true))
|
||
}
|
||
|
||
/// A document with paths A, B and C (bottom to top) on its layer, plus a locked path and a guide.
|
||
fn doc() -> (Document, Vec<NodeId>, NodeId) {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let layer = d.layers[0].id;
|
||
let mut ids = vec![];
|
||
for (i, name) in ["A", "B", "C"].into_iter().enumerate() {
|
||
let mut n = Node::path(d.alloc_id(), square(i as f64 * 20.0), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 1.0));
|
||
n.name = Some(name.into());
|
||
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
|
||
}
|
||
let mut locked = Node::path(d.alloc_id(), square(80.0), Appearance::default());
|
||
locked.locked = true;
|
||
d.insert(Some(layer), usize::MAX, locked).unwrap();
|
||
let guide = Node::new(d.alloc_id(), NodeKind::Path { path: square(90.0), rule: FillRule::NonZero, live: None, clipping: false, guide: true });
|
||
d.insert(Some(layer), usize::MAX, guide).unwrap();
|
||
(d, ids, layer)
|
||
}
|
||
|
||
fn names(d: &Document, layer: NodeId) -> Vec<String> {
|
||
d.children(Some(layer)).unwrap().iter().map(|n| n.name.clone().unwrap_or_else(|| "?".into())).collect()
|
||
}
|
||
|
||
/// Admits every effect record as it is (the host's checks are the engine's).
|
||
fn keep(fx: &[Effect], _: &Node) -> std::result::Result<Vec<Effect>, String> {
|
||
Ok(fx.to_vec())
|
||
}
|
||
|
||
fn decode_json(v: Value) -> Result<Vec<OutObject>> {
|
||
decode(v.to_string().as_bytes())
|
||
}
|
||
|
||
#[test]
|
||
fn targets_are_selected_paths_in_paint_order() {
|
||
let (mut d, ids, layer) = doc();
|
||
assert_eq!(filter_targets(&d, &[ids[2], ids[0]]), [ids[0], ids[2]]);
|
||
// A selected layer gives its paths, without the locked one and the guide.
|
||
assert_eq!(filter_targets(&d, &[layer]), ids);
|
||
d.node_mut(ids[1]).unwrap().visible = false;
|
||
assert_eq!(filter_targets(&d, &[layer]), [ids[0], ids[2]]);
|
||
}
|
||
|
||
/// Objects as plug-ins are given them (type, name, fill rule, fills, strokes, bounds); handed back
|
||
/// unchanged, they change nothing, swatch links included.
|
||
#[test]
|
||
fn objects_round_trip_through_the_json_model() {
|
||
let (mut d, ids, _) = doc();
|
||
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
|
||
g.gradient.stops[0].swatch = Some("White".into());
|
||
d.node_mut(ids[0]).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(g)));
|
||
let input: Value = serde_json::from_slice(&encode_input(&d, &ids)).unwrap();
|
||
let a = &input["objects"][0];
|
||
assert_eq!((a["type"].as_str(), a["name"].as_str(), a["fillRule"].as_str()), (Some("path"), Some("A"), Some("nonzero")));
|
||
assert_eq!(a["fills"][0]["color"], json!({"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}));
|
||
assert_eq!(a["strokes"][0]["paint"]["type"], "gradient");
|
||
assert_eq!(a["bounds"], json!([0.0, 0.0, 10.0, 10.0]));
|
||
// Echoed back unchanged, nothing changes (swatch links included).
|
||
let before = d.clone();
|
||
let out = decode(&serde_json::to_vec(&input).unwrap()).unwrap();
|
||
assert_eq!(apply_output(&mut d, &ids, out, None, &keep).unwrap(), ids);
|
||
assert_eq!(d, before);
|
||
}
|
||
|
||
/// A path handed back as it was stays the live shape it was (a rectangle keeps its corner
|
||
/// widgets); a path that changed is a plain path.
|
||
#[test]
|
||
fn an_unchanged_path_stays_a_live_shape() {
|
||
let (mut d, ids, _) = doc();
|
||
let a = ids[0];
|
||
let live = vectorcraft_doc::LiveShape::Rectangle {
|
||
w: 10.0,
|
||
h: 10.0,
|
||
radii: [0.0; 4],
|
||
radii_relative: false,
|
||
kinds: Default::default(),
|
||
xf: vectorcraft_geom::Affine::IDENTITY,
|
||
};
|
||
if let Some(NodeKind::Path { live: l, .. }) = d.node_mut(a).map(|n| &mut n.kind) {
|
||
*l = Some(Box::new(live));
|
||
}
|
||
let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap();
|
||
let before = d.clone();
|
||
apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &keep).unwrap();
|
||
assert_eq!(d, before, "still a live rectangle");
|
||
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "path": square(1.0)}]})).unwrap(), None, &keep).unwrap();
|
||
assert!(matches!(&d.node(a).unwrap().kind, NodeKind::Path { live: None, .. }), "moved: a plain path");
|
||
}
|
||
|
||
/// An output updates the input objects it lists, deletes the ones it leaves out, and adds new
|
||
/// objects where it lists them.
|
||
#[test]
|
||
fn outputs_update_add_and_delete_in_place() {
|
||
let (mut d, ids, layer) = doc();
|
||
let [a, b, c] = [ids[0].0, ids[1].0, ids[2].0];
|
||
let new = |name: &str| json!({"name": name, "path": square(5.0), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}}]});
|
||
// A recoloured, B dropped, C kept; new objects around them.
|
||
let out = json!({"objects": [new("n1"), {"id": a, "fills": [null, {"type": "solid", "color": {"model": "rgb", "r": 5, "g": 0, "b": 0}}]}, new("n2"), {"id": c}, new("n3")]});
|
||
let sel = apply_output(&mut d, &ids, decode_json(out).unwrap(), None, &keep).unwrap();
|
||
assert_eq!(names(&d, layer), ["n1", "A", "n2", "C", "n3", "?", "?"]);
|
||
assert_eq!(sel.len(), 5);
|
||
assert!(d.node(NodeId(b)).is_none());
|
||
let na = d.node(NodeId(a)).unwrap();
|
||
let fills = paints(&na.appearance, true);
|
||
assert_eq!(fills, [&Paint::None, &Paint::solid(Color::rgb(1.0, 0.0, 0.0))], "a second fill was added; colours are clamped");
|
||
let n1 = d.node(sel[0]).unwrap();
|
||
assert_eq!(n1.appearance.fill_paint(), Paint::solid(Color::gray(0.5)));
|
||
assert!(n1.appearance.stroke().is_none());
|
||
}
|
||
|
||
/// New objects take the place of the input objects they replace, or go on top of the given
|
||
/// container when there were none.
|
||
#[test]
|
||
fn new_objects_take_the_place_of_replaced_inputs_or_go_on_top() {
|
||
let (mut d, ids, layer) = doc();
|
||
let out = json!({"objects": [{"name": "X", "path": square(0.0)}]});
|
||
apply_output(&mut d, &ids[1..], decode_json(out.clone()).unwrap(), None, &keep).unwrap();
|
||
assert_eq!(names(&d, layer), ["A", "X", "?", "?"], "B and C replaced by X at B's place");
|
||
// No inputs (a generator): on top of the given container.
|
||
let (mut d, _, layer) = doc();
|
||
apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap();
|
||
assert_eq!(names(&d, layer).last().map(String::as_str), Some("X"));
|
||
}
|
||
|
||
/// An output turns a path into a compound path and back and sets its fill rule, strokes (weights
|
||
/// clamped), opacity and name.
|
||
#[test]
|
||
fn geometry_type_and_strokes_change() {
|
||
let (mut d, ids, _) = doc();
|
||
let a = ids[0];
|
||
let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect());
|
||
let blue = json!({"type": "solid", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}});
|
||
let out = json!({"objects": [{"id": a.0, "type": "compound", "path": two, "fillRule": "evenodd", "strokes": [{"paint": blue, "width": 4000}], "opacity": 0.5, "name": ""}]});
|
||
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap();
|
||
let n = d.node(a).unwrap();
|
||
let NodeKind::Compound { children, rule } = &n.kind else { panic!("not a compound: {:?}", n.kind) };
|
||
assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd));
|
||
assert_eq!(n.appearance.stroke().unwrap().width, MAX_STROKE_WIDTH);
|
||
assert_eq!((n.opacity, n.name.as_deref()), (0.5, None));
|
||
assert_eq!(geometry(n).unwrap().0, two);
|
||
// And back to a single path; strokes removed.
|
||
let out = json!({"objects": [{"id": a.0, "type": "path", "strokes": []}]});
|
||
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap();
|
||
let n = d.node(a).unwrap();
|
||
assert!(matches!(&n.kind, NodeKind::Path { path, .. } if *path == two));
|
||
assert!(n.appearance.stroke().is_none());
|
||
}
|
||
|
||
/// A compound path made a guide is left out of a later filter's inputs, as guide paths are; a
|
||
/// plain compound path is still one of them.
|
||
#[test]
|
||
fn compound_guides_are_not_filter_targets() {
|
||
let (mut d, _, layer) = doc();
|
||
let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect());
|
||
let out = json!({"objects": [{"type": "compound", "path": two.clone(), "guide": true}, {"type": "compound", "path": two}]});
|
||
let made = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap();
|
||
let targets = filter_targets(&d, &[layer]);
|
||
assert!(!targets.contains(&made[0]), "the compound guide");
|
||
assert!(targets.contains(&made[1]), "the plain compound path");
|
||
}
|
||
|
||
/// `guide` and `locked` make objects guides (a compound path's members too) and lock them, or undo
|
||
/// both; locked objects and guides are not [`selectable`]. Other values are refused.
|
||
#[test]
|
||
fn objects_become_guides_and_lock() {
|
||
let (mut d, ids, layer) = doc();
|
||
let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect());
|
||
let out = json!({"objects": [
|
||
{"id": ids[0].0, "guide": true},
|
||
{"id": ids[1].0, "locked": true},
|
||
{"name": "G", "type": "compound", "path": two, "guide": true, "locked": true}]});
|
||
let made = apply_output(&mut d, &ids[..2], decode_json(out).unwrap(), Some(layer), &keep).unwrap();
|
||
let a = d.node(ids[0]).unwrap();
|
||
assert!(matches!(a.kind, NodeKind::Path { guide: true, .. }) && !a.locked && !selectable(a));
|
||
let b = d.node(ids[1]).unwrap();
|
||
assert!(b.locked && matches!(b.kind, NodeKind::Path { guide: false, .. }) && !selectable(b));
|
||
let g = d.node(made[2]).unwrap();
|
||
let NodeKind::Compound { children, .. } = &g.kind else { panic!("not a compound: {:?}", g.kind) };
|
||
assert!(g.locked && children.iter().all(|c| matches!(c.kind, NodeKind::Path { guide: true, .. })));
|
||
// And back: a plain, unlocked, selectable path.
|
||
let out = json!({"objects": [{"id": ids[0].0, "guide": false, "locked": false}]});
|
||
apply_output(&mut d, &ids[..1], decode_json(out).unwrap(), None, &keep).unwrap();
|
||
assert!(selectable(d.node(ids[0]).unwrap()));
|
||
for bad in [json!({"guide": 1}), json!({"locked": "yes"})] {
|
||
assert!(decode_json(json!({"objects": [bad]})).is_err(), "{bad}");
|
||
}
|
||
}
|
||
|
||
/// A paint handed back as it was keeps its swatch link and any other is unlinked; a pattern paint
|
||
/// must name a pattern the document has.
|
||
#[test]
|
||
fn changed_paints_lose_swatch_links_and_patterns_must_exist() {
|
||
let (mut d, ids, _) = doc();
|
||
let a = ids[0];
|
||
d.node_mut(a).unwrap().appearance.set_fill(Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 });
|
||
let linked = json!({"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}, "swatch": "Red", "tint": 0.5});
|
||
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [linked]}]})).unwrap(), None, &keep).unwrap();
|
||
assert!(matches!(d.node(a).unwrap().appearance.fill_paint(), Paint::Solid { swatch: Some(_), .. }), "unchanged: kept");
|
||
let other = json!({"type": "solid", "color": {"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}, "swatch": "Red", "tint": 0.5});
|
||
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [other]}]})).unwrap(), None, &keep).unwrap();
|
||
assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
|
||
let pattern = json!({"objects": [{"id": a.0, "fills": [{"type": "pattern", "pattern": "Dots"}]}]});
|
||
assert!(apply_output(&mut d, &[a], decode_json(pattern.clone()).unwrap(), None, &keep).is_err());
|
||
d.patterns.push(serde_json::from_value(json!({"name": "Dots", "tile": {"x0": 0, "y0": 0, "x1": 10, "y1": 10}, "art": []})).unwrap());
|
||
assert!(apply_output(&mut d, &[a], decode_json(pattern).unwrap(), None, &keep).is_ok());
|
||
}
|
||
|
||
/// Output the host can't read as objects is refused, whatever is wrong with it.
|
||
#[test]
|
||
fn bad_outputs_are_refused() {
|
||
let (d, ids, _) = doc();
|
||
let a = ids[0].0;
|
||
let far = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e9, 0.0)], false));
|
||
for bad in [
|
||
json!([]),
|
||
json!({"objects": 3}),
|
||
json!({"objects": [7]}),
|
||
json!({"objects": [{"id": 999}]}),
|
||
json!({"objects": [{"id": a}, {"id": a}]}),
|
||
json!({"objects": [{"name": "no path"}]}),
|
||
json!({"objects": [{"id": a, "path": far}]}),
|
||
json!({"objects": [{"id": a, "path": {"subpaths": []}}]}),
|
||
json!({"objects": [{"id": a, "type": "text"}]}),
|
||
json!({"objects": [{"id": a, "fillRule": "odd"}]}),
|
||
json!({"objects": [{"id": a, "fills": [{"type": "gradient", "gradient": {"kind": "Linear", "stops": []}}]}]}),
|
||
json!({"objects": [{"id": a, "fills": [{"type": "solid"}]}]}),
|
||
json!({"objects": [{"id": a, "strokes": [3]}]}),
|
||
json!({"objects": [{"id": a, "opacity": "full"}]}),
|
||
json!({"error": "nothing to do"}),
|
||
] {
|
||
let mut d = d.clone();
|
||
let r = decode_json(bad.clone()).and_then(|out| apply_output(&mut d, &ids, out, None, &keep));
|
||
assert!(r.is_err(), "{bad}");
|
||
}
|
||
assert!(decode(b"\xff\xfe").is_err());
|
||
}
|
||
|
||
/// Strokes come with their join, cap and miter limit and objects with their effect records; both go
|
||
/// back the same way, the records only as the host admits them.
|
||
#[test]
|
||
fn stroke_styles_and_effect_records_round_trip() {
|
||
let (mut d, ids, _) = doc();
|
||
let a = ids[0];
|
||
let roughen = Effect { id: "distort.roughen".into(), params: json!({"size": 5.0}), visible: true };
|
||
d.node_mut(a).unwrap().appearance.effects.push(roughen.clone());
|
||
let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap();
|
||
let (stroke, effects) = (&input["objects"][0]["strokes"][0], &input["objects"][0]["effects"]);
|
||
assert_eq!((&stroke["join"], &stroke["cap"], &stroke["miterLimit"]), (&json!("miter"), &json!("butt"), &json!(10.0)));
|
||
assert_eq!(*effects, json!([{"id": "distort.roughen", "params": {"size": 5.0}, "visible": true}]));
|
||
// Handed back unchanged, nothing changes; the host sees the records and what the object had.
|
||
let seen = std::cell::RefCell::new(vec![]);
|
||
let admit = |fx: &[Effect], n: &Node| -> std::result::Result<Vec<Effect>, String> {
|
||
seen.borrow_mut().push((fx.to_vec(), n.appearance.effects.clone()));
|
||
Ok(fx.to_vec())
|
||
};
|
||
let before = d.clone();
|
||
apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &admit).unwrap();
|
||
assert_eq!(d, before);
|
||
assert_eq!(*seen.borrow(), [(vec![roughen.clone()], vec![roughen.clone()])]);
|
||
// A round join, a square cap, a miter limit past the range; the effects replaced.
|
||
let out = json!({"objects": [{"id": a.0, "strokes": [{"paint": null, "join": "round", "cap": "square", "miterLimit": 900}],
|
||
"effects": [{"id": "x.y", "params": {"k": 1}, "visible": false}, {"id": "z"}]}]});
|
||
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &admit).unwrap();
|
||
let n = d.node(a).unwrap();
|
||
let st = n.appearance.stroke().unwrap();
|
||
assert_eq!((st.join, st.cap, st.miter_limit, st.width), (LineJoin::Round, LineCap::Square, *MITER_LIMIT.end(), 1.0), "the weight kept");
|
||
let z = Effect { id: "z".into(), params: Value::Null, visible: true };
|
||
assert_eq!(n.appearance.effects, [Effect { id: "x.y".into(), params: json!({"k": 1}), visible: false }, z]);
|
||
// A new object's stroke takes what it is given; the rest is a new stroke's default.
|
||
let out = json!({"objects": [{"path": square(0.0), "strokes": [{"paint": null, "cap": "round"}]}]});
|
||
let layer = d.layers[0].id;
|
||
let new = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &admit).unwrap();
|
||
let st = d.node(new[0]).unwrap().appearance.stroke().unwrap().clone();
|
||
assert_eq!((st.cap, st.join, st.width, st.miter_limit), (LineCap::Round, LineJoin::Miter, 1.0, 10.0));
|
||
// What the host refuses changes nothing.
|
||
let before = d.clone();
|
||
let refuse = |_: &[Effect], _: &Node| -> std::result::Result<Vec<Effect>, String> { Err("not an effect".into()) };
|
||
let out = json!({"objects": [{"id": a.0, "effects": [{"id": "x.y"}]}]});
|
||
let e = apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &refuse).unwrap_err();
|
||
assert_eq!((e.to_string().contains("objects[0].effects: not an effect"), &d), (true, &before), "{e}");
|
||
}
|
||
|
||
/// Stroke styles and effect records in a form the host doesn't read are refused.
|
||
#[test]
|
||
fn bad_stroke_styles_and_effect_records_are_refused() {
|
||
let a = doc().1[0].0;
|
||
for bad in [json!({"join": "pointy"}), json!({"cap": 3}), json!({"miterLimit": "x"}), json!({"width": "wide"})] {
|
||
let mut stroke = json!({"paint": null});
|
||
stroke.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
|
||
assert!(decode_json(json!({"objects": [{"id": a, "strokes": [stroke]}]})).is_err(), "{bad}");
|
||
}
|
||
let long = "x".repeat(MAX_EFFECT_ID + 1);
|
||
for bad in [
|
||
json!(3),
|
||
json!([3]),
|
||
json!([{}]),
|
||
json!([{"id": ""}]),
|
||
json!([{"id": 5}]),
|
||
json!([{"id": long}]),
|
||
json!([{"id": "a\nb"}]),
|
||
json!([{"id": "a", "params": [1]}]),
|
||
json!([{"id": "a", "visible": "yes"}]),
|
||
] {
|
||
assert!(decode_json(json!({"objects": [{"id": a, "effects": bad}]})).is_err(), "{bad}");
|
||
}
|
||
}
|
||
|
||
/// A `w` × `h` PNG whose pixels vary with their column, half transparent.
|
||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||
let img = image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([(x * 100) as u8, (y * 7) as u8, 30, 128]));
|
||
let mut out = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||
out
|
||
}
|
||
|
||
/// An image object `w` × `h` pixels showing blob `key`, linked to `link`.
|
||
fn image_node(d: &mut Document, key: &str, (w, h): (u32, u32), link: Option<&str>) -> Node {
|
||
let im = vectorcraft_doc::ImageObject {
|
||
key: key.into(),
|
||
width: w,
|
||
height: h,
|
||
xf: Affine::new([2.0, 0.0, 0.0, 2.0, 40.0, 60.0]),
|
||
link: link.map(vectorcraft_doc::LinkInfo::new),
|
||
placement: Default::default(),
|
||
pixels: None,
|
||
};
|
||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||
}
|
||
|
||
/// [`doc`] with, above its paths: point type "T" in two paragraphs, an embedded 2 × 1 image "E", a
|
||
/// linked image "L", an instance "S" of symbol "Star" and an instance "G" of a symbol the document
|
||
/// doesn't have. Returns those five too.
|
||
fn mixed() -> (Document, Vec<NodeId>, NodeId, [NodeId; 5]) {
|
||
use vectorcraft_doc::{CharStyle, ParaStyle, Symbol, TextObject};
|
||
let (mut d, ids, layer) = doc();
|
||
let mut t = TextObject::point(Point::new(5.0, 50.0), "Hi\nthere", CharStyle::default());
|
||
t.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { space_before: 6.0, ..Default::default() }]);
|
||
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||
d.images.insert("e".into(), ImageBlob::png(png(2, 1)));
|
||
d.images.insert("l".into(), ImageBlob::png(png(4, 4)));
|
||
let embedded = image_node(&mut d, "e", (2, 1), None);
|
||
let linked = image_node(&mut d, "l", (4, 4), Some("C:/private/photo.png"));
|
||
let art = Arc::new(Node::path(d.alloc_id(), square(0.0), Appearance::default()));
|
||
d.symbols.push(Symbol { name: "Star".into(), art, dynamic: false });
|
||
let star = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::translate((100.0, 0.0)) });
|
||
let ghost = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
|
||
let mut extra = [NodeId(0); 5];
|
||
for (slot, (mut n, name)) in extra.iter_mut().zip([text, embedded, linked, star, ghost].into_iter().zip(["T", "E", "L", "S", "G"])) {
|
||
n.name = Some(name.into());
|
||
*slot = d.insert(Some(layer), usize::MAX, n).unwrap();
|
||
}
|
||
(d, ids, layer, extra)
|
||
}
|
||
|
||
/// A filter that doesn't list `inputs` is given what it was before: the selected paths alone, in
|
||
/// the same bytes. One that lists kinds is given those objects too, in paint order, under the same
|
||
/// rules (hidden and locked ones left out, selected ones or ones in a selected layer).
|
||
#[test]
|
||
fn legacy_filter_input_bytes_are_unchanged() {
|
||
let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed();
|
||
assert_eq!(filter_targets(&d, &[layer]), ids);
|
||
assert_eq!(filter_targets_with(&d, &[layer], &[]), ids);
|
||
let every: Vec<NodeId> = ids.iter().copied().chain([text, embedded, linked, star, ghost]).collect();
|
||
let (bytes, read_only) = encode_input_with(&d, &every, &[], usize::MAX).unwrap();
|
||
assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out");
|
||
assert!(read_only.is_empty());
|
||
let all = filter_targets_with(&d, &[layer], &InputKind::ALL);
|
||
assert_eq!(all, every);
|
||
let images: Vec<NodeId> = ids.iter().copied().chain([embedded, linked]).collect();
|
||
assert_eq!(filter_targets_with(&d, &[layer], &[InputKind::Image]), images);
|
||
assert_eq!(filter_targets_with(&d, &[star, ids[0]], &InputKind::ALL), [ids[0], star], "selected directly");
|
||
d.node_mut(text).unwrap().visible = false;
|
||
d.node_mut(star).unwrap().locked = true;
|
||
assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat());
|
||
let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance], usize::MAX).unwrap();
|
||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||
let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect();
|
||
assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]);
|
||
assert_eq!(read_only.len(), 2);
|
||
}
|
||
|
||
/// Type comes with its runs and their character attributes, one paragraph style per paragraph, its
|
||
/// kind and transform; type past [`MAX_INPUT_TEXT`] or [`MAX_INPUT_RUNS`] comes without text.
|
||
#[test]
|
||
fn declared_type_input_has_bounded_runs_and_paragraph_styles() {
|
||
use vectorcraft_doc::{CharStyle, ParaStyle, TextRun};
|
||
let (mut d, _, _, [text, ..]) = mixed();
|
||
let o = encode_read_only(&d, d.node(text).unwrap()).unwrap();
|
||
assert_eq!((&o["type"], &o["name"], &o["truncated"]), (&json!("type"), &json!("T"), &json!(false)));
|
||
assert_eq!(o["transform"], json!([1.0, 0.0, 0.0, 1.0, 5.0, 50.0]));
|
||
assert_eq!((&o["text"]["kind"], &o["text"]["vertical"]), (&json!("point"), &json!(false)));
|
||
assert_eq!(o["text"]["runs"], json!([{"text": "Hi\nthere", "style": serde_json::to_value(CharStyle::default()).unwrap()}]));
|
||
let spaced = ParaStyle { space_before: 6.0, ..Default::default() };
|
||
assert_eq!(o["text"]["paragraphs"], json!([serde_json::to_value(ParaStyle::default()).unwrap(), serde_json::to_value(spaced).unwrap()]));
|
||
assert!(o.get("path").is_none() && o.get("fills").is_none(), "not made into outlines");
|
||
let set_runs = |d: &mut Document, runs: Vec<TextRun>| {
|
||
if let NodeKind::Text(t) = &mut d.node_mut(text).unwrap().kind {
|
||
t.runs = runs;
|
||
t.paras.clear();
|
||
}
|
||
};
|
||
let run = |s: String| TextRun::new(s, CharStyle::default());
|
||
for (runs, truncated) in [
|
||
(vec![run("x".repeat(MAX_INPUT_TEXT))], false),
|
||
(vec![run("x".repeat(MAX_INPUT_TEXT - 1)), run("é".into())], true),
|
||
(vec![run("\n".repeat(MAX_INPUT_RUNS - 1))], false),
|
||
(vec![run("\n".repeat(MAX_INPUT_RUNS))], true),
|
||
((0..MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), false),
|
||
((0..=MAX_INPUT_RUNS).map(|_| run("a".into())).collect(), true),
|
||
] {
|
||
let n = runs.len();
|
||
set_runs(&mut d, runs);
|
||
let o = encode_read_only(&d, d.node(text).unwrap()).unwrap();
|
||
assert_eq!(o["truncated"], json!(truncated), "{n} runs");
|
||
let given = o["text"]["runs"].as_array().unwrap().len();
|
||
assert_eq!((given, o["text"]["paragraphs"].as_array().unwrap().is_empty()), (if truncated { 0 } else { n }, truncated), "{n} runs");
|
||
}
|
||
}
|
||
|
||
/// An embedded image comes with its pixels when they are small; a linked image never does, and
|
||
/// nothing of its file's path is given. The color mode is read from the image.
|
||
#[test]
|
||
fn declared_embedded_and_linked_images_have_distinct_metadata_and_pixel_omission() {
|
||
let (mut d, _, layer, [_, embedded, linked, ..]) = mixed();
|
||
let e = encode_read_only(&d, d.node(embedded).unwrap()).unwrap();
|
||
assert_eq!((&e["type"], &e["pixelSize"], &e["colorMode"]), (&json!("image"), &json!([2, 1]), &json!("rgb")));
|
||
assert_eq!((&e["linked"], &e["pixelsOmitted"], &e["transform"]), (&json!(false), &json!(false), &json!([2.0, 0.0, 0.0, 2.0, 40.0, 60.0])));
|
||
let pixels = craft_vectorcraft_format::base64::decode(e["pixelsBase64"].as_str().unwrap()).unwrap();
|
||
assert_eq!(pixels, [0, 0, 30, 128, 100, 0, 30, 128], "straight RGBA, row by row");
|
||
let l = encode_read_only(&d, d.node(linked).unwrap()).unwrap();
|
||
assert_eq!((&l["linked"], &l["pixelsOmitted"], &l["pixelSize"]), (&json!(true), &json!(true), &json!([4, 4])));
|
||
assert!(l.get("pixelsBase64").is_none());
|
||
assert!(!l.to_string().contains("private"), "the link's path isn't given: {l}");
|
||
// Past the limit (443 × 443 fits it; 444 × 443 doesn't), and pixels of another size, are omitted.
|
||
for ((w, h), omitted) in [((443, 443), false), ((444, 443), true)] {
|
||
d.images.insert("big".into(), ImageBlob::png(png(w, h)));
|
||
let n = image_node(&mut d, "big", (w, h), None);
|
||
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
|
||
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
|
||
assert_eq!((&o["pixelsOmitted"], o.get("pixelsBase64").is_some()), (&json!(omitted), !omitted), "{w} × {h}");
|
||
}
|
||
let n = image_node(&mut d, "e", (3, 1), None);
|
||
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
|
||
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "not the size it says");
|
||
// A size whose pixel count overflows is omitted too.
|
||
let n = image_node(&mut d, "e", (u32::MAX, u32::MAX), None);
|
||
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
|
||
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["pixelsOmitted"], json!(true), "too large to count");
|
||
// Gray pixels, and data that isn't an image.
|
||
let mut gray = vec![];
|
||
image::GrayImage::from_pixel(2, 2, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap();
|
||
for (blob, mode) in [(ImageBlob::png(gray), "gray"), (ImageBlob::png(b"not an image".to_vec()), "unknown")] {
|
||
d.images.insert("x".into(), blob);
|
||
let n = image_node(&mut d, "x", (2, 2), None);
|
||
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
|
||
assert_eq!(encode_read_only(&d, d.node(id).unwrap()).unwrap()["colorMode"], json!(mode));
|
||
}
|
||
}
|
||
|
||
/// A CMYK image's pixels are its inks as the working CMYK profile shows them on the canvas: black
|
||
/// ink alone is the profile's black, not RGB black.
|
||
#[test]
|
||
fn cmyk_image_pixels_go_through_the_working_cmyk_profile() {
|
||
use vectorcraft_color::cms::{self, lab};
|
||
let (mut d, _, layer, _) = mixed();
|
||
let inks = vectorcraft_doc::cmyk::Inks::new(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 0]).unwrap();
|
||
d.images.insert("k".into(), ImageBlob::cmyk_tiff(&inks).unwrap());
|
||
let n = image_node(&mut d, "k", (2, 1), None);
|
||
let id = d.insert(Some(layer), usize::MAX, n).unwrap();
|
||
let o = encode_read_only(&d, d.node(id).unwrap()).unwrap();
|
||
assert_eq!((&o["colorMode"], &o["pixelsOmitted"]), (&json!("cmyk"), &json!(false)));
|
||
let pixels = craft_vectorcraft_format::base64::decode(o["pixelsBase64"].as_str().unwrap()).unwrap();
|
||
let shown = |c: [f32; 4]| lab::xyz_to_srgb(lab::lab_to_xyz(cms::active().cmyk_display_lab(c))).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||
let ([r, g, b], [r2, g2, b2]) = (shown([0.0, 0.0, 0.0, 1.0]), shown([0.0; 4]));
|
||
assert_eq!(pixels, [r, g, b, 255, r2, g2, b2, 255]);
|
||
assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile");
|
||
}
|
||
|
||
/// A symbol instance comes with the symbol it shows and its transform, never its art; one whose
|
||
/// symbol the document lacks says so.
|
||
#[test]
|
||
fn declared_symbol_instance_keeps_identity_and_transform() {
|
||
let (d, _, _, [.., star, ghost]) = mixed();
|
||
let s = encode_read_only(&d, d.node(star).unwrap()).unwrap();
|
||
assert_eq!((&s["type"], &s["symbolId"], &s["overrides"]), (&json!("symbolInstance"), &json!("Star"), &json!({})));
|
||
assert_eq!(s["transform"], json!([1.0, 0.0, 0.0, 1.0, 100.0, 0.0]));
|
||
assert!(s.get("missing").is_none() && s.get("path").is_none());
|
||
let g = encode_read_only(&d, d.node(ghost).unwrap()).unwrap();
|
||
assert_eq!((&g["symbolId"], &g["missing"]), (&json!("Gone"), &json!(true)));
|
||
}
|
||
|
||
/// Pixels and type count against totals over the whole input, in order: an image whose pixels
|
||
/// don't fit what the images before it left comes with `pixelsOmitted` (its size checked before it
|
||
/// is decoded), and type whose runs and paragraphs don't fit comes with `truncated`. The read-only
|
||
/// objects returned are the ones in the input.
|
||
#[test]
|
||
fn input_totals_cover_images_and_type_in_order() {
|
||
let (mut d, _, layer, [text, embedded, ..]) = mixed();
|
||
let copy = |d: &mut Document, id: NodeId| {
|
||
let mut n = d.node(id).unwrap().clone();
|
||
n.id = d.alloc_id();
|
||
d.insert(Some(layer), usize::MAX, n).unwrap()
|
||
};
|
||
let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded));
|
||
let one = encode_read_only(&d, d.node(text).unwrap()).unwrap();
|
||
// The runs and paragraphs with the commas between them, without their brackets.
|
||
let size: usize = ["runs", "paragraphs"].iter().map(|k| one["text"][k].to_string().len() - 2).sum();
|
||
// A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't.
|
||
let ids = [text, text2, embedded, image2, image3];
|
||
let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size, input: usize::MAX }).unwrap();
|
||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||
let objects = v["objects"].as_array().unwrap();
|
||
assert_eq!(objects.len(), 5);
|
||
let flags: Vec<&Value> = objects.iter().map(|o| if o["type"] == "type" { &o["truncated"] } else { &o["pixelsOmitted"] }).collect();
|
||
assert_eq!(flags, [&json!(false), &json!(true), &json!(false), &json!(false), &json!(true)]);
|
||
assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none());
|
||
assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]);
|
||
for o in objects {
|
||
assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap())));
|
||
}
|
||
// Within the full totals every one of them is given.
|
||
let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap();
|
||
let all = String::from_utf8(bytes).unwrap();
|
||
assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}");
|
||
}
|
||
|
||
/// Every object counts toward the input's capacity in order, its id, bounds, transform and name
|
||
/// included: a symbol instance before type leaves the type less room, so text that fits the exact
|
||
/// size of the whole input comes with `truncated` one byte under it, and an image's pixels are
|
||
/// omitted the same way.
|
||
#[test]
|
||
fn object_fields_count_toward_the_input_capacity() {
|
||
let (d, ids, _, [text, embedded, _, star, _]) = mixed();
|
||
for (order, flag) in [([ids[0], star, text], "truncated"), ([ids[0], star, embedded], "pixelsOmitted")] {
|
||
let (bytes, _) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap();
|
||
let (exact, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len()).unwrap();
|
||
assert_eq!(exact, bytes, "{flag}: the exact size fits");
|
||
let (under, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len() - 1).unwrap();
|
||
let v: Value = serde_json::from_slice(&under).unwrap();
|
||
assert_eq!(v["objects"][2][flag], json!(true), "{flag}: one byte under");
|
||
assert!(under.len() < bytes.len() - 1, "{flag}");
|
||
}
|
||
}
|
||
|
||
/// Type with fractional colors counts the bytes the input gives it: one byte under its exact size
|
||
/// it comes with `truncated`, not a limit.
|
||
#[test]
|
||
fn fractional_colors_count_as_the_input_writes_them() {
|
||
use vectorcraft_doc::{CharStyle, TextObject};
|
||
let (mut d, ids, layer, _) = mixed();
|
||
let style = CharStyle { fill: Paint::solid(Color::rgb(0.1, 0.2, 0.3)), ..CharStyle::default() };
|
||
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Hi", style))));
|
||
let text = d.insert(Some(layer), usize::MAX, t).unwrap();
|
||
let order = [ids[0], text];
|
||
let (bytes, _) = encode_input_with(&d, &order, &[InputKind::Type], usize::MAX).unwrap();
|
||
let (under, _) = encode_input_with(&d, &order, &[InputKind::Type], bytes.len() - 1).unwrap();
|
||
let v: Value = serde_json::from_slice(&under).unwrap();
|
||
assert_eq!(v["objects"][1]["truncated"], json!(true));
|
||
}
|
||
|
||
/// A read-only object whose fields other than its pixels and text alone don't fit what is left of
|
||
/// the input fails the run with a limit naming it and its size, rather than being left out.
|
||
#[test]
|
||
fn an_object_too_large_to_give_is_a_limit_naming_it() {
|
||
let (mut d, ids, _, [_, _, linked, star, _]) = mixed();
|
||
d.node_mut(linked).unwrap().name = Some("n".repeat(4096));
|
||
let (fits, _) = encode_input_with(&d, &[ids[0], star], &InputKind::ALL, usize::MAX).unwrap();
|
||
let order = [ids[0], star, linked];
|
||
let e = encode_input_with(&d, &order, &InputKind::ALL, fits.len() + 4096).unwrap_err();
|
||
assert!(matches!(e, Error::Limit(_)), "{e}");
|
||
// Measured with `pixelsOmitted` false and a comma before it.
|
||
let mut o = encode_read_only(&d, d.node(linked).unwrap()).unwrap();
|
||
o["pixelsOmitted"] = json!(false);
|
||
let size = o.to_string().len() + 1;
|
||
assert_eq!(e.to_string(), format!("plug-in exceeded its memory budget (object {} needs {size} bytes of input, 4096 left)", linked.0));
|
||
// A filter that doesn't list its kind is given the rest.
|
||
let (bytes, read_only) = encode_input_with(&d, &order, &[InputKind::SymbolInstance], fits.len() + 4096).unwrap();
|
||
assert_eq!((bytes, read_only.len()), (fits, 1));
|
||
}
|
||
|
||
/// A path or compound path that doesn't fit what is left of the input fails the run with the limit
|
||
/// the runtime gives an input past its share, before anything after it is written; the exact size
|
||
/// still fits.
|
||
#[test]
|
||
fn a_path_past_the_input_capacity_is_the_runtime_limit() {
|
||
let (d, ids, _, [text, ..]) = mixed();
|
||
let (bytes, _) = encode_input_with(&d, &ids, &[], usize::MAX).unwrap();
|
||
assert_eq!(encode_input_with(&d, &ids, &[], bytes.len()).unwrap().0, bytes, "the exact size fits");
|
||
let e = encode_input_with(&d, &[ids[0], ids[1], ids[2], text], &[InputKind::Type], bytes.len() - 1).unwrap_err();
|
||
assert_eq!(e.to_string(), "plug-in exceeded its memory budget (the selection is too large to hand over)");
|
||
}
|
||
|
||
/// An output lists at most [`MAX_OBJECTS`] objects and must return every read-only one, so an input
|
||
/// with more read-only objects than that is a limit; exactly that many are given.
|
||
#[test]
|
||
fn more_read_only_objects_than_an_output_lists_is_a_limit() {
|
||
let (mut d, _, layer, [.., ghost]) = mixed();
|
||
let mut ids = vec![ghost];
|
||
for _ in 1..MAX_OBJECTS {
|
||
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
|
||
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
|
||
}
|
||
let (_, read_only) = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap();
|
||
assert_eq!(read_only.len(), MAX_OBJECTS);
|
||
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Gone".into(), xf: Affine::IDENTITY });
|
||
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
|
||
let e = encode_input_with(&d, &ids, &[InputKind::SymbolInstance], usize::MAX).unwrap_err();
|
||
assert_eq!(e.to_string(), format!("plug-in exceeded its object budget (more than {MAX_OBJECTS} read-only objects)"));
|
||
// A filter that doesn't list the kind is given none of them.
|
||
assert!(encode_input_with(&d, &ids, &[], usize::MAX).unwrap().1.is_empty());
|
||
}
|
||
|
||
/// Objects that clip a clip group are left out, and so are those inside a group that clips one;
|
||
/// those it clips are given. This covers a filter that lists no `inputs` too.
|
||
#[test]
|
||
fn objects_that_clip_a_clip_group_are_not_inputs() {
|
||
let (mut d, _, layer, [text, embedded, ..]) = mixed();
|
||
let (t, e) = (Arc::new(d.node(text).unwrap().clone()), Arc::new(d.node(embedded).unwrap().clone()));
|
||
let mut copy = |n: &Arc<Node>| {
|
||
let mut c = (**n).clone();
|
||
c.id = d.alloc_id();
|
||
Arc::new(c)
|
||
};
|
||
let (t_source, e_source, t_inner, e_inner, t_clipped) = (copy(&t), copy(&e), copy(&t), copy(&e), copy(&t));
|
||
let inner = Node::group(d.alloc_id(), vec![t_inner.clone(), e_inner.clone()]);
|
||
let mut by_group = Node::group(d.alloc_id(), vec![Arc::new(inner), t_clipped.clone()]);
|
||
by_group.set_clips(true);
|
||
let mut by_type = Node::group(d.alloc_id(), vec![t_source.clone(), e_source.clone()]);
|
||
by_type.set_clips(true);
|
||
let by_group = d.insert(Some(layer), usize::MAX, by_group).unwrap();
|
||
let by_type = d.insert(Some(layer), usize::MAX, by_type).unwrap();
|
||
let path = |d: &mut Document, x: f64| Arc::new(Node::path(d.alloc_id(), square(x), Appearance::default()));
|
||
let (part, clipped_by_compound, in_source, clipped_by_paths) = (path(&mut d, 0.0), path(&mut d, 10.0), path(&mut d, 20.0), path(&mut d, 30.0));
|
||
let compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![part], rule: FillRule::NonZero });
|
||
let mut by_compound = Node::group(d.alloc_id(), vec![Arc::new(compound), clipped_by_compound.clone()]);
|
||
by_compound.set_clips(true);
|
||
let source = Node::group(d.alloc_id(), vec![in_source]);
|
||
let mut by_paths = Node::group(d.alloc_id(), vec![Arc::new(source), clipped_by_paths.clone()]);
|
||
by_paths.set_clips(true);
|
||
let by_compound = d.insert(Some(layer), usize::MAX, by_compound).unwrap();
|
||
let by_paths = d.insert(Some(layer), usize::MAX, by_paths).unwrap();
|
||
let ids = |list: &[NodeId]| filter_targets_with(&d, list, &InputKind::ALL);
|
||
assert_eq!(ids(&[by_type]), [e_source.id], "the type clips; the image is clipped");
|
||
assert_eq!(ids(&[by_group]), [t_clipped.id], "the group's type and image clip");
|
||
assert_eq!(ids(&[by_compound]), [clipped_by_compound.id], "the compound path clips");
|
||
assert_eq!(ids(&[by_paths]), [clipped_by_paths.id], "the group's path clips");
|
||
assert_eq!(filter_targets(&d, &[by_compound]), [clipped_by_compound.id], "a filter without inputs");
|
||
assert_eq!(filter_targets(&d, &[by_paths]), [clipped_by_paths.id], "a filter without inputs");
|
||
}
|
||
|
||
/// A TIFF with more strips and planes than the decoder can count comes as `"unknown"`, without its
|
||
/// pixels, and never reaches the decoder.
|
||
#[test]
|
||
fn a_tiff_too_large_to_count_is_unknown_and_has_no_pixels() {
|
||
let entries: [(u16, u16, u32); 9] =
|
||
[(256, 4, 1), (257, 4, 1 << 31 | 1), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
|
||
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
|
||
tiff.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
|
||
for (tag, ty, v) in entries {
|
||
tiff.extend(tag.to_le_bytes());
|
||
tiff.extend(ty.to_le_bytes());
|
||
tiff.extend(1u32.to_le_bytes());
|
||
tiff.extend(v.to_le_bytes());
|
||
}
|
||
tiff.extend([0; 4]);
|
||
let (mut d, _, layer, _) = mixed();
|
||
d.images.insert("big".into(), ImageBlob::new("image/tiff", tiff));
|
||
let big = image_node(&mut d, "big", (1, 1), None);
|
||
let big = d.insert(Some(layer), usize::MAX, big).unwrap();
|
||
let (bytes, _) = encode_input_with(&d, &[big], &[InputKind::Image], usize::MAX).unwrap();
|
||
let input: Value = serde_json::from_slice(&bytes).unwrap();
|
||
assert_eq!(input["objects"][0]["colorMode"], "unknown");
|
||
assert_eq!(input["objects"][0]["pixelsOmitted"], true);
|
||
}
|
||
|
||
/// A field a plug-in adds to a read-only object is named in the refusal with its control and
|
||
/// direction characters escaped, and cut short.
|
||
#[test]
|
||
fn a_changed_field_is_named_escaped_and_short() {
|
||
let (d, _, layer, [text, ..]) = mixed();
|
||
let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]);
|
||
let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type], usize::MAX).unwrap();
|
||
let mut v: Value = serde_json::from_slice(&bytes).unwrap();
|
||
let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap();
|
||
let key = format!("\u{202E}{}", "k".repeat(500));
|
||
v["objects"][at][key.as_str()] = json!(1);
|
||
let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string();
|
||
let shown = e.split('\'').nth(1).unwrap_or_default();
|
||
assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}");
|
||
assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}");
|
||
}
|
||
|
||
/// A filter given read-only objects returns each one as `{"id": …}` or exactly as given (numbers by
|
||
/// value), and they stay as they are, new objects listed after one going just above it. Any change,
|
||
/// an object left out or listed twice, and a new object of such a kind are refused before anything
|
||
/// changes.
|
||
#[test]
|
||
fn read_only_kind_field_and_omission_refuse() {
|
||
let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed();
|
||
let targets = filter_targets_with(&d, &[layer], &InputKind::ALL);
|
||
let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL, usize::MAX).unwrap();
|
||
assert_eq!(read_only.len(), 5);
|
||
let input: Value = serde_json::from_slice(&bytes).unwrap();
|
||
let before = d.clone();
|
||
let out = decode_output_with(&bytes, &read_only).unwrap();
|
||
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
|
||
apply_output(&mut d, &targets, out.objects, None, &keep).unwrap();
|
||
assert_eq!(d, before, "an exact echo changes nothing");
|
||
// A filter that doesn't list `inputs` could never hand them back.
|
||
assert!(decode_output(&bytes).is_err());
|
||
// Numbers written another way are the same numbers.
|
||
let mut echo = input.clone();
|
||
echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]);
|
||
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok());
|
||
// So is an id written as a float; a fractional one names no object.
|
||
let id = input["objects"][3]["id"].as_u64().unwrap();
|
||
let mut echo = input.clone();
|
||
echo["objects"][3]["id"] = json!(id as f64);
|
||
let out = decode_output_with(echo.to_string().as_bytes(), &read_only).unwrap();
|
||
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
|
||
echo["objects"][3]["id"] = json!(id as f64 + 0.5);
|
||
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_err());
|
||
// `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type.
|
||
let ro = |id: NodeId| json!({"id": id.0});
|
||
let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]});
|
||
let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap();
|
||
assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]);
|
||
assert_eq!(made.len(), 6);
|
||
assert!(ids.iter().all(|id| d.node(*id).is_none()));
|
||
assert_eq!(d.node(text), before.node(text), "the type is as it was");
|
||
// Refused, naming the object and the field.
|
||
let t = text.0;
|
||
let changed = |f: &dyn Fn(&mut Value)| {
|
||
let mut v = input.clone();
|
||
f(&mut v);
|
||
v
|
||
};
|
||
for (out, says) in [
|
||
(
|
||
changed(&|v| v["objects"][3]["text"]["runs"][0]["text"] = json!("Bye")),
|
||
format!("objects[3] (id {t}): read-only field 'text.runs[0].text' changed"),
|
||
),
|
||
(changed(&|v| v["objects"][3]["type"] = json!("path")), format!("(id {t}): read-only field 'type' changed")),
|
||
(changed(&|v| v["objects"][3]["opacity"] = json!(0.5)), format!("(id {t}): read-only field 'opacity' changed")),
|
||
(changed(&|v| v["objects"][3]["transform"][4] = json!(6)), format!("(id {t}): read-only field 'transform[4]' changed")),
|
||
(
|
||
changed(&|v| {
|
||
v["objects"][4].as_object_mut().unwrap().remove("pixelsBase64");
|
||
}),
|
||
format!("(id {}): read-only field 'pixelsBase64' changed", embedded.0),
|
||
),
|
||
(changed(&|v| v["objects"][6]["symbolId"] = json!("Moon")), format!("(id {}): read-only field 'symbolId' changed", star.0)),
|
||
(
|
||
changed(&|v| {
|
||
v["objects"].as_array_mut().unwrap().remove(3);
|
||
}),
|
||
format!("objects (id {t}): a read-only object was left out"),
|
||
),
|
||
(changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"id": ghost.0}))), format!("objects[8] (id {}): is listed twice", ghost.0)),
|
||
(
|
||
changed(&|v| v["objects"].as_array_mut().unwrap().push(json!({"type": "type", "path": square(0.0)}))),
|
||
"`type` must be \"path\" or \"compound\"".to_string(),
|
||
),
|
||
] {
|
||
let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string();
|
||
assert!(e.contains(&says), "{says}: {e}");
|
||
}
|
||
// Writing one or leaving one out is refused when applying too, whatever decoded the output.
|
||
let mut d = before.clone();
|
||
let write = vec![OutObject { id: Some(text), name: Some("Renamed".into()), ..OutObject::default() }];
|
||
let e = apply_output(&mut d, &[text], write, None, &keep).unwrap_err().to_string();
|
||
assert!(e.contains("is read-only"), "{e}");
|
||
let e = apply_output(&mut d, &[text], vec![], None, &keep).unwrap_err().to_string();
|
||
assert!(e.contains("a read-only object was left out"), "{e}");
|
||
assert_eq!(d, before);
|
||
}
|
||
|
||
/// A filter's `documentData`: absent keeps what the plug-in keeps, `null` forgets it, any other JSON
|
||
/// of at most [`MAX_DOCUMENT_DATA`] bytes is kept, and more refuses the whole output.
|
||
#[test]
|
||
fn document_data_is_kept_forgotten_or_refused() {
|
||
let out = |data: Option<Value>| {
|
||
let mut v = json!({"objects": []});
|
||
if let Some(data) = data {
|
||
v["documentData"] = data;
|
||
}
|
||
decode_output(v.to_string().as_bytes()).map(|o| o.document_data)
|
||
};
|
||
assert_eq!(out(None).unwrap(), None);
|
||
assert_eq!(out(Some(Value::Null)).unwrap(), Some(None));
|
||
assert_eq!(out(Some(json!({"pens": 2}))).unwrap(), Some(Some(json!({"pens": 2}))));
|
||
// A string's quotes count: this one is the limit exactly, and one more byte refuses it.
|
||
let at = json!("x".repeat(MAX_DOCUMENT_DATA - 2));
|
||
assert_eq!(out(Some(at.clone())).unwrap(), Some(Some(at)));
|
||
assert!(matches!(out(Some(json!("x".repeat(MAX_DOCUMENT_DATA - 1)))), Err(Error::Output(_))));
|
||
// A decline is still a decline.
|
||
assert!(matches!(decode_output(br#"{"error": "No pens.", "documentData": 1}"#), Err(Error::Failed(_))));
|
||
}
|