285 lines
14 KiB
Rust
285 lines
14 KiB
Rust
//! The Links panel's commands: listing images with their status (`links.list`), Go To, Embed and
|
||
//! Unembed, Link Info and the Placement Options a relinked file follows.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{NodeKind, PlacementOptions};
|
||
|
||
use super::tests_links::{BLUE, Folder, RED, bounds, centre_colour, image, near, open, place, png, save, session, write};
|
||
use super::*;
|
||
|
||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||
}
|
||
|
||
/// Place the file at `path` embedded → its id.
|
||
fn place_embedded(s: &mut Session, path: &str) -> NodeId {
|
||
let r = run(s, "file.place", json!({"path": path, "at": [200, 150], "link": false}));
|
||
NodeId(r["ids"][0].as_u64().unwrap())
|
||
}
|
||
|
||
fn close(a: f64, b: f64) -> bool {
|
||
(a - b).abs() < 1e-6
|
||
}
|
||
|
||
#[test]
|
||
fn embed_keeps_the_pixels_without_the_file() {
|
||
let dir = Folder::new("embed");
|
||
let pic = dir.file("photo.png");
|
||
let bytes = png(600, 300, RED);
|
||
write(&pic, &bytes);
|
||
let mut s = session();
|
||
let id = place(&mut s, &pic);
|
||
let placed = bounds(&s, id);
|
||
run(&mut s, "select.set", json!({"ids": []}));
|
||
assert!(s.execute("links.embed", &json!({})).is_err(), "nothing selected");
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
assert_eq!(run(&mut s, "links.embed", json!({})), json!({"embedded": [id.0], "missing": []}));
|
||
let im = image(&s, id);
|
||
assert!(im.link.is_none());
|
||
assert_eq!(s.doc().unwrap().doc.images[&im.key].bytes.as_slice(), bytes.as_slice(), "the file's own bytes");
|
||
assert_eq!(bounds(&s, id), placed);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(image(&s, id).link.is_some(), "one undo step");
|
||
run(&mut s, "edit.redo", json!({}));
|
||
// Saved and reopened without the file: the full pixels, nothing missing.
|
||
let doc_path = dir.file("poster.vectorcraft");
|
||
save(&mut s, &doc_path);
|
||
std::fs::remove_file(&pic).unwrap();
|
||
let r = open(&mut s, &doc_path);
|
||
assert_eq!(r["missingLinks"], json!([]), "{r}");
|
||
let im = image(&s, id);
|
||
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy());
|
||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||
}
|
||
|
||
#[test]
|
||
fn a_missing_file_showing_its_preview_stays_linked() {
|
||
let dir = Folder::new("embed-missing");
|
||
let pic = dir.file("photo.png");
|
||
write(&pic, &png(600, 300, RED));
|
||
let mut s = session();
|
||
let id = place(&mut s, &pic);
|
||
let doc_path = dir.file("poster.vectorcraft");
|
||
save(&mut s, &doc_path);
|
||
std::fs::remove_file(&pic).unwrap();
|
||
open(&mut s, &doc_path);
|
||
assert_eq!(run(&mut s, "links.embed", json!({"ids": [id.0]})), json!({"embedded": [], "missing": [id.0]}));
|
||
assert!(image(&s, id).link.is_some());
|
||
assert!(s.doc().unwrap().history.undo.is_empty(), "nothing changed");
|
||
}
|
||
|
||
#[test]
|
||
fn unembed_writes_the_bytes_and_links_to_them() {
|
||
let dir = Folder::new("unembed");
|
||
let pic = dir.file("photo.png");
|
||
let bytes = png(600, 300, RED);
|
||
write(&pic, &bytes);
|
||
let mut s = session();
|
||
let id = place_embedded(&mut s, &pic);
|
||
let placed = bounds(&s, id);
|
||
// No path: the file's bytes come back and nothing changes.
|
||
let r = run(&mut s, "links.unembed", json!({"id": id.0}));
|
||
assert_eq!(r["name"], "photo.png");
|
||
assert_eq!(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), bytes);
|
||
assert!(image(&s, id).link.is_none());
|
||
// To a path: written there, and the image links to it.
|
||
let out = dir.file("out/photo.png");
|
||
std::fs::create_dir_all(dir.file("out")).unwrap();
|
||
let r = run(&mut s, "links.unembed", json!({"id": id.0, "path": out}));
|
||
assert_eq!(r, json!({"id": id.0, "path": out}));
|
||
assert_eq!(std::fs::read(&out).unwrap(), bytes);
|
||
let link = image(&s, id).link.unwrap();
|
||
assert_eq!((link.path.as_str(), link.size), (out.as_str(), Some(bytes.len() as u64)));
|
||
assert_eq!(bounds(&s, id), placed);
|
||
assert_eq!(run(&mut s, "links.check", json!({}))["links"][0]["status"], "ok");
|
||
assert!(s.execute("links.unembed", &json!({"id": id.0, "path": out})).is_err(), "linked already");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(image(&s, id).link.is_none(), "one undo step");
|
||
// Another extension converts.
|
||
let jpg = dir.file("photo.jpg");
|
||
run(&mut s, "links.unembed", json!({"id": id.0, "path": jpg}));
|
||
assert_eq!(std::fs::read(&jpg).unwrap()[..2], [0xff, 0xd8]);
|
||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(s.execute("links.unembed", &json!({"id": id.0, "path": dir.file("photo.psd")})).is_err(), "not a format it writes");
|
||
}
|
||
|
||
#[test]
|
||
fn go_to_selects_the_image() {
|
||
let dir = Folder::new("goto");
|
||
let pic = dir.file("photo.png");
|
||
write(&pic, &png(600, 300, RED));
|
||
let mut s = session();
|
||
let id = place(&mut s, &pic);
|
||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||
assert_ne!(s.doc().unwrap().selection.objects, vec![id]);
|
||
let r = run(&mut s, "links.goTo", json!({"id": id.0}));
|
||
assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0]}));
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||
let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[1].id;
|
||
assert!(s.execute("links.goTo", &json!({"id": rect.0})).is_err(), "not an image");
|
||
}
|
||
|
||
#[test]
|
||
fn the_list_shows_each_image_with_its_status() {
|
||
let dir = Folder::new("list");
|
||
let (a, b) = (dir.file("a.png"), dir.file("b.png"));
|
||
write(&a, &png(600, 300, RED));
|
||
write(&b, &png(40, 20, BLUE));
|
||
let mut s = session();
|
||
let linked = place(&mut s, &a);
|
||
let embedded = place_embedded(&mut s, &b);
|
||
let l = run(&mut s, "links.list", json!({}));
|
||
let rows = l["links"].as_array().unwrap();
|
||
assert_eq!(rows.len(), 2);
|
||
// Top first.
|
||
assert_eq!((rows[0]["id"].as_u64(), rows[0]["status"].as_str(), rows[0]["name"].as_str()), (Some(embedded.0), Some("embedded"), Some("b.png")));
|
||
assert_eq!(
|
||
(rows[1]["id"].as_u64(), rows[1]["status"].as_str(), rows[1]["path"].as_str(), rows[1]["format"].as_str()),
|
||
(Some(linked.0), Some("ok"), Some(a.as_str()), Some("PNG"))
|
||
);
|
||
assert_eq!((rows[1]["pixelWidth"].as_u64(), rows[1]["pixelHeight"].as_u64()), (Some(600), Some(300)));
|
||
write(&a, &png(300, 300, BLUE));
|
||
let l = run(&mut s, "links.list", json!({"sort": "status"}));
|
||
assert_eq!((l["modified"].as_u64(), l["embedded"].as_u64(), l["missing"].as_u64()), (Some(1), Some(1), Some(0)));
|
||
assert_eq!(l["links"][0]["status"], "modified", "modified before embedded");
|
||
let l = run(&mut s, "links.list", json!({"show": "embedded"}));
|
||
assert_eq!(l["links"].as_array().unwrap().len(), 1);
|
||
std::fs::remove_file(&a).unwrap();
|
||
assert_eq!(run(&mut s, "links.list", json!({"show": "missing"}))["links"][0]["id"], linked.0);
|
||
assert_eq!(run(&mut s, "links.list", json!({"sort": "name"}))["links"][0]["name"], "a.png");
|
||
assert!(s.execute("links.list", &json!({"show": "broken"})).is_err());
|
||
assert!(s.execute("links.list", &json!({"sort": "size"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn link_info_has_the_file_and_placement_details() {
|
||
let dir = Folder::new("info");
|
||
let pic = dir.file("photo.png");
|
||
let bytes = png(600, 300, RED);
|
||
write(&pic, &bytes);
|
||
let mut s = session();
|
||
let id = place(&mut s, &pic);
|
||
let i = run(&mut s, "links.info", json!({"id": id.0}));
|
||
assert_eq!((i["status"].as_str(), i["fileName"].as_str(), i["format"].as_str()), (Some("ok"), Some("photo.png"), Some("PNG")));
|
||
assert_eq!(std::path::Path::new(i["location"].as_str().unwrap()), dir.0.as_path());
|
||
assert_eq!((i["fileSize"].as_u64(), i["pixelWidth"].as_u64(), i["colorMode"].as_str()), (Some(bytes.len() as u64), Some(600), Some("RGB")));
|
||
assert_eq!((i["ppi"].clone(), i["effectivePpi"].clone(), i["scale"].clone()), (json!([72.0, 72.0]), json!([72.0, 72.0]), json!([100.0, 100.0])));
|
||
assert!(i["modified"].as_u64().is_some());
|
||
assert_eq!(i["placement"], json!({"preserve": "bounds", "align": "center", "clip": false}));
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
run(&mut s, "object.scale", json!({"sx": 50}));
|
||
run(&mut s, "object.rotate", json!({"angle": 30}));
|
||
let i = run(&mut s, "links.info", json!({}));
|
||
let num = |v: &Value, k: usize| v.get(k).and_then(Value::as_f64).unwrap();
|
||
assert!(close(num(&i["scale"], 0), 50.0) && close(num(&i["effectivePpi"], 0), 144.0), "{i}");
|
||
assert!(close(i["rotation"].as_f64().unwrap(), 30.0), "counter-clockwise: {i}");
|
||
let embedded = place_embedded(&mut s, &pic);
|
||
let i = run(&mut s, "links.info", json!({"id": embedded.0}));
|
||
assert_eq!((i["status"].as_str(), i["linked"].as_bool(), i["fileName"].as_str()), (Some("embedded"), Some(false), None));
|
||
}
|
||
|
||
/// A 600×300 pt image at the centre of the artboard, its placement options set to `options`,
|
||
/// relinked to a 300×100 px file → the image's id and session.
|
||
fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) {
|
||
let dir = Folder::new(name);
|
||
let (pic, other) = (dir.file("photo.png"), dir.file("blue.png"));
|
||
write(&pic, &png(600, 300, RED));
|
||
write(&other, &png(300, 100, BLUE));
|
||
let mut s = session();
|
||
let id = place(&mut s, &pic);
|
||
let mut p = options;
|
||
p["ids"] = json!([id.0]);
|
||
run(&mut s, "links.placementOptions", p);
|
||
run(&mut s, "links.relink", json!({"ids": [id.0], "path": other}));
|
||
(s, id, dir)
|
||
}
|
||
|
||
#[test]
|
||
fn a_relinked_file_follows_the_placement_options() {
|
||
let b = |s: &Session, id| {
|
||
let r = bounds(s, id);
|
||
[r.x0, r.y0, r.x1, r.y1].map(|v| (v * 1e6).round() / 1e6)
|
||
};
|
||
// The old bounds: -100, 0, 500, 300.
|
||
let (s, id, _d) = relinked("pl-bounds", json!({"preserve": "bounds"}));
|
||
assert_eq!(b(&s, id), [-100.0, 0.0, 500.0, 300.0], "stretched into the bounds");
|
||
let (s, id, _d) = relinked("pl-fit", json!({"preserve": "fit"}));
|
||
assert_eq!(b(&s, id), [-100.0, 50.0, 500.0, 250.0], "proportional, centred");
|
||
let (s, id, _d) = relinked("pl-transforms", json!({"preserve": "transforms"}));
|
||
assert_eq!(b(&s, id), [50.0, 100.0, 350.0, 200.0], "at the same 100%");
|
||
let (s, id, _d) = relinked("pl-file", json!({"preserve": "fileDimensions", "align": "topLeft"}));
|
||
assert_eq!(b(&s, id), [-100.0, 0.0, 200.0, 100.0]);
|
||
let (mut s, id, d) = relinked("pl-fill", json!({"preserve": "fill", "align": "left", "clip": true}));
|
||
assert_eq!(b(&s, id), [-100.0, 0.0, 800.0, 300.0], "covers the bounds");
|
||
let clip_group = |s: &Session| {
|
||
let doc = &s.doc().unwrap().doc;
|
||
let g = doc.node(doc.parent_of(id).unwrap()).unwrap();
|
||
let NodeKind::Group { children, clip: true } = &g.kind else { panic!("not clipped: {g:?}") };
|
||
(g.id, children[0].geometric_bounds().unwrap())
|
||
};
|
||
let (group, clip) = clip_group(&s);
|
||
assert_eq!(clip, vectorcraft_geom::Rect::new(-100.0, 0.0, 500.0, 300.0), "clipped to the old bounds");
|
||
// Relinked again: the same clip group, its path following.
|
||
run(&mut s, "links.relink", json!({"ids": [id.0], "path": d.file("photo.png")}));
|
||
assert_eq!(clip_group(&s).0, group);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(
|
||
matches!(s.doc().unwrap().doc.node(s.doc().unwrap().doc.parent_of(id).unwrap()).unwrap().kind, NodeKind::Layer { .. }),
|
||
"one undo step each"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn placement_options_read_set_and_round_trip() {
|
||
let dir = Folder::new("placement");
|
||
let pic = dir.file("photo.png");
|
||
write(&pic, &png(600, 300, RED));
|
||
let mut s = session();
|
||
let id = place(&mut s, &pic);
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
assert_eq!(run(&mut s, "links.placementOptions", json!({})), json!({"placement": {"preserve": "bounds", "align": "center", "clip": false}}));
|
||
let r = run(&mut s, "links.placementOptions", json!({"preserve": "fill", "align": "topRight", "clip": true}));
|
||
assert_eq!(r, json!({"ids": [id.0], "placement": {"preserve": "fill", "align": "topRight", "clip": true}}));
|
||
assert!(s.execute("links.placementOptions", &json!({"preserve": "stretch"})).is_err());
|
||
assert!(s.execute("links.placementOptions", &json!({"align": "middle"})).is_err());
|
||
// Saved and opened again.
|
||
let doc_path = dir.file("poster.vectorcraft");
|
||
save(&mut s, &doc_path);
|
||
let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
|
||
assert_eq!(
|
||
saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["placement"],
|
||
json!({"preserve": "fill", "align": "topRight", "clip": true})
|
||
);
|
||
open(&mut s, &doc_path);
|
||
let want = PlacementOptions { preserve: vectorcraft_doc::links::Preserve::Fill, align: vectorcraft_doc::links::Align::TopRight, clip: true };
|
||
assert_eq!(image(&s, id).placement, want);
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "links.placementOptions", json!({"preserve": "bounds", "align": "center", "clip": false}));
|
||
save(&mut s, &doc_path);
|
||
let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
|
||
assert_eq!(saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["placement"], Value::Null, "the default isn't written");
|
||
}
|
||
|
||
#[test]
|
||
fn the_links_commands_are_reachable_headless() {
|
||
for id in [
|
||
"links.list",
|
||
"links.goTo",
|
||
"links.embed",
|
||
"links.unembed",
|
||
"links.info",
|
||
"links.placementOptions",
|
||
"links.relink",
|
||
"links.update",
|
||
"links.check",
|
||
] {
|
||
let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} is not a command"));
|
||
assert!(!c.params.is_empty(), "{id} documents its params");
|
||
}
|
||
// Without a document every one says why instead of failing silently.
|
||
let mut s = Session::new();
|
||
assert!(s.execute("links.list", &json!({})).is_err());
|
||
}
|