diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs
index a4c222090..3fe46ad35 100644
--- a/crates/engine/src/tests/links.rs
+++ b/crates/engine/src/tests/links.rs
@@ -452,6 +452,63 @@ fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() {
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#""#;
+ 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#""#;
+ 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.
diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs
index b6d488c9e..9de3bebcd 100644
--- a/crates/svg/src/import/files.rs
+++ b/crates/svg/src/import/files.rs
@@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use usvg::{ImageHrefResolver, ImageKind, roxmltree};
use vectorcraft_doc::LinkInfo;
+use vectorcraft_doc::links::missing_placeholder;
use super::{Edits, depth, href};
use crate::{ImportOptions, MAX_ELEMENT_DEPTH, MAX_SVGZ, SvgError};
@@ -62,22 +63,6 @@ pub(in crate::import) struct Linked {
pub missing: bool,
}
-/// The side of the placeholder image, in pixels.
-const MARKER: u32 = 64;
-
-/// The placeholder of a missing linked image: a light tile, crossed out (drawn here).
-fn marker() -> Option> {
- let last = MARKER - 1;
- let img = image::RgbaImage::from_fn(MARKER, MARKER, |x, y| {
- let edge = x == 0 || y == 0 || x == last || y == last;
- let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1;
- image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] })
- });
- let mut png = Vec::new();
- img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
- Some(png)
-}
-
/// `s` with `%XX` escapes decoded (an href is a URL).
fn percent_decoded(s: &str) -> String {
if !s.contains('%') {
@@ -159,7 +144,7 @@ impl Files {
};
let source = source.or_else(|why| {
warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked"));
- let bytes = Arc::new(placeholder.get_or_insert_with(marker).clone().ok_or(why)?);
+ let bytes = Arc::new(placeholder.get_or_insert_with(missing_placeholder).clone().ok_or(why)?);
files.links.insert(key(&bytes), Linked { link: LinkInfo::new(path), missing: true });
Ok::<_, &str>(Source::Raster(ImageKind::PNG, bytes))
});
@@ -479,10 +464,4 @@ mod tests {
assert!(!read("2147483647"));
assert!(read("1"));
}
-
- #[test]
- fn the_placeholder_is_a_png() {
- let png = marker().unwrap();
- assert_eq!(image::load_from_memory(&png).unwrap().width(), MARKER);
- }
}
diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs
index 49bcfaab7..55cf0aa91 100644
--- a/crates/svg/src/import/importer.rs
+++ b/crates/svg/src/import/importer.rs
@@ -26,6 +26,13 @@ pub(super) struct SymbolMade {
reuse: Reuse,
}
+/// The image key of a linked image of the file at `path` that shows `bytes` (the file's pixels or
+/// a placeholder): the key of the bytes and the path's. Images of other files that show the same
+/// pixels have keys of their own, and reading one file's pixels in changes that file's images only.
+fn linked_key(bytes: &[u8], path: &str) -> String {
+ format!("img-{:016x}-{}", fnv1a(bytes), vectorcraft_doc::links::hash_bytes(path.as_bytes()))
+}
+
impl Importer {
pub(super) fn warn(&mut self, s: String) {
if !self.warnings.contains(&s) {
@@ -721,11 +728,14 @@ impl Importer {
.ok()
.and_then(|r| r.into_dimensions().ok())
.unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32));
- let key = format!("img-{:016x}", fnv1a(bytes));
// A linked file keeps a preview; a placeholder is all a missing one has (its preview).
let linked = self.files.linked(i.kind());
let missing = linked.is_some_and(|l| l.missing);
let link = linked.map(|l| l.link.clone());
+ let key = match &link {
+ Some(l) => linked_key(bytes, &l.path),
+ None => format!("img-{:016x}", fnv1a(bytes)),
+ };
self.doc.images.entry(key.clone()).or_insert_with(|| {
let blob = ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: missing.then(|| bytes.clone()) };
if link.is_some() && !missing { blob.with_proxy() } else { blob }
diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs
index 13518a6fd..7857ba07c 100644
--- a/crates/svg/tests/it/import_details.rs
+++ b/crates/svg/tests/it/import_details.rs
@@ -105,6 +105,40 @@ fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() {
assert!(!w.is_empty());
}
+/// Missing files show the same placeholder, each under a key of its own: the images of one file
+/// share a key, and those of another file have a different one.
+#[test]
+fn each_missing_file_has_its_own_placeholder_key() {
+ let dir = folder("missing-keys");
+ let body = r#""#;
+ let (d, _) = open_in(&dir, body);
+ let [(_, a), (_, b), (_, a2)] = images(&d)[..] else { panic!("{:?}", art(&d)) };
+ assert_eq!(Path::new(&a.link.as_ref().unwrap().path), dir.join("img").join("a.png"));
+ assert_eq!(Path::new(&b.link.as_ref().unwrap().path), dir.join("img").join("b.png"));
+ assert_ne!(a.key, b.key, "two files, two keys");
+ assert_eq!(a.key, a2.key, "one file, one key");
+ assert!(d.images[&a.key].is_proxy() && d.images[&b.key].is_proxy(), "placeholders, not the files");
+ assert_eq!(d.images[&a.key].bytes, d.images[&b.key].bytes, "the same placeholder");
+}
+
+/// Two files with the same pixels are linked under keys of their own: the images of one file share
+/// a key, and those of the other file have a different one.
+#[test]
+fn files_with_the_same_pixels_have_keys_of_their_own() {
+ let dir = folder("same-pixels");
+ let bytes = png(40, 20);
+ std::fs::write(dir.join("img").join("a.png"), &bytes).unwrap();
+ std::fs::write(dir.join("img").join("b.png"), &bytes).unwrap();
+ let body = r#""#;
+ let (d, w) = open_in(&dir, body);
+ assert!(w.is_empty(), "{w:?}");
+ let [(_, a), (_, b), (_, a2)] = images(&d)[..] else { panic!("{:?}", art(&d)) };
+ assert_ne!(a.key, b.key, "two files, two keys");
+ assert_eq!(a.key, a2.key, "one file, one key");
+ assert_eq!(*d.images[&a.key].bytes, bytes);
+ assert_eq!(*d.images[&b.key].bytes, bytes);
+}
+
#[test]
fn svg_images_import_as_vector_art() {
let dir = folder("svgimage");