Files
vectorcraft/crates/engine/src/tests_place.rs
T
2026-10-23 09:07:55 +07:00

794 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File → Place (`file.place`, `file.place.info`, `image.info`) and the place cursor
//! (`file.place.queue` and the `place` tool).
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// A `w`×`h` PNG of one colour (`rgba`), declaring `ppi` when given.
fn png(w: u32, h: u32, rgba: [u8; 4], ppi: Option<f64>) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::from_pixel(w, h, image::Rgba(rgba)).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
match ppi {
Some(r) => cmd::fileio::ppi::with_png_resolution(&out, (r, r)),
None => out,
}
}
fn file(name: &str, bytes: &[u8]) -> Value {
json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })
}
/// `file.place` of `bytes` named `name`, with `extra` params.
fn place(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> Value {
let mut p = file(name, bytes);
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
s.execute("file.place", &p).unwrap()
}
fn node(s: &Session, id: &Value) -> Node {
s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone()
}
fn top_children(s: &Session) -> Vec<Arc<Node>> {
s.doc().unwrap().doc.layers.last().unwrap().children().unwrap().clone()
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn serialize(s: &mut Session, format: &str) -> Vec<u8> {
let v = s.execute("document.serialize", &json!({"format": format})).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
fn eps_of(s: &mut Session) -> Vec<u8> {
let v = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
/// `file.place.info`'s width and height for `bytes` named `name`, with `extra` params.
fn info_size(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> (f64, f64) {
let mut p = file(name, bytes);
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
let r = s.execute("file.place.info", &p).unwrap();
(r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap())
}
/// The bounds of what the active document's art draws ([`vectorcraft_render::encode::art_bounds`])
/// lie within a millionth of a point of `want`.
fn drawn_at(s: &Session, want: Rect) -> bool {
let b = vectorcraft_render::encode::art_bounds(&s.doc().unwrap().doc).unwrap_or_default();
[b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-6)
}
/// A 400 × 300 pt document holding one group: a 40 × 40 pt square at (20, 20) and, hidden, a
/// 170 × 42 pt rectangle at (100, 100). Returns it with the square's visual bounds.
fn group_with_a_hidden_member() -> (Session, Rect) {
let mut s = session();
let square = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
let wide = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 170, "height": 42})).unwrap()["id"].clone();
s.execute("object.group", &json!({"ids": [square, wide]})).unwrap();
s.execute("object.hide", &json!({"ids": [wide]})).unwrap();
assert!(!node(&s, &wide).visible);
let shown = node(&s, &square).visual_bounds().unwrap();
(s, shown)
}
/// How many of `n` and the objects in it are hidden.
fn hidden_in(n: &Node) -> usize {
let mut hidden = 0;
n.walk(&mut |c| hidden += usize::from(!c.visible));
hidden
}
#[test]
fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() {
let mut s = session();
let png = png(4, 2, [20, 40, 60, 255], None);
let original = s.doc().unwrap().doc.layers.clone();
// Previously filter_map dropped the string from a five-element rectangle and
// treated the remaining four values as a different, valid placement.
for rect in [json!([0, "ignored", 10, 40, 50]), json!([10, 20, 40, null, 50]), json!([10, 20, 40, 50, 60])] {
let mut params = file("tiny.png", &png);
params["rect"] = rect;
assert!(s.execute("file.place", &params).is_err(), "{params}");
assert_eq!(s.doc().unwrap().doc.layers, original, "failed Place must not add art");
}
// An explicitly supplied invalid position formerly behaved as though no
// position were supplied and centred the image on the artboard.
for at in [json!(["not-a-number", 20]), json!([10, null]), json!([10, 20, 30]), json!("invalid")] {
let mut params = file("tiny.png", &png);
params["at"] = at;
assert!(s.execute("file.place", &params).is_err(), "{params}");
assert_eq!(s.doc().unwrap().doc.layers, original);
}
let mut params = file("tiny.png", &png);
params["rect"] = json!([10, 20, 80, 60]);
let placed = s.execute("file.place", &params).unwrap();
assert_eq!(placed["ids"].as_array().unwrap().len(), 1);
// A null position is no position, as for the other optional params.
let mut params = file("tiny.png", &png);
params["at"] = Value::Null;
assert_eq!(s.execute("file.place", &params).unwrap()["ids"].as_array().unwrap().len(), 1);
}
#[test]
fn a_300_px_png_at_300_ppi_places_as_72_pt_centred_selected_and_undoable() {
let mut s = session();
let r = place(&mut s, "photo.png", &png(300, 150, [255, 0, 0, 255], Some(300.0)), json!({}));
assert_eq!((r["format"].as_str(), r["linked"].as_bool()), (Some("png"), Some(false)), "bytes have no path to link");
assert!(close(r["width"].as_f64().unwrap(), 72.0) && close(r["height"].as_f64().unwrap(), 36.0), "{r}");
let id = &r["ids"][0];
let n = node(&s, id);
assert_eq!(n.name.as_deref(), Some("photo.png"));
let b = n.geometric_bounds().unwrap();
assert!(close(b.center().x, 200.0) && close(b.center().y, 150.0), "centred on the artboard: {b:?}");
let st = s.doc().unwrap();
assert_eq!(st.selection.objects, [NodeId(id.as_u64().unwrap())]);
assert_eq!((st.history.undo.len(), st.history.undo[0].label.as_str()), (1, "Place"));
// No resolution: 72 ppi, one point per pixel; `at` centres it there.
let r = place(&mut s, "plain.png", &png(30, 20, [0, 0, 255, 255], None), json!({"at": [50, 60]}));
let b = node(&s, &r["ids"][0]).geometric_bounds().unwrap();
assert!(close(b.width(), 30.0) && close(b.center().x, 50.0) && close(b.center().y, 60.0), "{b:?}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(top_children(&s).is_empty(), "each Place is one undo step");
}
#[test]
fn rect_fits_the_art_inside_keeping_its_aspect() {
let mut s = session();
let r = place(&mut s, "wide.png", &png(40, 20, [0, 0, 0, 255], None), json!({"rect": [10, 10, 100, 100]}));
let b = node(&s, &r["ids"][0]).geometric_bounds().unwrap();
assert!(close(b.width(), 100.0) && close(b.height(), 50.0) && close(b.center().y, 60.0), "{b:?}");
}
#[test]
fn svg_with_a_data_png_renders_and_leaves_the_clipboard_alone() {
let mut s = session();
let rect = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let clipboard = s.clipboard.clone();
let red = vectorcraft_format::base64_encode(&png(4, 2, [255, 0, 0, 255], None));
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><image width="40" height="20" href="data:image/png;base64,{red}"/><rect width="10" height="10" fill="#0000ff"/></svg>"##
);
let r = place(&mut s, "art.svg", svg.as_bytes(), json!({"at": [100, 100]}));
assert_eq!(r["format"], "svg");
let g = node(&s, &r["ids"][0]);
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("one group: {:?}", g.kind) };
assert_eq!(children.len(), 2);
assert_eq!(g.name.as_deref(), Some("art.svg"));
assert_eq!(s.clipboard, clipboard, "Place never touches the clipboard");
let doc = &s.doc().unwrap().doc;
assert!(doc.images.len() == 1, "the embedded image joined the document");
// The group spans 80..120 × 90..110: blue square top left, red image elsewhere.
let img = vectorcraft_testkit::raster::render_region(doc, Rect::new(80.0, 90.0, 120.0, 110.0), 1.0);
assert_eq!(img.over_white(30, 15), [255, 0, 0]);
assert_eq!(img.over_white(4, 4), [0, 0, 255]);
}
#[test]
fn replace_keeps_the_transform_and_the_stacking_place() {
let mut s = session();
let first = place(&mut s, "a.png", &png(100, 50, [255, 0, 0, 255], None), json!({"at": [150, 150]}))["ids"][0].clone();
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
s.execute("object.scale", &json!({"sx": 50})).unwrap();
let centre = node(&s, &first).geometric_bounds().unwrap().center();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("select.set", &json!({"ids": [first]})).unwrap();
assert!(s.execute("file.place", &json!({"replace": true, "at": [0, 0], "name": "b.png", "dataBase64": ""})).is_err());
let r = place(&mut s, "b.png", &png(20, 20, [0, 255, 0, 255], Some(144.0)), json!({"replace": true}));
let id = &r["ids"][0];
assert_eq!(top_children(&s)[0].id.0, id.as_u64().unwrap(), "same place in the stacking order");
assert_eq!(top_children(&s).len(), 2);
let c = node(&s, id).geometric_bounds().unwrap().center();
assert!(close(c.x, centre.x) && close(c.y, centre.y), "{c:?} vs {centre:?}");
// 144 ppi at the old image's 50%: 288 ppi, rotated as it was.
let info = s.execute("image.info", &json!({})).unwrap();
assert!(close(info["ppi"][0].as_f64().unwrap(), 288.0) && close(info["ppi"][1].as_f64().unwrap(), 288.0), "{info}");
let NodeKind::Image(im) = &node(&s, id).kind else { panic!() };
let [a, b, ..] = im.xf.as_coeffs();
assert!(close(b.atan2(a).to_degrees(), -30.0), "{:?}", im.xf);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(top_children(&s)[0].id.0, first.as_u64().unwrap(), "one undo step brings the old image back");
}
#[test]
fn replace_needs_exactly_one_selected_object() {
let mut s = session();
let r = s
.execute("file.place", &json!({"replace": true, "name": "b.png", "dataBase64": vectorcraft_format::base64_encode(&png(2, 2, [0; 4], None))}));
assert!(r.unwrap_err().to_string().contains("select the one object"));
}
/// `p` with the members of `extra` added.
fn with(mut p: Value, extra: Value) -> Value {
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
p
}
/// A file read apart from the session ([`Session::prepare_place`], then [`cmd::place::PlaceJob::read`]
/// on another thread and [`Session::place_read`]), as File › Place reads one off the UI thread, is
/// placed as `file.place` places it: the same reply, document, selection and undo step, and the
/// same journal entry.
#[test]
fn a_file_read_apart_from_the_session_places_as_file_place_does() {
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
src.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let (pdf, native, eps) = (serialize(&mut src, "pdf"), serialize(&mut src, "vectorcraft"), eps_of(&mut src));
// SVG is serialized as text.
let svg = src.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().as_bytes().to_vec();
let dxf = serialize(&mut src, "dxf");
let dir = std::env::temp_dir().join(format!("vc-place-apart-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let saved = |name: &str, bytes: &[u8]| {
let p = dir.join(name);
std::fs::write(&p, bytes).unwrap();
p.to_string_lossy().into_owned()
};
let (pdf_path, native_path) = (saved("page.pdf", &pdf), saved("art.vectorcraft", &native));
let words = super::tests_textimport::docx(r#"<w:p><w:r><w:t>Placed words</w:t></w:r></w:p>"#, super::tests_textimport::STYLES);
let cases = [
with(file("red.png", &png(30, 20, [255, 0, 0, 255], Some(144.0))), json!({"at": [150, 100]})),
with(file("notes.txt", b"Two lines\nof text"), json!({"text": {}})),
file("note.rtf", br"{\rtf1\ansi {\b Bold} words\par}"),
file("words.docx", &words),
with(file("art.svg", &svg), json!({"rect": [10, 10, 80, 40]})),
file("art.eps", &eps),
file("plan.dxf", &dxf),
json!({"path": pdf_path}),
json!({"path": pdf_path, "link": false, "crop": "bounding"}),
json!({"path": native_path}),
with(file("page.pdf", &pdf), json!({"template": true})),
with(file("blue.png", &png(8, 8, [0, 0, 255, 255], None)), json!({"replace": true})),
];
for p in cases {
let name = p["name"].as_str().or(p["path"].as_str()).map(cmd::fileio::file_name).unwrap_or_default();
let (mut a, mut b) = (session(), session());
for s in [&mut a, &mut b] {
let id = s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 30})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
}
let at_once = a.execute("file.place", &p).map_err(|e| e.to_string());
assert!(at_once.is_ok(), "{name}: {at_once:?}");
let job = b.prepare_place(&p).unwrap();
assert_eq!(job.name(), name);
let read = std::thread::spawn(move || job.read()).join().unwrap().unwrap();
assert_eq!(b.place_read(read).map_err(|e| e.to_string()), at_once, "{name}: the same reply");
let (sa, sb) = (a.doc().unwrap(), b.doc().unwrap());
// `file.new` stamps each document with the second it was made in, which can differ by one.
let undated = |d: &vectorcraft_doc::Document| {
let mut d = d.clone();
d.metadata.created = None;
d
};
assert!(undated(&sa.doc) == undated(&sb.doc), "{name}: the same document");
assert_eq!(sa.selection.objects, sb.selection.objects, "{name}");
assert_eq!(sa.history.undo.len(), sb.history.undo.len(), "{name}: one undo step");
assert_eq!(a.journal.last(), b.journal.last(), "{name}");
assert_eq!(b.journal.last().map(|(id, _)| id.as_str()), Some("file.place"), "{name}: journaled as file.place");
}
let _ = std::fs::remove_dir_all(&dir);
// With no document open, the error the commands give.
let mut none = Session::new();
let p = file("red.png", &png(2, 2, [0; 4], None));
let err = |r: Result<Value>| r.err().map(|e| e.to_string());
assert_eq!(none.prepare_place(&p).err().map(|e| e.to_string()), err(none.execute("file.place", &p)));
let q = json!({"files": [p]});
assert_eq!(none.prepare_place_queue(&q).err().map(|e| e.to_string()), err(none.execute("file.place.queue", &q)));
}
/// Replace swaps the object selected when Place started, even when another one is selected by the
/// time the file has been read, and the journal replays the same; when that object was deleted or
/// changed meanwhile, nothing is placed.
#[test]
fn a_file_read_apart_from_the_session_replaces_what_was_selected_when_place_started() {
let two = || {
let mut s = session();
let first = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let other = s.execute("shape.rectangle", &json!({"x": 200, "y": 100, "width": 20, "height": 20})).unwrap()["id"].clone();
(s, first, other)
};
let (mut s, first, other) = two();
let p = with(file("blue.png", &png(8, 8, [0, 0, 255, 255], None)), json!({"replace": true}));
s.execute("select.set", &json!({"ids": [first]})).unwrap();
let read = s.prepare_place(&p).unwrap().read().unwrap();
s.execute("select.set", &json!({"ids": [other]})).unwrap();
let r = s.place_read(read).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.node(NodeId(first.as_u64().unwrap())).is_none(), "the object selected when Place started is replaced");
assert!(st.doc.node(NodeId(other.as_u64().unwrap())).is_some());
assert_eq!(st.selection.objects, [NodeId(r["ids"][0].as_u64().unwrap())]);
// The journal's last two entries, run where the other object is selected, replace the same one.
let last: Vec<_> = s.journal.iter().rev().take(2).rev().cloned().collect();
assert_eq!(last.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["select.set", "file.place"]);
let (mut replay, _, _) = two();
replay.execute("select.set", &json!({"ids": [other]})).unwrap();
for (id, p) in &last {
replay.execute(id, p).unwrap();
}
let undated = |s: &Session| {
let mut d = (*s.doc().unwrap().doc).clone();
d.metadata.created = None;
d
};
assert!(undated(&replay) == undated(&s), "the replay replaces the same object");
// The object to replace is deleted before the file has been read.
let read = s.prepare_place(&p).unwrap().read().unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let (doc, undo, journal) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len(), s.journal.len());
let err = s.place_read(read).unwrap_err();
assert!(err.to_string().contains("the object to replace was changed or deleted"), "{err}");
let st = s.doc().unwrap();
assert!(*st.doc == *doc && st.history.undo.len() == undo && s.journal.len() == journal, "nothing placed or journaled");
// The object to replace is moved, or an undo removes it and the next object made takes its id,
// before the file has been read.
for undone in [false, true] {
let mut s = session();
let id = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let read = s.prepare_place(&p).unwrap().read().unwrap();
if undone {
s.execute("edit.undo", &json!({})).unwrap();
let next = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap()["id"].clone();
assert_eq!(next, id, "the next object takes the id");
} else {
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
}
let (doc, undo, journal) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len(), s.journal.len());
let err = s.place_read(read).unwrap_err();
assert!(err.to_string().contains("the object to replace was changed or deleted"), "undone {undone}: {err}");
let st = s.doc().unwrap();
assert!(*st.doc == *doc && st.history.undo.len() == undo && s.journal.len() == journal, "undone {undone}: nothing placed or journaled");
}
}
/// A linked image keeps the preview its file was read with ([`cmd::place::PlaceJob::read`] makes
/// it), so placing the read file does not decode the image again; an embedded image has none.
#[test]
fn a_linked_image_keeps_the_preview_made_when_its_file_was_read() {
let blob = vectorcraft_doc::ImageBlob::new("image/png", png(600, 300, [9, 9, 9, 255], None)).with_proxy();
let made = blob.proxy.clone().unwrap();
let mut d = vectorcraft_doc::Document::new(100.0, 100.0);
cmd::links::store_image(&mut d, "linked", blob.clone(), true);
assert!(d.images["linked"].proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &made)), "the preview it was read with");
cmd::links::store_image(&mut d, "embedded", blob, false);
assert!(d.images["embedded"].proxy.is_none());
}
/// Type and a DXF drawing fitted to the artboard, read apart from the session while the artboard
/// under `at` changes size, are placed against the artboard as it is when they are placed: as
/// `file.place` places them after the change, which is what a replay of the journal does.
#[test]
fn a_file_read_apart_from_the_session_is_placed_against_the_artboard_as_it_is_then() {
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
src.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let dxf = serialize(&mut src, "dxf");
let text = with(file("notes.txt", b"Words to place"), json!({"text": {}, "at": [100, 150]}));
for p in [text, with(file("plan.dxf", &dxf), json!({"dxf": {"fit": true}, "at": [100, 150]}))] {
let (mut a, mut b) = (session(), session());
let read = b.prepare_place(&p).unwrap().read().unwrap();
for s in [&mut a, &mut b] {
s.execute("artboard.setProps", &json!({"index": 0, "width": 200})).unwrap();
}
let at_once = a.execute("file.place", &p).unwrap();
assert_eq!(b.place_read(read).unwrap(), at_once, "{}", at_once["name"]);
let undated = |s: &Session| {
let mut d = (*s.doc().unwrap().doc).clone();
d.metadata.created = None;
d
};
assert!(undated(&a) == undated(&b), "{}", at_once["name"]);
if at_once["format"] == "txt" {
// The 200 × 300 pt artboard less a 36 pt margin on each side.
assert!(close(at_once["width"].as_f64().unwrap(), 128.0) && close(at_once["height"].as_f64().unwrap(), 228.0), "{at_once}");
}
}
}
#[test]
fn template_puts_the_art_on_a_locked_template_layer_below_the_current_one() {
let mut s = session();
s.execute("layer.new", &json!({})).unwrap();
let current = s.doc().unwrap().current_layer().unwrap();
let r = place(&mut s, "sketch.png", &png(10, 10, [0, 0, 0, 255], None), json!({"template": true}));
let st = s.doc().unwrap();
let layers = &st.doc.layers;
assert_eq!(layers.len(), 3);
let t = layers.iter().position(|l| l.name.as_deref() == Some("Template sketch.png")).unwrap();
assert_eq!(layers[t + 1].id, current, "right below the current layer");
assert!(layers[t].locked && matches!(layers[t].kind, NodeKind::Layer { template: true, .. }));
assert_eq!(st.doc.layer_of(NodeId(r["ids"][0].as_u64().unwrap())), Some(layers[t].id));
assert!(st.selection.is_empty(), "locked template art isn't selected");
assert_eq!(st.history.undo.len(), 2, "layer.new, then Place as one step");
}
#[test]
fn pdf_pages_and_native_artboards_place_as_clipped_groups() {
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50, "artboards": 2})).unwrap();
src.execute("artboard.setProps", &json!({"index": 1, "width": 40})).unwrap();
let b1 = src.doc().unwrap().doc.artboards[1].rect;
src.execute("shape.rectangle", &json!({"x": b1.x0 + 5.0, "y": 5, "width": 20, "height": 10})).unwrap();
let pdf = serialize(&mut src, "pdf");
let native = serialize(&mut src, "vectorcraft");
let mut s = session();
for (name, bytes) in [("two.pdf", &pdf), ("two.vectorcraft", &native)] {
let r = place(&mut s, name, bytes, json!({"page": 2}));
assert!(close(r["width"].as_f64().unwrap(), 40.0) && close(r["height"].as_f64().unwrap(), 50.0), "{name}: {r}");
let g = node(&s, &r["ids"][0]);
assert!(matches!(g.kind, NodeKind::Group { clip: true, .. }), "{name}: clipped to the page");
let r = place(&mut s, name, bytes, json!({"page": 2, "crop": "bounding"}));
assert!((r["width"].as_f64().unwrap() - 20.0).abs() < 1.5, "{name}: the art's bounds: {r}");
let err = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes), "page": 3})).unwrap_err();
assert!(err.to_string().contains("has 2"), "{name}: {err}");
}
}
/// With crop "bounding", the art's box is the box of what shows, as an export measures it: a hidden
/// member of a group does not count, and is still placed, hidden.
#[test]
fn bounding_box_placement_measures_only_the_art_that_shows() {
let (mut src, shown) = group_with_a_hidden_member();
let eps = eps_of(&mut src);
let native = serialize(&mut src, "vectorcraft");
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300"><g><rect x="20" y="20" width="40" height="40"/><rect x="100" y="100" width="170" height="42" style="display:none"/></g></svg>"#;
// The sample's group `Pair` has a hidden member 42 pt tall; what shows is 170 × 40 pt.
let sample = vectorcraft_testkit::ai::eps(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_ps());
let cases: [(&str, &[u8], (f64, f64)); 4] = [
("pair.eps", &eps, (shown.width(), shown.height())),
("pair.vectorcraft", &native, (shown.width(), shown.height())),
("pair.svg", svg, (40.0, 40.0)),
("art.eps", &sample, (170.0, 40.0)),
];
for (name, bytes, (w, h)) in cases {
let mut s = session();
let (iw, ih) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
assert!(close(iw, w) && close(ih, h), "{name}: {iw} × {ih}, not {w} × {h}");
// `rect` fits what shows, centered.
let r = place(&mut s, name, bytes, json!({"crop": "bounding", "rect": [100, 100, 80, 80]}));
let k = (80.0 / w).min(80.0 / h);
let want = Rect::from_center_size((140.0, 140.0), (w * k, h * k));
assert!(drawn_at(&s, want), "{name}: not {want:?}");
assert_eq!(hidden_in(&node(&s, &r["ids"][0])), 1, "{name}: the hidden member is placed, hidden");
}
}
/// With crop "bounding", an effect's reach counts in the art's box, as in an export of the art.
#[test]
fn bounding_box_placement_counts_a_drop_shadow() {
let mut src = session();
let id = src.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
let square = node(&src, &id).visual_bounds().unwrap();
src.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 8, "y": 0, "blur": 0}})).unwrap();
let export = vectorcraft_render::encode::art_bounds(&src.doc().unwrap().doc).unwrap();
assert!(export.width() > square.width() + 7.0, "{export:?}");
let eps = eps_of(&mut src);
let native = serialize(&mut src, "vectorcraft");
let cases: [(&str, &[u8]); 2] = [("shadow.eps", &eps), ("shadow.vectorcraft", &native)];
for (name, bytes) in cases {
let mut s = session();
let (w, h) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
assert!(close(w, export.width()) && close(h, export.height()), "{name}: {w} × {h}, not {export:?}");
}
}
/// A symbol instance in the placed art is measured by its symbol's art, for the art's box and for
/// the size `file.place` answers.
#[test]
fn a_placed_symbol_instance_is_measured_by_its_art() {
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300"><defs><symbol id="s"><rect width="100" height="10"/></symbol></defs><use xlink:href="#s" x="50" y="50"/></svg>"##;
let mut s = session();
assert_eq!(info_size(&mut s, "bar.svg", svg, json!({"crop": "bounding"})), (100.0, 10.0));
let r = place(&mut s, "bar.svg", svg, json!({"crop": "bounding", "rect": [100, 100, 200, 200]}));
assert!(close(r["width"].as_f64().unwrap(), 200.0) && close(r["height"].as_f64().unwrap(), 20.0), "{r}");
assert!(drawn_at(&s, Rect::new(100.0, 190.0, 300.0, 210.0)));
// Replace centers an image on it and gives a text file its size.
let r = place(&mut s, "dot.png", &png(10, 10, [0, 0, 0, 255], None), json!({"replace": true}));
let c = node(&s, &r["ids"][0]).geometric_bounds().unwrap().center();
assert!(close(c.x, 200.0) && close(c.y, 200.0), "{c:?}");
let r = place(&mut s, "bar.svg", svg, json!({"crop": "bounding", "rect": [100, 100, 200, 200]}));
assert!(close(r["width"].as_f64().unwrap(), 200.0), "{r}");
let r = place(&mut s, "note.txt", b"Hello", json!({"replace": true}));
let NodeKind::Text(t) = &node(&s, &r["ids"][0]).kind else { panic!("area type") };
let frame = t.area_frame().and_then(|f| f.bounds()).unwrap();
assert!(close(frame.width(), 200.0) && close(frame.height(), 20.0), "{frame:?}");
}
/// Crop "crop" still frames the art by its artboard (an SVG by its viewBox), hidden members or not.
#[test]
fn crop_placement_keeps_the_artboard_with_a_hidden_member() {
let (mut src, _) = group_with_a_hidden_member();
let eps = eps_of(&mut src);
let native = serialize(&mut src, "vectorcraft");
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><g><rect x="20" y="20" width="40" height="40"/><rect x="100" y="100" width="170" height="42" style="display:none"/></g></svg>"#;
let cases: [(&str, &[u8]); 3] = [("pair.eps", &eps), ("pair.vectorcraft", &native), ("pair.svg", svg)];
for (name, bytes) in cases {
let mut s = session();
assert_eq!(info_size(&mut s, name, bytes, json!({"crop": "crop"})), (400.0, 300.0), "{name}");
let r = place(&mut s, name, bytes, json!({"crop": "crop"}));
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(400.0), Some(300.0)), "{name}: {r}");
}
}
/// A page's or artboard's art is the objects whose visible art lies on it: a group whose only part
/// on the artboard is hidden stays out, and a hidden object on the artboard is still placed, hidden.
#[test]
fn a_group_whose_only_part_on_the_artboard_is_hidden_is_left_out() {
let mut src = session();
let off = src.execute("shape.rectangle", &json!({"x": -300, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
let under = src.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 170, "height": 42})).unwrap()["id"].clone();
src.execute("object.group", &json!({"ids": [off, under]})).unwrap();
src.execute("object.hide", &json!({"ids": [under]})).unwrap();
let dot = src.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
let shown = node(&src, &dot).visual_bounds().unwrap();
let ghost = src.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone();
src.execute("object.hide", &json!({"ids": [ghost]})).unwrap();
let eps = eps_of(&mut src);
let native = serialize(&mut src, "vectorcraft");
let cases: [(&str, &[u8]); 2] = [("parts.eps", &eps), ("parts.vectorcraft", &native)];
for (name, bytes) in cases {
let mut s = session();
let (w, h) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
assert!(close(w, shown.width()) && close(h, shown.height()), "{name}: {w} × {h}");
let r = place(&mut s, name, bytes, json!({"crop": "bounding"}));
let g = node(&s, &r["ids"][0]);
let kids = g.children().unwrap();
assert_eq!(kids.len(), 2, "{name}: the circle and the hidden square");
assert!(kids[0].visible && !kids[1].visible, "{name}");
}
}
#[test]
fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() {
// The file: a global "Brand" red rectangle and a "Star" symbol instance.
let mut src = session();
src.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
src.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
src.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap();
src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
src.execute("symbol.new", &json!({"name": "Star"})).unwrap();
let bytes = serialize(&mut src, "vectorcraft");
// The document: its own, different "Brand" and "Star".
let mut s = session();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#00ff00", "global": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
s.execute("symbol.new", &json!({"name": "Star"})).unwrap();
let r = place(&mut s, "logo.vectorcraft", &bytes, json!({"crop": "bounding"}));
let g = node(&s, &r["ids"][0]);
let (mut fill, mut symbol) = (None, None);
g.walk(&mut |n| match &n.kind {
NodeKind::SymbolInstance { symbol: name, .. } => symbol = Some(name.clone()),
NodeKind::Path { .. } => {
fill = n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None })
}
_ => {}
});
assert_eq!(symbol.as_deref(), Some("Star 2"));
let doc = &s.doc().unwrap().doc;
assert!(doc.symbols.iter().any(|x| x.name == "Star 2") && doc.symbols.iter().any(|x| x.name == "Star"));
let Some(Paint::Solid { color, swatch: None, .. }) = fill else { panic!("unlinked: {fill:?}") };
assert_eq!(color.to_hex(), "#ff0000", "keeps its look");
assert_eq!(doc.swatch("Brand").and_then(|w| w.paint.color()).map(|c| c.to_hex()).as_deref(), Some("#00ff00"));
// A document without them gets them as they are.
let mut fresh = session();
let r = place(&mut fresh, "logo.vectorcraft", &bytes, json!({}));
let doc = &fresh.doc().unwrap().doc;
assert!(doc.symbols.iter().any(|x| x.name == "Star") && doc.swatch("Brand").is_some());
let mut linked = false;
node(&fresh, &r["ids"][0]).walk(&mut |n| {
linked |= n
.appearance
.items
.iter()
.any(|i| matches!(i, AppearanceItem::Fill(f) if matches!(&f.paint, Paint::Solid { swatch: Some(w), .. } if w == "Brand")));
});
assert!(linked, "still linked to the swatch it brought");
}
#[test]
fn info_reports_size_resolution_colour_mode_and_link() {
let mut s = session();
let mut gray = vec![];
image::GrayImage::from_pixel(300, 150, image::Luma([128])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap();
let gray = cmd::fileio::ppi::with_png_resolution(&gray, (300.0, 300.0));
let mut p = file("scan.png", &gray);
p["thumbnail"] = json!(16);
let info = s.execute("file.place.info", &p).unwrap();
assert_eq!((info["colorMode"].as_str(), info["pixelWidth"].as_u64()), (Some("Grayscale"), Some(300)));
assert_eq!(info["ppi"], json!([300.0, 300.0]));
assert!(close(info["width"].as_f64().unwrap(), 72.0));
let thumb = image::load_from_memory(&vectorcraft_format::base64_decode(info["thumbnailBase64"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!((thumb.width(), thumb.height()), (16, 8));
assert!(top_children(&s).is_empty(), "info places nothing");
// A path is linked by default.
let path = std::env::temp_dir().join(format!("vectorcraft-place-{}.png", std::process::id()));
std::fs::write(&path, &gray).unwrap();
let path = path.to_string_lossy().to_string();
let r = s.execute("file.place", &json!({"path": path})).unwrap();
assert_eq!(r["linked"], true);
let im = s.execute("image.info", &json!({})).unwrap();
assert_eq!((im["linked"].as_bool(), im["link"].as_str(), im["colorMode"].as_str()), (Some(true), Some(path.as_str()), Some("Grayscale")));
assert!(close(im["ppi"][0].as_f64().unwrap(), 300.0), "{im}");
let r = s.execute("file.place", &json!({"path": path, "link": false})).unwrap();
assert_eq!(r["linked"], false);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_queue_of_3_with_click_drag_and_esc_places_2() {
let mut s = session();
let view = ViewInfo::default();
let files: Vec<Value> = ["a.png", "b.png", "c.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect();
let r = s.execute("file.place.queue", &json!({"files": files, "thumbnail": 8})).unwrap();
assert_eq!(r["count"], 3);
assert!(r["files"][0]["thumbnailBase64"].is_string());
assert_eq!(s.tool_id(), "place");
assert_eq!(s.tool_options()["name"], "a.png");
let ev = |kind, x, y| PointerEvent::new(kind, x, y);
// Click: 100% with its top-left corner at the click.
s.pointer(&ev(PointerKind::Down, 10.0, 10.0), view).unwrap();
s.pointer(&ev(PointerKind::Up, 10.0, 10.0), view).unwrap();
let a = s.doc().unwrap().selection.objects[0];
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds(), Some(Rect::new(10.0, 10.0, 50.0, 30.0)));
// Drag: the dragged size, aspect kept.
s.pointer(&ev(PointerKind::Down, 100.0, 100.0), view).unwrap();
s.pointer(&ev(PointerKind::Drag, 180.0, 110.0), view).unwrap();
s.pointer(&ev(PointerKind::Up, 180.0, 110.0), view).unwrap();
let b = s.doc().unwrap().selection.objects[0];
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().name.as_deref(), Some("b.png"));
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds(), Some(Rect::new(100.0, 100.0, 180.0, 140.0)));
// Esc discards the last one and asks for the previous tool back.
assert!(s.tool_claims_key(ToolKey::Escape, view));
let reqs = s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap();
assert_eq!(reqs, vec![UiRequest::SwitchTool("selection".into())]);
assert_eq!(top_children(&s).len(), 2);
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "one undo step per placement");
}
/// With [`Session::place_cursor_requests`] set, a click of the place cursor asks the frontend to
/// place its file ([`UiRequest::Place`]) and places nothing itself; the params it asks with place
/// the file as the click places it without the flag.
#[test]
fn a_click_of_the_place_cursor_asks_the_frontend_to_place_the_file_when_asked_to() {
let view = ViewInfo::default();
let files: Vec<Value> = ["a.png", "b.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect();
let (mut at_once, mut s) = (session(), session());
s.place_cursor_requests = true;
for s in [&mut at_once, &mut s] {
s.execute("file.place.queue", &json!({"files": files})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), view).unwrap();
}
at_once.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), view).unwrap();
let reqs = s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), view).unwrap();
let [UiRequest::Place(p)] = &reqs[..] else { panic!("{reqs:?}") };
assert_eq!(p["rect"], json!([10.0, 10.0, 40.0, 20.0]));
assert!(top_children(&s).is_empty(), "the frontend places it");
assert_eq!(s.tool_options()["names"], json!(["b.png"]), "the cursor goes on to the next file");
s.execute("file.place", p).unwrap();
// Left out: the second each document was made in.
let doc = |s: &Session| {
let mut d = (*s.doc().unwrap().doc).clone();
d.metadata.created = None;
d
};
assert!(doc(&s) == doc(&at_once));
assert_eq!(s.journal.last(), at_once.journal.last());
}
/// Files read for the place cursor apart from the session ([`Session::prepare_place_queue`], then
/// [`cmd::place::QueueJob::read`] on another thread and [`Session::place_queue_read`]) load it as
/// `file.place.queue` loads it: the same reply, with the files that can't be read skipped, and the
/// same tool and queue.
#[test]
fn files_read_for_the_place_cursor_apart_from_the_session_load_it_as_file_place_queue_does() {
let mut files: Vec<Value> = ["a.png", "b.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect();
files.push(file("notes.xyz", b"hello"));
let p = json!({"files": files, "thumbnail": 8, "link": false});
let (mut a, mut b) = (session(), session());
let at_once = a.execute("file.place.queue", &p).unwrap();
assert_eq!((at_once["count"].as_u64(), at_once["skipped"][0]["name"].as_str()), (Some(2), Some("notes.xyz")));
let job = b.prepare_place_queue(&p).unwrap();
assert_eq!(job.names(), ["a.png", "b.png", "notes.xyz"]);
let read = std::thread::spawn(move || job.read()).join().unwrap().unwrap();
assert_eq!(b.place_queue_read(read).unwrap(), at_once);
assert_eq!((b.tool_id(), b.tool_options()), (a.tool_id(), a.tool_options()));
// None of the files can be read: an error, and the tool stays.
let mut c = session();
let none = json!({"files": [file("notes.xyz", b"hello")]});
let read = c.prepare_place_queue(&none).unwrap().read().unwrap();
assert!(c.place_queue_read(read).unwrap_err().to_string().contains("not a format"));
assert_eq!(c.tool_id(), "selection");
}
#[test]
fn bad_place_params_are_errors() {
let mut s = session();
let ok = vectorcraft_format::base64_encode(&png(2, 2, [0; 4], None));
for p in [
json!({}),
json!({"name": "x.png", "dataBase64": "%%%"}),
json!({"name": "notes.xyz", "dataBase64": vectorcraft_format::base64_encode(b"hello")}),
json!({"name": "x.png", "dataBase64": ok, "rect": [0, 0, 0, 10]}),
json!({"name": "x.png", "dataBase64": ok, "at": [1e12, 0]}),
json!({"name": "x.png", "dataBase64": ok, "page": 0}),
json!({"name": "x.png", "dataBase64": ok, "crop": "trim"}),
json!({"name": "x.png", "dataBase64": ok, "template": true, "replace": true}),
] {
assert!(s.execute("file.place", &p).is_err(), "{p}");
}
assert!(s.execute("file.place.queue", &json!({})).is_err());
assert!(s.execute("file.place.queue", &json!({"files": [{"name": "notes.txt", "dataBase64": ""}]})).is_err());
assert_eq!(s.tool_id(), "selection", "a failed queue leaves the tool alone");
assert!(top_children(&s).is_empty());
}
/// An SVG placed with `crop: "crop"` given is framed and clipped by its viewBox, as a PDF by its
/// page, so art drawn on a shared canvas keeps its place and scale; by default (and with
/// "bounding") it is fitted by its art's bounds (#788).
#[test]
fn an_svg_placed_with_crop_is_framed_by_its_viewbox() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M200 300 L600 300 L600 800 L200 800 Z"/></svg>"#;
let path_bounds = |s: &Session, id: &Value| {
let mut b = None;
node(s, id).walk(&mut |n| {
if let NodeKind::Path { clipping: false, .. } = n.kind {
b = n.visual_bounds();
}
});
b.unwrap()
};
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6);
let mut s = session();
let r = place(&mut s, "glyph.svg", svg, json!({"rect": [100, 100, 200, 200], "crop": "crop"}));
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(200.0)), "{r}");
let id = &r["ids"][0];
assert!(near(path_bounds(&s, id), Rect::new(140.0, 160.0, 220.0, 260.0)), "{:?}", path_bounds(&s, id));
assert!(matches!(node(&s, id).kind, NodeKind::Group { clip: true, .. }), "clipped to the viewBox");
for extra in [json!({"rect": [100, 100, 200, 200]}), json!({"rect": [100, 100, 200, 200], "crop": "bounding"})] {
let mut s = session();
let r = place(&mut s, "glyph.svg", svg, extra);
assert!(near(path_bounds(&s, &r["ids"][0]), Rect::new(120.0, 100.0, 280.0, 300.0)), "{r}");
}
}
/// An SVG whose top-level group is clipped is placed as one clip group: the clip is kept, and its
/// clipping path is not left behind as a loose path.
#[test]
fn a_clipped_top_level_group_stays_clipped() {
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
<defs><clipPath id="c"><path d="M0 0 L200 0 L200 100 L0 100 Z"/></clipPath></defs>
<g id="Layer_1" clip-path="url(#c)"><path d="M100 -20 L250 -20 L250 40 L100 40 Z" fill="#f00"/></g>
</svg>"##;
let mut s = session();
let r = place(&mut s, "c.svg", svg, json!({"link": false}));
assert_eq!((r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap()), (200.0, 100.0), "{r}");
let placed = node(&s, &r["ids"][0]);
let NodeKind::Group { children, clip: false } = &placed.kind else { panic!("{:?}", placed.kind) };
let [inner] = children.as_slice() else { panic!("one object: {children:?}") };
assert!(matches!(&inner.kind, NodeKind::Group { children, clip: true } if children.len() == 2), "{:?}", inner.kind);
}