mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
794 lines
44 KiB
Rust
794 lines
44 KiB
Rust
//! 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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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);
|
||
}
|