Files
vectorcraft/crates/engine/src/tests/links.rs
T
dotCooCoo b7203714be Links: Relink can replace all instances, the folder's missing links or each link separately
The Links panel's Relink... button and menu item open a Relink dialog
(ui.relinkDialog) with a Replace menu. Selected Instances relinks the
selected images and placed documents, as Relink did before. All
Instances also relinks every other object in the layers linked to the
same file. Missing Links from This Folder relinks the selection to the
chosen file, then every other missing link to the file of its name in
that file's folder, and the status bar names the files not there. Each
Link Separately asks for a file for each selected link in turn, one OK
each, and relinks them all at the last one: the dialog has one row per
linked file, and each selected embedded image gets a row of its own.
Every choice is one undo step.

links.relink takes scope: selected (default), all, missingInFolder
(with path) or each (with paths: [{ids, path}] in place of ids, path
and folder). A scope or paths that doesn't fit is an error and relinks
nothing. With copy, every file is read and checked before any is
copied, copies made before a failed copy are removed, and
missingInFolder looks for missing images only.

Docs: params text, docs/mcp.md, docs/control-protocol.md,
docs/target-app-parity.md. Catalog rows for the new labels and messages
in every catalog.
2026-11-08 13:21:01 +07:00

1126 lines
56 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.
//! Linked images: what a link records, the preview saved in place of the pixels, finding files
//! that moved, missing and modified files (`document.open`, `links.check`, `links.update`,
//! `links.relink`).
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind};
use vectorcraft_geom::Rect;
use crate::*;
/// A fresh folder for one test (removed when dropped).
pub(crate) struct Folder(pub(crate) PathBuf);
impl Folder {
pub(crate) fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("vectorcraft-links-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
pub(crate) fn file(&self, name: &str) -> String {
self.0.join(name).to_string_lossy().into_owned()
}
}
impl Drop for Folder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A `w`×`h` PNG of one colour.
pub(crate) fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let mut out = vec![];
let [r, g, b] = rgb;
image::RgbaImage::from_pixel(w, h, image::Rgba([r, g, b, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
pub(crate) const RED: [u8; 3] = [230, 20, 20];
pub(crate) const BLUE: [u8; 3] = [20, 20, 230];
pub(crate) fn write(path: &str, bytes: &[u8]) {
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(path, bytes).unwrap();
}
pub(crate) fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// Place the file at `path` (linked) → its id.
pub(crate) fn place(s: &mut Session, path: &str) -> NodeId {
let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
NodeId(r["ids"][0].as_u64().unwrap())
}
pub(crate) fn image(s: &Session, id: NodeId) -> ImageObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Image(im) => (**im).clone(),
k => panic!("not an image: {k:?}"),
}
}
pub(crate) fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
/// The colour at the centre of the active document's artboard.
pub(crate) fn centre_colour(doc: &Document) -> [u8; 3] {
let img = vectorcraft_render::Renderer::new().render_region(doc, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true);
let [r, g, b, _] = img.pixel(200, 150);
[r, g, b]
}
pub(crate) fn near(a: [u8; 3], b: [u8; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 6)
}
/// The first image object's JSON in a saved document.
fn saved_image(v: &mut Value) -> &mut Value {
&mut v["document"]["layers"][0]["kind"]["children"][0]["kind"]
}
pub(crate) fn save(s: &mut Session, path: &str) {
s.execute("document.save", &json!({"path": path})).unwrap();
}
pub(crate) fn open(s: &mut Session, path: &str) -> Value {
s.execute("document.open", &json!({"path": path})).unwrap()
}
#[test]
fn a_placed_link_records_the_file_and_the_document_saves_only_a_preview() {
let dir = Folder::new("record");
let pic = dir.file("art/photo.png");
let bytes = png(600, 300, RED);
write(&pic, &bytes);
let mut s = session();
let id = place(&mut s, &pic);
let im = image(&s, id);
let link = im.link.clone().unwrap();
assert_eq!((link.path.as_str(), link.size), (pic.as_str(), Some(bytes.len() as u64)));
assert_eq!(link.hash.as_deref(), Some(vectorcraft_doc::links::hash_bytes(&bytes).as_str()));
assert!(link.modified.is_some_and(|m| m > 1_600_000_000_000), "{link:?}");
let blob = &s.doc().unwrap().doc.images[&im.key];
assert!(blob.proxy.is_some() && !blob.is_proxy(), "full pixels, with a preview to save");
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
let entry = &saved["images"][&im.key];
assert_eq!(entry["proxy"], true);
assert!(entry["data"].as_str().unwrap().len() * 3 / 4 < bytes.len(), "the preview is smaller than the file");
let saved_link = saved_image(&mut saved)["link"].clone();
assert_eq!((saved_link["path"].as_str(), saved_link["relative"].as_str()), (Some(pic.as_str()), Some("art/photo.png")));
// Embedding keeps the pixels, and no preview is saved for them.
s.execute("file.place", &json!({"path": pic, "link": false})).unwrap();
save(&mut s, &doc_path);
let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
assert_eq!(saved["images"][&im.key]["proxy"], Value::Null, "an embedded image shows it too");
}
/// A flat `w`×`h` Photoshop document of one colour (raw RGB, no layers).
fn psd(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec();
f.extend(h.to_be_bytes());
f.extend(w.to_be_bytes());
f.extend([0, 8, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
for v in rgb {
f.extend(std::iter::repeat_n(v, (w * h) as usize));
}
f
}
/// #918: a Photoshop document placed linked shows its merged image, and updates when it changes.
#[test]
fn a_linked_photoshop_document_places_and_updates() {
let dir = Folder::new("psd");
let pic = dir.file("art/photo.psd");
write(&pic, &psd(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let link = image(&s, id).link.unwrap();
assert_eq!(link.path, pic);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
write(&pic, &psd(400, 400, BLUE));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height), (400, 400));
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
/// An uncompressed 24-bit `w`×`h` Targa file of one color.
fn targa(w: u16, h: u16, rgb: [u8; 3]) -> Vec<u8> {
let mut f = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0];
f.extend(w.to_le_bytes());
f.extend(h.to_le_bytes());
f.extend([24, 0]);
let [r, g, b] = rgb;
f.extend(std::iter::repeat_n([b, g, r], usize::from(w) * usize::from(h)).flatten());
f
}
/// A Targa file placed linked shows its pixels and updates when it changes, and a link can be
/// relinked to one.
#[test]
fn a_linked_targa_file_places_updates_and_relinks() {
let dir = Folder::new("tga");
let pic = dir.file("art/photo.tga");
write(&pic, &targa(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
assert_eq!(image(&s, id).link.unwrap().path, pic);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
write(&pic, &targa(400, 400, BLUE));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height), (400, 400));
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
let other = dir.file("other/red.vst");
write(&other, &targa(300, 100, RED));
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
/// A BMP file named `.dib` placed linked shows its pixels, is listed as BMP and updates when it
/// changes; relinked to a PNG named `.pns`, it is listed as PNG.
#[test]
fn linked_dib_and_pns_files_place_update_and_relink() {
let bmp = |w: u32, h: u32, rgb: [u8; 3]| {
let mut out = vec![];
let [r, g, b] = rgb;
image::RgbImage::from_pixel(w, h, image::Rgb([r, g, b])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Bmp).unwrap();
out
};
let dir = Folder::new("dib");
let pic = dir.file("art/scan.DIB");
write(&pic, &bmp(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
assert_eq!(s.execute("links.info", &json!({"id": id.0})).unwrap()["format"], "BMP");
write(&pic, &bmp(400, 400, BLUE));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
let other = dir.file("other/red.pns");
write(&other, &png(300, 100, RED));
assert_eq!(s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap(), json!({"relinked": [id.0], "notFound": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other));
assert_eq!(s.execute("links.info", &json!({"id": id.0})).unwrap()["format"], "PNG");
}
#[test]
fn the_relative_path_finds_the_file_after_the_folder_moves() {
let a = Folder::new("move-a");
let b = Folder::new("move-b");
let pic = a.file("art/photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
save(&mut s, &a.file("poster.vectorcraft"));
// Move the document with its links: the old folder is gone.
std::fs::remove_dir_all(&b.0).unwrap();
std::fs::rename(&a.0, &b.0).unwrap();
let r = open(&mut s, &b.file("poster.vectorcraft"));
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
let moved = b.file("art/photo.png");
let im = image(&s, id);
assert_eq!(Path::new(&im.link.unwrap().path), Path::new(&moved), "the link follows the file");
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy(), "the file's pixels were read");
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!((c["links"][0]["status"].as_str(), c["missing"].as_u64()), (Some("ok"), Some(0)), "{c}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
/// Links in symbols, graph designs, patterns and opacity masks are saved with their relative
/// paths, as links in the layers are: after the folder moves, each file is found and its link takes
/// the new path, which the next save writes relative to the document again.
#[test]
fn links_outside_the_layers_find_their_files_after_the_folder_moves() {
let a = Folder::new("nested-move-a");
let b = Folder::new("nested-move-b");
let c = Folder::new("nested-move-c");
let names = ["layer", "symbol", "design", "pattern", "mask"];
for (i, name) in (0u8..).zip(names) {
write(&a.file(&format!("art/{name}.png")), &png(20, 10, [20 + 40 * i, 90, 160]));
}
let mut s = session();
place(&mut s, &a.file("art/layer.png"));
let id = place(&mut s, &a.file("art/symbol.png"));
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap();
let id = place(&mut s, &a.file("art/design.png"));
s.execute("graph.design", &json!({"new": "Bar"})).unwrap();
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
let id = place(&mut s, &a.file("art/pattern.png"));
s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap();
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0);
let mask = place(&mut s, &a.file("art/mask.png"));
s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap();
let mut in_layers = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
in_layers.extend(im.link.as_ref().map(|l| l.name().to_string()));
}
});
assert_eq!(in_layers, ["layer.png"], "only one file is shown by an object in the layers");
// The links' paths, each image checked to show its file's pixels rather than the saved preview.
let paths = |s: &Session| {
let d = &s.doc().unwrap().doc;
let mut out = vec![];
d.visit_images(|_, im| {
let l = im.link.as_ref().unwrap();
assert!(!d.images[&im.key].is_proxy(), "{} shows its preview", l.name());
out.push(PathBuf::from(&l.path));
});
out.sort();
out
};
let files = |dir: &Folder| {
let mut out: Vec<PathBuf> = names.iter().map(|n| PathBuf::from(dir.file(&format!("art/{n}.png")))).collect();
out.sort();
out
};
assert_eq!(paths(&s), files(&a));
save(&mut s, &a.file("poster.vectorcraft"));
// Move the document with its links: the old folder is gone.
std::fs::remove_dir_all(&b.0).unwrap();
std::fs::rename(&a.0, &b.0).unwrap();
let r = open(&mut s, &b.file("poster.vectorcraft"));
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
assert_eq!(paths(&s), files(&b), "the links follow the files");
// Saved again and moved again.
save(&mut s, &b.file("poster.vectorcraft"));
std::fs::remove_dir_all(&c.0).unwrap();
std::fs::rename(&b.0, &c.0).unwrap();
let r = open(&mut s, &c.file("poster.vectorcraft"));
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
assert_eq!(paths(&s), files(&c));
}
/// With Update Links: Automatically, a file in a symbol and in the layers that changed and moved
/// with the document is read again for the layers' image and for the symbol's, and both links take
/// its new path, size and hash.
#[test]
fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() {
let a = Folder::new("nested-modified-a");
let b = Folder::new("nested-modified-b");
let pic = a.file("art/photo.png");
write(&pic, &png(20, 10, RED));
let mut s = session();
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
let id = place(&mut s, &pic);
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap();
let id = place(&mut s, &pic);
save(&mut s, &a.file("poster.vectorcraft"));
write(&pic, &png(30, 30, BLUE));
std::fs::remove_dir_all(&b.0).unwrap();
std::fs::rename(&a.0, &b.0).unwrap();
let r = open(&mut s, &b.file("poster.vectorcraft"));
assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}");
let moved = PathBuf::from(b.file("art/photo.png"));
let blue = png(30, 30, BLUE);
let mut images = vec![];
s.doc().unwrap().doc.visit_images(|_, im| {
let l = im.link.as_ref().unwrap();
images.push((PathBuf::from(&l.path), l.size, l.hash.clone(), im.width, im.height, im.key.clone()));
});
let key = image(&s, id).key;
let new = (moved, Some(blue.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&blue)), 30, 30, key);
assert_eq!(images, [new.clone(), new], "the layers' image, then the symbol's");
}
/// A link inside a symbol that the document records without a relative path, beside a link in the
/// layers to the same file that has one, takes the file's new path after the folder moves.
#[test]
fn a_symbols_link_without_a_relative_path_follows_the_moved_file() {
let a = Folder::new("nested-unrelative-a");
let b = Folder::new("nested-unrelative-b");
let pic = a.file("art/photo.png");
write(&pic, &png(20, 10, RED));
let mut s = session();
let id = place(&mut s, &pic);
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap();
place(&mut s, &pic);
let doc_path = a.file("poster.vectorcraft");
save(&mut s, &doc_path);
// Removes the relative path of every link under `v` → how many it removed.
fn unrelative(v: &mut Value) -> usize {
match v {
Value::Object(o) => {
let own = match o.get_mut("link") {
Some(Value::Object(l)) => usize::from(l.remove("relative").is_some()),
_ => 0,
};
own + o.values_mut().map(unrelative).sum::<usize>()
}
Value::Array(a) => a.iter_mut().map(unrelative).sum(),
_ => 0,
}
}
let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
assert_eq!(unrelative(&mut saved["document"]["symbols"]), 1, "the symbol's link");
assert_eq!(unrelative(&mut saved["document"]["layers"].clone()), 1, "the layers' link keeps its relative path");
std::fs::write(&doc_path, serde_json::to_vec(&saved).unwrap()).unwrap();
std::fs::remove_dir_all(&b.0).unwrap();
std::fs::rename(&a.0, &b.0).unwrap();
let r = open(&mut s, &b.file("poster.vectorcraft"));
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
let mut paths = vec![];
s.doc().unwrap().doc.visit_images(|_, im| paths.push(PathBuf::from(&im.link.as_ref().unwrap().path)));
let moved = PathBuf::from(b.file("art/photo.png"));
assert_eq!(paths, [moved.clone(), moved], "the layers' link, then the symbol's");
}
/// Update Links reads a changed file again for the images inside symbols, patterns and opacity
/// masks too: those showing the same content as an image in the layers, and those no object in the
/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step.
#[test]
fn update_links_reads_files_again_inside_symbols_patterns_and_masks() {
let dir = Folder::new("nested-update");
let (photo, logo) = (dir.file("photo.png"), dir.file("logo.png"));
write(&photo, &png(20, 10, RED));
write(&logo, &png(20, 10, [200, 200, 20]));
let mut s = session();
// photo.png in the layers, and in a symbol filling its bounds, clipped to them.
let id = place(&mut s, &photo);
s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap();
let id = place(&mut s, &photo);
// logo.png only in a pattern and in an opacity mask.
let p = place(&mut s, &logo);
s.execute("object.pattern.make", &json!({"ids": [p.0], "edit": false})).unwrap();
s.execute("edit.clear", &json!({"ids": [p.0]})).unwrap();
let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0);
let mask = place(&mut s, &logo);
s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap();
// photo.png wider than the symbol's image is: filling its bounds overflows them.
let (blue, green) = (png(60, 20, BLUE), png(40, 20, [20, 200, 20]));
write(&photo, &blue);
write(&logo, &green);
let u = s.execute("links.update", &json!({})).unwrap();
assert_eq!(u, json!({"updated": [id.0], "missing": []}));
// (name, size, hash, pixel size) of each image, which shows its file's pixels.
let images = |s: &Session| {
let d = &s.doc().unwrap().doc;
let mut out = vec![];
d.visit_images(|_, im| {
let l = im.link.as_ref().unwrap();
assert!(!d.images[&im.key].is_proxy(), "{} shows its preview", l.name());
out.push((l.name().to_string(), l.size, l.hash.clone(), (im.width, im.height)));
});
out.sort();
out
};
let shown = |name: &str, bytes: &[u8], size| (name.to_string(), Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(bytes)), size);
let logo_now = shown("logo.png", &green, (40, 20));
let photo_now = shown("photo.png", &blue, (60, 20));
assert_eq!(images(&s), [logo_now.clone(), logo_now, photo_now.clone(), photo_now]);
// The symbol's image is clipped to its old bounds.
let mut clipped = false;
s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| {
if let NodeKind::Group { children, clip: true } = &n.kind {
clipped |=
matches!(&children[..], [c, im] if matches!(c.kind, NodeKind::Path { clipping: true, .. }) && matches!(im.kind, NodeKind::Image(_)));
}
});
assert!(clipped, "{:?}", s.doc().unwrap().doc.symbols[0].art);
s.execute("edit.undo", &json!({})).unwrap();
assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s));
}
#[test]
fn a_missing_link_renders_its_preview_and_relinks() {
let dir = Folder::new("missing");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let placed = bounds(&s, id);
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!([{"name": "photo.png", "path": pic, "ids": [id.0]}]));
let doc = &s.doc().unwrap().doc;
assert!(doc.images[&image(&s, id).key].is_proxy(), "the preview stands in");
assert!(near(centre_colour(doc), RED), "missing still renders");
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!((c["links"][0]["status"].as_str(), c["links"][0]["preview"].as_bool(), c["missing"].as_u64()), (Some("missing"), Some(true), Some(1)));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [], "missing": [id.0]}));
// Replace: another file, smaller; the image keeps its bounds.
let other = dir.file("other/blue.png");
write(&other, &png(300, 100, BLUE));
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other.clone()));
let b = bounds(&s, id);
assert!((b.x0 - placed.x0).abs() < 1e-6 && (b.y1 - placed.y1).abs() < 1e-6, "{b:?} vs {placed:?}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "one undo step");
// Relink to Folder: the file of the link's name there.
write(&dir.file("found/photo.png"), &png(600, 300, BLUE));
let r = s.execute("links.relink", &json!({"folder": dir.file("found")})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}), "nothing selected: every missing link");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder");
}
/// The path of the file image `id` links to.
fn link_path(s: &Session, id: NodeId) -> String {
image(s, id).link.map(|l| l.path).unwrap_or_default()
}
/// Replace: All Instances relinks every image linked to the same file as the one given, in one
/// undo step; Selected Instances (the default) only the one given.
#[test]
fn relink_all_instances_takes_every_image_of_the_same_file() {
let dir = Folder::new("relink-all");
let (pic, other) = (dir.file("photo.png"), dir.file("other.png"));
write(&pic, &png(60, 30, RED));
write(&other, &png(60, 30, RED));
let mut s = session();
let (a, b, c) = (place(&mut s, &pic), place(&mut s, &pic), place(&mut s, &other));
let blue = dir.file("new/blue.png");
write(&blue, &png(60, 30, BLUE));
let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "selected"})).unwrap();
assert_eq!(r, json!({"relinked": [a.0], "notFound": []}));
assert_eq!(link_path(&s, b), pic, "selected instances: the other copy keeps its file");
s.execute("edit.undo", &json!({})).unwrap();
let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "all"})).unwrap();
assert_eq!(r, json!({"relinked": [a.0, b.0], "notFound": []}));
assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (blue.clone(), blue.clone(), other.clone()));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((link_path(&s, a), link_path(&s, b)), (pic.clone(), pic.clone()), "one undo step");
// The selection stands in for ids.
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let r = s.execute("links.relink", &json!({"path": blue, "scope": "all"})).unwrap();
assert_eq!(r["relinked"], json!([b.0, a.0]));
}
/// Replace: Missing Links from This Folder relinks the given links to the chosen file, then every
/// other missing link to the file of its name in that file's folder, in one undo step; names not
/// there are listed in notFound and stay missing.
#[test]
fn relink_missing_links_from_this_folder_finds_the_others_by_name() {
let dir = Folder::new("relink-missing-folder");
let names = ["a.png", "b.png", "c.png", "d.png"];
let mut s = session();
let ids: Vec<NodeId> = names
.iter()
.map(|n| {
write(&dir.file(n), &png(60, 30, RED));
place(&mut s, &dir.file(n))
})
.collect();
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
// d.png stays where it was: it isn't missing.
for n in &names[..3] {
std::fs::remove_file(dir.file(n)).unwrap();
}
open(&mut s, &doc_path);
let found = dir.file("found/a new name.png");
write(&found, &png(60, 30, BLUE));
write(&dir.file("found/b.png"), &png(60, 30, BLUE));
write(&dir.file("found/d.png"), &png(60, 30, BLUE));
let r = s.execute("links.relink", &json!({"ids": [ids[0].0], "path": found, "scope": "missingInFolder"})).unwrap();
assert_eq!(r, json!({"relinked": [ids[0].0, ids[1].0], "notFound": ["c.png"]}));
let b_found = dir.0.join("found").join("b.png").to_string_lossy().into_owned();
assert_eq!((link_path(&s, ids[0]), link_path(&s, ids[1])), (found.clone(), b_found));
assert_eq!((link_path(&s, ids[2]), link_path(&s, ids[3])), (dir.file("c.png"), dir.file("d.png")));
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!(c["missing"], 1, "{c}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 3, "one undo step");
}
/// Replace: Each Link Separately takes one file for each link, in one undo step.
#[test]
fn relink_each_link_separately_takes_a_file_for_each() {
let dir = Folder::new("relink-each");
let (one, two) = (dir.file("one.png"), dir.file("two.png"));
write(&one, &png(60, 30, RED));
write(&two, &png(60, 30, RED));
let mut s = session();
let (a, b, c) = (place(&mut s, &one), place(&mut s, &one), place(&mut s, &two));
let (x, y) = (dir.file("new/x.png"), dir.file("new/y.png"));
write(&x, &png(60, 30, BLUE));
write(&y, &png(30, 30, BLUE));
let paths = json!([{"ids": [a.0, b.0], "path": x}, {"ids": [c.0], "path": y}]);
let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap();
assert_eq!(r, json!({"relinked": [a.0, b.0, c.0], "notFound": []}));
assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (x.clone(), x.clone(), y.clone()));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((link_path(&s, a), link_path(&s, c)), (one.clone(), two.clone()), "one undo step");
// Two entries naming the same file read it once.
let paths = json!([{"ids": [a.0], "path": y}, {"ids": [c.0], "path": y}]);
let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap();
assert_eq!(r["relinked"], json!([a.0, c.0]));
}
/// With copy, a file that can't be shown stops the call before any file is copied into the
/// document's folder.
#[test]
fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() {
let dir = Folder::new("relink-each-copy");
let (one, two) = (dir.file("doc/one.png"), dir.file("doc/two.png"));
write(&one, &png(60, 30, RED));
write(&two, &png(60, 30, RED));
let mut s = session();
let (a, c) = (place(&mut s, &one), place(&mut s, &two));
save(&mut s, &dir.file("doc/poster.vectorcraft"));
let (x, bad) = (dir.file("new/x.png"), dir.file("new/broken.png"));
write(&x, &png(60, 30, BLUE));
write(&bad, b"not an image");
let paths = json!([{"ids": [a.0], "path": x}, {"ids": [c.0], "path": bad}]);
assert!(s.execute("links.relink", &json!({"scope": "each", "paths": paths, "copy": true})).is_err());
assert!(!Path::new(&dir.file("doc/x.png")).exists(), "no copy is left behind");
assert_eq!(link_path(&s, a), one);
}
/// A scope or paths that doesn't fit is refused, and nothing is relinked.
#[test]
fn relink_refuses_scopes_and_paths_that_dont_fit() {
let dir = Folder::new("relink-bad-scope");
let pic = dir.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
let a = place(&mut s, &pic);
let blue = dir.file("blue.png");
write(&blue, &png(60, 30, BLUE));
let bad = [
json!({"ids": [a.0], "path": blue, "scope": "everything"}),
json!({"ids": [a.0], "path": blue, "scope": 3}),
json!({"scope": "each"}),
json!({"scope": "each", "paths": []}),
json!({"scope": "each", "paths": [{"ids": [a.0]}]}),
json!({"scope": "each", "paths": [{"path": blue}]}),
json!({"scope": "each", "paths": [{"ids": ["one"], "path": blue}]}),
json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}, {"ids": [a.0], "path": pic}]}),
json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "path": blue}),
json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "ids": [a.0]}),
json!({"scope": "all", "paths": [{"ids": [a.0], "path": blue}]}),
json!({"ids": [a.0], "paths": [{"ids": [a.0], "path": blue}]}),
json!({"ids": [a.0], "folder": dir.0.to_string_lossy(), "scope": "missingInFolder"}),
json!({"ids": [a.0], "scope": "missingInFolder"}),
];
for p in bad {
assert!(s.execute("links.relink", &p).is_err(), "{p}");
assert_eq!(link_path(&s, a), pic, "{p} relinks nothing");
}
}
/// A link stored as a classic Mac OS path (`Disk:Folder:file`, as older `.ai` files keep them) is
/// named by its last component: the Links panel shows that name and its folder, Relink to Folder
/// looks for that name, and the document's own folder is searched for it. The stored path is kept.
#[test]
fn a_classic_mac_path_is_found_by_its_file_name() {
const CLASSIC: &str = "Macintosh HD:Images:photo.png";
let dir = Folder::new("classic-mac");
let pic = dir.file("art/photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
place(&mut s, &pic);
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
saved_image(&mut v)["link"] = json!({ "path": CLASSIC });
let doc_path = dir.file("poster.vectorcraft");
write(&doc_path, &serde_json::to_vec(&v).unwrap());
let r = open(&mut s, &doc_path);
let id = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
assert_eq!(r["missingLinks"], json!([{"name": "photo.png", "path": CLASSIC, "ids": [id.0]}]));
let i = s.execute("links.info", &json!({"id": id.0})).unwrap();
assert_eq!(
(i["fileName"].as_str(), i["location"].as_str(), i["path"].as_str()),
(Some("photo.png"), Some("Macintosh HD:Images"), Some(CLASSIC)),
"{i}"
);
// Relink to Folder: the file of the link's name there.
let r = s.execute("links.relink", &json!({"ids": [id.0], "folder": dir.file("found")})).unwrap();
assert_eq!(r, json!({"relinked": [], "notFound": ["photo.png"]}));
write(&dir.file("found/photo.png"), &png(600, 300, BLUE));
let r = s.execute("links.relink", &json!({"ids": [id.0], "folder": dir.file("found")})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
// The document's folder: the file of the link's name beside the document.
write(&dir.file("photo.png"), &png(600, 300, RED));
let r = open(&mut s, &doc_path);
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
/// A `w`×`h` PNG of noise cut to half its length: its header reads, its pixels don't.
pub(crate) fn cut_png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_fn(w, h, |x, y| {
let v = x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503).rotate_left(13);
image::Rgb([v as u8, (v >> 8) as u8, (v >> 16) as u8])
});
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out.truncate(out.len() / 2);
out
}
/// A linked file whose pixels can't be read (cut short) isn't shown: Relink is an error, and
/// Update Links and Open (Update Links: Automatically) count it as missing. The image keeps what
/// it showed.
#[test]
fn a_linked_file_whose_pixels_cant_be_read_is_not_shown() {
let dir = Folder::new("cut");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let shown = image(&s, id);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
let cut = dir.file("cut.png");
write(&cut, &cut_png(300, 100));
let e = s.execute("links.relink", &json!({"ids": [id.0], "path": cut})).unwrap_err();
assert!(e.to_string().ends_with("the image's pixels can't be read"), "{e}");
write(&pic, &cut_png(300, 100));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [], "missing": [id.0]}));
assert_eq!(image(&s, id), shown);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!((r["missingLinks"][0]["ids"].clone(), r["updatedLinks"].clone()), (json!([id.0]), json!([])), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "the image still shows its preview");
}
#[test]
fn a_modified_link_follows_the_update_links_preference() {
let dir = Folder::new("modified");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let placed = bounds(&s, id);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
write(&pic, &png(400, 400, BLUE));
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!((c["links"][0]["status"].as_str(), c["modified"].as_u64()), (Some("modified"), Some(1)), "{c}");
// Ask When Modified (the default): reported, left as it was.
let r = open(&mut s, &doc_path);
assert_eq!((r["modifiedLinks"][0]["ids"].clone(), r["updatedLinks"].clone()), (json!([id.0]), json!([])), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "the preview until updated");
let r = s.execute("links.update", &json!({})).unwrap();
assert_eq!(r, json!({"updated": [id.0], "missing": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height), (400, 400));
let b = bounds(&s, id);
assert!((b.width() - placed.width()).abs() < 1e-6 && (b.height() - placed.height()).abs() < 1e-6, "keeps its bounds: {b:?}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Update Links");
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!(c["links"][0]["status"], "ok");
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], json!([]), "nothing left to update");
// Automatically: read again as the document opens.
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
assert!(s.doc().unwrap().history.undo.is_empty(), "opening is not an undo step");
}
/// Preferences › File Handling › Use Low Resolution Proxy for Linked EPS (#394): an unchanged linked
/// `.eps` keeps its saved preview on open; Update Links still reads a modified file when set to
/// Automatically.
#[test]
fn low_res_proxy_eps_keeps_unchanged_preview_and_automatic_update_reads_modified_eps() {
let dir = Folder::new("eps-proxy");
let pic = dir.file("photo.eps");
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);
s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap();
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "askWhenModified"})).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "unchanged EPS stays on its preview");
assert!(s.doc().unwrap().doc.images[&image(&s, id).key].is_proxy());
write(&pic, &png(400, 400, BLUE));
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!(r["updatedLinks"][0]["ids"].clone(), json!([id.0]), "modified EPS updates on open: {r}");
assert!(!s.doc().unwrap().doc.images[&image(&s, id).key].is_proxy());
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
/// With Low Resolution Proxy on, export reads the linked EPS file's full pixels, not the preview.
#[test]
fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() {
let dir = Folder::new("eps-proxy-export");
let pic = dir.file("photo.eps");
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);
s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}");
let key = image(&s, id).key;
assert!(s.doc().unwrap().doc.images[&key].is_proxy());
let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(!full.images[&key].is_proxy());
let dim =
image::ImageReader::new(std::io::Cursor::new(full.images[&key].bytes.as_ref())).with_guessed_format().unwrap().into_dimensions().unwrap();
assert_eq!(dim, (600, 300));
let png_out = s.execute("document.export", &json!({"format": "png"})).unwrap();
let bytes = vectorcraft_format::base64_decode(png_out["dataBase64"].as_str().unwrap()).unwrap();
let rendered = image::load_from_memory(&bytes).unwrap();
assert!(rendered.width() >= 400 && rendered.height() >= 300);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "canvas still shows the preview");
}
/// An SVG linking two missing files: once one of them is found, export reads its pixels into its
/// own images only, and the other file's image keeps its placeholder.
#[test]
fn export_reads_a_found_svg_link_into_its_own_images_only() {
let dir = Folder::new("svg-missing-keys");
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><image href="a.png" width="20" height="20"/><image href="b.png" x="20" width="20" height="20"/></svg>"#;
let svg_path = dir.file("art.svg");
write(&svg_path, svg.as_bytes());
let mut s = session();
open(&mut s, &svg_path);
let mut found = vec![];
s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone()));
let [a, b] = &found[..] else { panic!("two images: {found:?}") };
assert!(a.link.as_ref().unwrap().path.ends_with("a.png") && b.link.as_ref().unwrap().path.ends_with("b.png"));
let placeholder = s.doc().unwrap().doc.images[&b.key].bytes.clone();
let bytes = png(20, 20, RED);
write(&dir.file("a.png"), &bytes);
let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None);
assert_eq!(warnings.len(), 1, "b.png is still missing: {warnings:?}");
assert_eq!(*full.images[&a.key].bytes, bytes, "a.png's pixels");
assert_eq!(full.images[&b.key].bytes, placeholder, "b.png keeps its placeholder");
}
/// An SVG linking two files with the same pixels, saved with previews only: once one file is gone
/// and the other has changed, export reads the changed file into its own images only.
#[test]
fn export_reads_a_changed_svg_link_into_its_own_images_only() {
let dir = Folder::new("svg-same-pixels");
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><image href="a.png" width="20" height="20"/><image href="b.png" x="20" width="20" height="20"/></svg>"#;
let svg_path = dir.file("art.svg");
write(&svg_path, svg.as_bytes());
write(&dir.file("a.png"), &png(600, 300, RED));
write(&dir.file("b.png"), &png(600, 300, RED));
let mut s = session();
open(&mut s, &svg_path);
let doc_path = dir.file("art.vectorcraft");
save(&mut s, &doc_path);
std::fs::remove_file(dir.file("a.png")).unwrap();
let blue = png(600, 300, BLUE);
write(&dir.file("b.png"), &blue);
open(&mut s, &doc_path);
let mut found = vec![];
s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone()));
let [a, b] = &found[..] else { panic!("two images: {found:?}") };
assert!(a.link.as_ref().unwrap().path.ends_with("a.png") && b.link.as_ref().unwrap().path.ends_with("b.png"));
assert_ne!(a.key, b.key, "two files, two keys");
let doc = &s.doc().unwrap().doc;
assert!(doc.images[&a.key].is_proxy() && doc.images[&b.key].is_proxy(), "previews");
let preview = doc.images[&a.key].bytes.clone();
let (full, warnings) = cmd::links::full_linked_images(doc, None);
assert_eq!(warnings.len(), 1, "a.png is missing: {warnings:?}");
assert_eq!(*full.images[&b.key].bytes, blue, "b.png's new pixels");
assert_eq!(full.images[&a.key].bytes, preview, "a.png keeps its preview");
}
/// The half of opening that the desktop app runs on a thread of its own (`read_for_open`) reads
/// the linked files and lays out the type. The half left for the UI thread (`open_read`) reads no
/// file: with a linked file moved away in between, it opens the document that `document.open` opens.
#[test]
fn a_file_read_for_opening_has_its_links_read_and_its_type_laid_out() {
let dir = Folder::new("read-for-open");
let tif = dir.file("art/photo.tif");
let mut tiff = vec![];
image::RgbImage::from_pixel(600, 300, image::Rgb(RED)).write_to(&mut std::io::Cursor::new(&mut tiff), image::ImageFormat::Tiff).unwrap();
write(&tif, &tiff);
let (gone, changed) = (dir.file("gone.png"), dir.file("changed.png"));
write(&gone, &png(300, 100, BLUE));
write(&changed, &png(300, 100, BLUE));
let mut s = session();
let photo = place(&mut s, &tif);
place(&mut s, &gone);
place(&mut s, &changed);
let area = json!({"width": 150, "height": 20, "fit": "autoHeight"});
let r = s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Linked files and type. ".repeat(12), "area": area})).unwrap();
let text = NodeId(r["id"].as_u64().unwrap());
s.execute("text.areaOptions", &json!({"ids": [text.0], "columns": 2})).unwrap();
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
std::fs::remove_file(&gone).unwrap();
write(&changed, &png(200, 200, RED));
let bytes = std::fs::read(&doc_path).unwrap();
let away = dir.file("away.tif");
for update in ["askWhenModified", "automatically"] {
let mut at_once = session();
at_once.execute("prefs.set", &json!({"key": "updateLinks", "value": update})).unwrap();
let expected = open(&mut at_once, &doc_path);
let rows = if update == "automatically" { &expected["updatedLinks"] } else { &expected["modifiedLinks"] };
assert!(expected["missingLinks"][0]["name"] == "gone.png" && rows[0]["name"] == "changed.png", "{update}: {expected}");
let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, update == "automatically", false).unwrap();
let doc = file.document();
let NodeKind::Image(im) = &doc.node(photo).unwrap().kind else { panic!("not an image") };
assert!(!doc.images[&im.key].is_proxy(), "{update}: the TIFF is read, not the preview saved for it");
let NodeKind::Text(t) = &doc.node(text).unwrap().kind else { panic!("not type") };
assert!(t.cached_bounds.is_some(), "{update}: the type is laid out");
std::fs::rename(&tif, &away).unwrap();
let mut opened = session();
let r = cmd::fileio::open_read(&mut opened, file).unwrap();
std::fs::rename(&away, &tif).unwrap();
assert_eq!(r, expected, "{update}");
let (a, b) = (at_once.doc().unwrap(), opened.doc().unwrap());
assert!(*b.doc == *a.doc, "{update}: the same document");
assert!(b.history.undo.is_empty() && !b.is_dirty(), "{update}");
}
}
/// Opening lays out each type object with the plans the objects before it left on the font's
/// face: its bounds and baselines are those of a layout in a font database of its own, which holds
/// no plans. The objects share a script and direction and differ in features and language. Only
/// bundled Latin glyphs are set, which every font database holds.
#[test]
fn type_read_for_opening_has_the_layout_of_a_font_database_with_no_plans() {
let dir = Folder::new("read-for-open-plans");
let style = vectorcraft_doc::CharStyle { size: 100.0, ..Default::default() };
let manual = vectorcraft_doc::CharStyle { kerning: Some(0.0), ..style.clone() };
let turkish = vectorcraft_doc::CharStyle { language: Some("tr".into()), ..style.clone() };
let mut doc = Document::new(800.0, 600.0);
let layer = doc.layers[0].id;
for (text, style) in [("AVAV", &style), ("AVAV", &manual), ("office", &turkish), ("To Wa", &style), ("To Wa", &manual)] {
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(20.0, 120.0), text, style.clone());
let id = doc.alloc_id();
doc.insert(Some(layer), usize::MAX, vectorcraft_doc::Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
}
let doc_path = dir.file("type.vectorcraft");
let bytes = vectorcraft_format::save_file(&doc);
let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, false, false).unwrap();
let mut seen = 0;
file.document().walk(|node| {
if let NodeKind::Text(t) = &node.kind {
let fresh = vectorcraft_text::layout(&vectorcraft_text::FontDb::with_font_dirs(vec![]), t);
assert_eq!(t.cached_bounds, Some(fresh.bounds), "{:?}", t.plain_text());
assert_eq!(t.cached_baselines, fresh.baselines(), "{:?}", t.plain_text());
seen += 1;
}
});
assert_eq!(seen, 5);
}
#[test]
fn links_from_older_files_load_and_keep_their_pixels() {
let dir = Folder::new("legacy");
let pic = dir.file("photo.png");
let bytes = png(600, 300, RED);
write(&pic, &bytes);
let mut s = session();
s.execute("file.place", &json!({"path": pic, "at": [200, 150], "link": false})).unwrap();
// As older versions saved a link: the path alone, the pixels embedded.
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
saved_image(&mut v)["link"] = json!(pic);
v["format"] = json!("drawcraft");
let legacy = serde_json::to_vec(&v).unwrap();
let doc_path = dir.file("old.drawcraft");
write(&doc_path, &legacy);
let r = open(&mut s, &doc_path);
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
let st = s.doc().unwrap();
let id = st.doc.layers[0].children().unwrap()[0].id;
let im = image(&s, id);
let link = im.link.unwrap();
assert_eq!((link.path.as_str(), link.size), (pic.as_str(), Some(bytes.len() as u64)), "the details are filled in");
assert!(s.doc().unwrap().doc.images[&im.key].proxy.is_some(), "a preview to save from now on");
// Gone, the embedded pixels still show.
std::fs::remove_file(&pic).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!(r["missingLinks"][0]["name"], "photo.png");
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy());
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn without_a_file_system_the_saved_preview_shows() {
// What the web does: the document arrives as bytes and no linked file can be read.
let dir = Folder::new("web");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
std::fs::remove_file(&pic).unwrap();
let doc = vectorcraft_format::load(&saved).unwrap();
assert!(doc.images.values().all(|b| b.is_proxy()));
assert!(near(centre_colour(&doc), RED), "the preview renders");
let r = s.execute("document.open", &json!({"name": "poster.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&saved)})).unwrap();
assert_eq!(r["missingLinks"][0]["ids"], json!([id.0]));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
// Exports carry the preview.
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
assert!(svg["text"].as_str().unwrap().contains("data:image/png;base64,"));
}
#[test]
fn relative_paths_are_written_against_the_saved_file() {
let mut d = Document::new(10.0, 10.0);
let (lid, nid) = (d.layers[0].id, d.alloc_id());
let root = if cfg!(windows) { "C:\\work" } else { "/work" };
let sep = std::path::MAIN_SEPARATOR;
let im = ImageObject {
key: "k".into(),
width: 1,
height: 1,
xf: Default::default(),
link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))),
placement: Default::default(),
pixels: None,
};
d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap();
let rel = |dest: String| {
let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone());
match &d.node(nid).unwrap().kind {
NodeKind::Image(im) => im.link.as_ref().unwrap().relative.clone(),
_ => None,
}
};
assert_eq!(rel(format!("{root}{sep}doc.vectorcraft")).as_deref(), Some("art/a.png"));
assert_eq!(rel(format!("{root}{sep}docs{sep}deep{sep}doc.vectorcraft")).as_deref(), Some("../../art/a.png"));
assert_eq!(rel(format!("{root}{sep}art{sep}doc.vectorcraft")).as_deref(), Some("a.png"));
if cfg!(windows) {
assert_eq!(rel("D:\\other\\doc.vectorcraft".into()), None, "another drive");
assert_eq!(rel("c:\\WORK\\doc.vectorcraft".into()).as_deref(), Some("art/a.png"), "without case");
}
}
#[test]
fn a_placed_document_brings_its_links_with_the_files_pixels() {
let dir = Folder::new("place-doc");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
place(&mut s, &pic);
let doc_path = dir.file("part.vectorcraft");
save(&mut s, &doc_path);
let mut s = session();
// As an editable copy (placed linked, it stays one locked object).
s.execute("file.place", &json!({"path": doc_path, "link": false})).unwrap();
let doc = &s.doc().unwrap().doc;
let mut placed = vec![];
doc.visit_images(|_, im| placed.push(im.clone()));
let [im] = &placed[..] else { panic!("one image: {placed:?}") };
assert_eq!(im.link.as_ref().map(|l| l.path.as_str()), Some(pic.as_str()), "still linked");
assert!(!doc.images[&im.key].is_proxy(), "the file's pixels, not the preview");
}
/// The placed (linked) files of a `.ai` file come in with the preview the file keeps of them: one
/// not found shows it, one found is read at once into the same bounds, which isn't a change. A copy
/// saved without PDF compatibility, placed without Link, reads the found file too.
#[test]
fn links_of_an_imported_file_are_read_where_they_are_found() {
use vectorcraft_testkit::ai;
let dir = Folder::new("imported");
let pic = dir.file("photo.png").replace('\\', "/");
// A 2×1 preview (premultiplied ARGB: a blue at half opacity) of a file of 600 × 300 px placed at
// 60 × 30 pt, which the PDF part draws there too.
let place = format!(
"%AI5_BeginPlace\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n({pic})`\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 \x7f\x10\x10\x60\x7f\x10\x10\x60\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n"
);
let doc_path = dir.file("poster.ai");
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &place));
write(&doc_path, &ai::ai(data.as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f"));
let placed = |s: &Session| {
let mut found = vec![];
s.doc().unwrap().doc.visit_images(|n, im| found.push((n.geometric_bounds().unwrap(), im.clone())));
let [one] = &found[..] else { panic!("one image: {found:?}") };
one.clone()
};
let in_place = |b: Rect| [b.x0 - 20.0, b.y0 - 20.0, b.x1 - 80.0, b.y1 - 50.0].iter().all(|d| d.abs() < 1e-6);
let mut s = session();
let r = open(&mut s, &doc_path);
assert_eq!((r["missingLinks"][0]["name"].as_str(), r["missingLinks"][0]["path"].as_str()), (Some("photo.png"), Some(pic.as_str())), "{r}");
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("low-resolution preview")), "{r}");
let (b, im) = placed(&s);
assert!(in_place(b) && im.width == 2 && s.doc().unwrap().doc.images[&im.key].is_proxy(), "{b:?} {im:?}");
write(&pic, &png(600, 300, RED));
let r = open(&mut s, &doc_path);
assert_eq!((&r["missingLinks"], &r["modifiedLinks"], &r["updatedLinks"]), (&json!([]), &json!([]), &json!([])), "{r}");
let (b, im) = placed(&s);
assert!(in_place(b) && (im.width, im.height) == (600, 300) && im.link.as_ref().is_some_and(|l| l.hash.is_some()), "{b:?} {im:?}");
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy());
let alone = dir.file("alone.ai");
write(&alone, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET"));
let mut s = session();
s.execute("file.place", &json!({"path": alone, "link": false})).unwrap();
let (_, im) = placed(&s);
assert!((im.width, im.height) == (600, 300) && !s.doc().unwrap().doc.images[&im.key].is_proxy(), "{im:?}");
}
/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the
/// file keeps relative to the document's (`src/`), and its link takes the size, modification time
/// and hash of the file found there. Not found, the link keeps no such details.
#[test]
fn links_of_an_imported_file_are_found_by_their_relative_folder() {
use vectorcraft_testkit::ai;
let dir = Folder::new("imported-relative");
std::fs::create_dir_all(dir.0.join("src")).unwrap();
let pic = dir.file("src/photo.png").replace('\\', "/");
let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n(/no/such/folder/src/photo.png)`\n\
%%IncludeFile:/no/such/folder/src/photo.png\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\
%AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\
2 1 8 32 17 \x7f\x10\x10\x60\x7f\x10\x10\x60\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n";
let doc_path = dir.file("poster.ai");
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place));
write(&doc_path, &ai::ai(data.as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f"));
let link = |s: &Session| {
let mut found = vec![];
s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone()));
let [im] = &found[..] else { panic!("one image: {found:?}") };
(im.link.clone().unwrap(), s.doc().unwrap().doc.images[&im.key].is_proxy())
};
let mut s = session();
let r = open(&mut s, &doc_path);
assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("photo.png"), "{r}");
let (l, proxy) = link(&s);
assert_eq!((l.path.as_str(), l.relative.as_deref()), ("/no/such/folder/src/photo.png", Some("src/photo.png")));
assert!(proxy && l.size.is_none() && l.modified.is_none() && l.hash.is_none(), "{l:?}");
let bytes = png(600, 300, RED);
write(&pic, &bytes);
let r = open(&mut s, &doc_path);
assert_eq!(&r["missingLinks"], &json!([]), "{r}");
let (l, proxy) = link(&s);
assert!(!proxy, "the file's pixels, not the preview");
assert_eq!(l.path.replace('\\', "/"), pic);
assert_eq!((l.size, l.hash.clone()), (Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&bytes))));
assert!(l.modified.is_some(), "{l:?}");
}