mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
The Links panel lists every image (thumbnail, name, page, missing/modified/embedded badge) with Show filters, sorting and thumbnail sizes; its buttons and flyout relink (to a file or a folder), go to, update, edit the original, embed and unembed, show in folder and open Placement Options. Link Info shows format, colour space, location, ppi and effective ppi, dimensions, scale, rotation, size and dates. Edit > Edit Original and the Properties panel's image section reach the same commands. Engine: links.list, links.goTo, links.embed, links.unembed, links.info and links.placementOptions; links.relink and links.update now place the new file as the image's Placement Options say (transforms, bounds, file dimensions, fit, fill, the alignment point, clip to the old bounds). Model: ImageObject.placement. UI: links.editOriginal, links.reveal, ui.placementOptionsDialog; Services.open_file and pick_folder on the desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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());
|
||
}
|