From 2e5ec7b2517066893017182ad7d7b3963c656f4e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 09:35:06 +0700 Subject: [PATCH] M4.53: Linked images remember their file and keep a preview; missing links ask Replace or Ignore, modified ones follow Update Links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A linked image (`ImageObject.link`, now a LinkInfo: absolute path, path relative to the document, size, modification time, hash, page; old path strings still load) keeps a low-resolution preview (`ImageBlob.proxy`, at most 256 px a side) that a native save writes in place of the pixels of images only linked images show. Opening a native document reads the linked files again, looking for each at its path, then relative to the document and by name in its folder: missing files show their preview, modified ones are read again only with Update Links: Automatically. `document.open` reports `missingLinks`, `modifiedLinks` and `updatedLinks`. links.check reports each link's status, links.update reads modified files again and links.relink points images at another file or at same-named files in a folder (each keeps its bounds; one undo step). In the app, a document opened without its linked files asks about each (Replace…, Ignore, Apply to All), then offers to update modified links when the preference asks. The renderer caches previews apart from the file's pixels; the Eyedropper samples previews in the object's pixel space. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 6 + crates/doc/src/links.rs | 215 +++++++++ crates/doc/src/node.rs | 6 +- crates/doc/src/pixels.rs | 76 ++-- crates/engine/src/cmd/docinfo.rs | 5 +- crates/engine/src/cmd/fileio/load.rs | 10 +- crates/engine/src/cmd/fileio/mod.rs | 31 +- crates/engine/src/cmd/fileio/save.rs | 14 +- crates/engine/src/cmd/flatten.rs | 2 +- crates/engine/src/cmd/links.rs | 477 ++++++++++++++++++++ crates/engine/src/cmd/menucmds.rs | 4 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/place/mod.rs | 28 +- crates/engine/src/cmd/rasterfx.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_build.rs | 2 +- crates/engine/src/tests_links.rs | 324 +++++++++++++ crates/format/src/lib.rs | 28 +- crates/format/tests/links.rs | 63 +++ crates/format/tests/prop_format.rs | 4 +- crates/mcp/src/lib.rs | 2 + crates/mcp/src/tests_links.rs | 34 ++ crates/pdf/src/import.rs | 10 +- crates/pdf/src/tests.rs | 2 +- crates/render/src/lib.rs | 19 +- crates/render/src/tests_cmykblend.rs | 2 +- crates/render/src/tests_objectfx.rs | 2 +- crates/render/src/tests_setup.rs | 4 +- crates/svg/src/export.rs | 4 +- crates/svg/src/import.rs | 2 +- crates/svg/tests/options.rs | 4 +- crates/svg/tests/roundtrip.rs | 2 +- crates/tools/src/xform/eyedropper.rs | 2 +- crates/ui-egui/src/dialogs/missing_links.rs | 254 +++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/io.rs | 6 +- crates/ui-egui/src/tests_place.rs | 2 +- docs/control-protocol.md | 9 + docs/mcp.md | 19 + 39 files changed, 1586 insertions(+), 96 deletions(-) create mode 100644 crates/doc/src/links.rs create mode 100644 crates/engine/src/cmd/links.rs create mode 100644 crates/engine/src/tests_links.rs create mode 100644 crates/format/tests/links.rs create mode 100644 crates/mcp/src/tests_links.rs create mode 100644 crates/ui-egui/src/dialogs/missing_links.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f0e468d3..39013a0b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ pub mod appearance; pub mod graph; pub mod hit; +pub mod links; pub mod live; pub mod marks; pub mod metadata; @@ -46,6 +47,7 @@ pub use appearance::{ }; pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; +pub use links::LinkInfo; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; @@ -401,6 +403,10 @@ pub struct ImageBlob { pub mime: String, #[serde(skip)] pub bytes: Arc>, + /// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only + /// linked images show this blob, and what `bytes` hold while the linked file can't be read. + #[serde(skip)] + pub proxy: Option>>, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs new file mode 100644 index 00000000..7a9e0211 --- /dev/null +++ b/crates/doc/src/links.rs @@ -0,0 +1,215 @@ +//! Linked files: where a placed file lives and what it was when last read ([`LinkInfo`]), the +//! low-resolution preview a linked image keeps for when its file can't be read +//! ([`ImageBlob::proxy`]), and every image object of a document ([`Document::visit_images`]). + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Cursor; +use std::sync::Arc; + +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::{Document, ImageBlob, ImageObject, Node, NodeKind}; + +/// The longest side of a linked image's preview (pixels). Smaller images keep no preview: their +/// full pixels are saved instead. +pub const PROXY_SIZE: u32 = 256; +/// The MIME type of previews. +pub const PROXY_MIME: &str = "image/png"; + +/// A linked file: an image object shows the file at `path` (File → Place with Link) and the +/// document saves only a preview of it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct LinkInfo { + /// The file's absolute path. + pub path: String, + /// The path relative to the document's folder (`/` separators), written on save: how the file + /// is found after the document and its links move together. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relative: Option, + /// The file's modification time when last read (milliseconds since the Unix epoch). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified: Option, + /// The file's size when last read (bytes). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// The hash of the file's bytes when last read ([`hash_bytes`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hash: Option, + /// The page shown, for a linked document page (1-based). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, +} + +impl LinkInfo { + /// A link to `path` with nothing known about the file. + pub fn new(path: impl Into) -> Self { + Self { path: path.into(), ..Self::default() } + } + + /// The file name: the path's last component, whichever system's separators it uses. + pub fn name(&self) -> &str { + self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path) + } +} + +impl From<&str> for LinkInfo { + fn from(path: &str) -> Self { + Self::new(path) + } +} + +/// [`ImageObject::link`] from a link object or, as files saved before links had details, a path. +pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Path(String), + Info(LinkInfo), + } + Ok(Option::::deserialize(d)?.map(|r| match r { + Repr::Path(path) => LinkInfo::new(path), + Repr::Info(info) => info, + })) +} + +/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits. +pub fn hash_bytes(bytes: &[u8]) -> String { + let h = bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3)); + format!("img{h:016x}") +} + +impl ImageBlob { + /// A blob of encoded image `bytes` of type `mime`, without a preview. + pub fn new(mime: impl Into, bytes: Vec) -> Self { + Self { mime: mime.into(), bytes: Arc::new(bytes), proxy: None } + } + + /// This blob with a preview of at most [`PROXY_SIZE`] pixels a side (PNG), when the image is + /// larger than that and decodes; otherwise unchanged. + pub fn with_proxy(mut self) -> Self { + if self.proxy.is_none() { + self.proxy = proxy_png(&self.bytes).map(Arc::new); + } + self + } + + /// Are the bytes the preview (the linked file hasn't been read)? + pub fn is_proxy(&self) -> bool { + self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes)) + } +} + +/// `bytes` decoded and shrunk to fit [`PROXY_SIZE`] as a PNG; `None` when small enough already or +/// undecodable. +fn proxy_png(bytes: &[u8]) -> Option> { + let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; + if w.max(h) <= PROXY_SIZE { + return None; + } + let small = image::load_from_memory(bytes).ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE); + let mut png = Vec::new(); + small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + +/// `n` and every descendant, opacity-mask art included. +fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { + f(n); + if let Some(m) = &n.mask { + walk_all(&m.art, f); + } + for c in n.children().into_iter().flatten() { + walk_all(c, f); + } +} + +impl Document { + /// Visit every image object: in the layers, then in symbol definitions and pattern swatches, + /// opacity-mask art included. + pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) { + let roots = self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)); + for root in roots { + walk_all(root, &mut |n| { + if let NodeKind::Image(im) = &n.kind { + f(n, im); + } + }); + } + } + + /// The keys of image blobs only linked images show: what a save writes as their preview. + pub fn linked_only_images(&self) -> BTreeSet { + if self.images.values().all(|b| b.proxy.is_none()) { + return BTreeSet::new(); + } + // Per key: (shown linked, shown embedded). + let mut uses: BTreeMap<&str, (bool, bool)> = BTreeMap::new(); + self.visit_images(|_, im| { + let u = uses.entry(im.key.as_str()).or_default(); + if im.link.is_some() { + u.0 = true; + } else { + u.1 = true; + } + }); + uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::NodeId; + use vectorcraft_geom::Affine; + + fn png(w: u32, h: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + fn image(d: &mut Document, key: &str, link: Option) -> NodeId { + let id = d.alloc_id(); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link }))) + .unwrap() + } + + #[test] + fn a_path_string_from_older_files_reads_as_a_link() { + let old: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":"C:\\art\\photo.png"}"#).unwrap(); + let link = old.link.unwrap(); + assert_eq!((link.path.as_str(), link.name(), link.hash.as_deref()), ("C:\\art\\photo.png", "photo.png", None)); + let new: ImageObject = + serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":{"path":"/a/b.png","size":9,"page":2}}"#).unwrap(); + assert_eq!(new.link, Some(LinkInfo { size: Some(9), page: Some(2), ..LinkInfo::new("/a/b.png") })); + let none: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0]}"#).unwrap(); + assert_eq!(none.link, None); + // Written as an object, with only what is known. + let v = serde_json::to_value(&new).unwrap(); + assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2})); + } + + #[test] + fn large_images_get_a_small_png_preview() { + let big = ImageBlob::new("image/png", png(600, 300)).with_proxy(); + let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap(); + assert_eq!((p.width(), p.height()), (256, 128)); + assert!(!big.is_proxy(), "the bytes are the full image"); + assert!(ImageBlob::new("image/png", png(40, 20)).with_proxy().proxy.is_none(), "small images keep their pixels"); + assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable"); + } + + #[test] + fn only_blobs_no_embedded_image_shows_are_linked_only() { + let mut d = Document::new(100.0, 100.0); + for k in ["a", "b", "c"] { + d.images.insert(k.into(), ImageBlob::new("image/png", png(300, 10)).with_proxy()); + } + image(&mut d, "a", Some(LinkInfo::new("/a.png"))); + image(&mut d, "b", Some(LinkInfo::new("/b.png"))); + image(&mut d, "b", None); + image(&mut d, "c", None); + assert_eq!(d.linked_only_images().into_iter().collect::>(), ["a"]); + } +} diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index f075ebcb..197c16e2 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -200,8 +200,10 @@ pub struct ImageObject { pub height: u32, /// Maps pixel space (0..w, 0..h) into the document. pub xf: Affine, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub link: Option, + /// The file a linked image shows (File → Place with Link); `None`: embedded. Files saved + /// before links had details hold just the path. + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")] + pub link: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index d02edf4d..219b6a81 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -9,13 +9,12 @@ use crate::ImageBlob; impl ImageBlob { /// A PNG blob of `png` bytes. pub fn png(png: Vec) -> Self { - Self { mime: "image/png".into(), bytes: Arc::new(png) } + Self::new("image/png", png) } /// A key derived from the bytes (FNV-1a), the same for identical images. pub fn content_key(&self) -> String { - let h = self.bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3)); - format!("img{h:016x}") + crate::links::hash_bytes(&self.bytes) } /// The image with `f` applied to each pixel's (straight) RGB, alpha kept and fully transparent @@ -48,42 +47,59 @@ impl ImageBlob { /// image, where every pixel is transparent, or when the image can't be decoded. The last image /// decoded is kept for the next sample (the Eyedropper samples one image click after click). pub fn sample(&self, x: f64, y: f64, size: u32) -> Option<[u8; 4]> { + sample_pixels(&*self.decoded()?, x, y, size) + } + + /// [`Self::sample`] at (`x`, `y`) in the pixel space of an image object of `object` (width, + /// height) pixels, which the decoded pixels may not match (a linked image showing its preview). + pub fn sample_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> { + let img = self.decoded()?; + let k = |n: u32, of: u32| n as f64 / of.max(1) as f64; + sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size) + } + + /// The decoded pixels (the last image decoded is kept for the next sample). + fn decoded(&self) -> Option> { thread_local! { static LAST: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; } let key = self.content_key(); - let img = LAST.with_borrow_mut(|last| match last { + LAST.with_borrow_mut(|last| match last { Some((k, img)) if *k == key => Some(img.clone()), _ => { let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8()); *last = Some((key, img.clone())); Some(img) } - })?; - let (w, h) = (img.width() as i64, img.height() as i64); - let (cx, cy) = (x.floor() as i64, y.floor() as i64); - if !(0..w).contains(&cx) || !(0..h).contains(&cy) { - return None; - } - let r = size.max(1) as i64 / 2; - let (mut sum, mut alpha, mut n) = ([0u64; 3], 0u64, 0u64); - for py in (cy - r).max(0)..=(cy + r).min(h - 1) { - for px in (cx - r).max(0)..=(cx + r).min(w - 1) { - let [pr, pg, pb, pa] = img.get_pixel(px as u32, py as u32).0; - let a = pa as u64; - sum = [sum[0] + pr as u64 * a, sum[1] + pg as u64 * a, sum[2] + pb as u64 * a]; - alpha += a; - n += 1; - } - } - if alpha == 0 { - return None; - } - let c = |v: u64| ((v + alpha / 2) / alpha) as u8; - Some([c(sum[0]), c(sum[1]), c(sum[2]), ((alpha + n / 2) / n) as u8]) + }) } } +/// [`ImageBlob::sample`] of decoded pixels `img`. +fn sample_pixels(img: &image::RgbaImage, x: f64, y: f64, size: u32) -> Option<[u8; 4]> { + let (w, h) = (img.width() as i64, img.height() as i64); + let (cx, cy) = (x.floor() as i64, y.floor() as i64); + if !(0..w).contains(&cx) || !(0..h).contains(&cy) { + return None; + } + let r = size.max(1) as i64 / 2; + let (mut sum, mut alpha, mut n) = ([0u64; 3], 0u64, 0u64); + for py in (cy - r).max(0)..=(cy + r).min(h - 1) { + for px in (cx - r).max(0)..=(cx + r).min(w - 1) { + let [pr, pg, pb, pa] = img.get_pixel(px as u32, py as u32).0; + let a = pa as u64; + sum = [sum[0] + pr as u64 * a, sum[1] + pg as u64 * a, sum[2] + pb as u64 * a]; + alpha += a; + n += 1; + } + } + if alpha == 0 { + return None; + } + let c = |v: u64| ((v + alpha / 2) / alpha) as u8; + Some([c(sum[0]), c(sum[1]), c(sum[2]), ((alpha + n / 2) / n) as u8]) +} + #[cfg(test)] mod tests { use super::*; @@ -110,6 +126,14 @@ mod tests { assert_eq!(b.sample(0.0, 0.0, 5), Some([85, 85, 85, 255]), "clipped to the image"); } + #[test] + fn samples_in_the_pixel_space_of_an_object_with_more_pixels() { + // An object of 8 × 2 pixels showing these 4 × 1 (a linked image's preview). + let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 255]], 4, 1); + assert_eq!(b.sample_object(5.0, 1.5, (8, 2), 1), Some([0, 0, 255, 255])); + assert_eq!(b.sample_object(1.0, 0.0, (4, 1), 1), b.sample(1.0, 0.0, 1), "the same size: the same pixels"); + } + #[test] fn maps_each_colour_once_and_keeps_alpha() { let b = blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2); diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index d8214a04..c92dbff8 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -68,7 +68,10 @@ fn info(s: &mut Session, p: &Value) -> Result { Some("textObjects") } NodeKind::Image(im) => { - images.insert(im.key.clone(), json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link })); + images.insert( + im.key.clone(), + json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }), + ); Some("images") } NodeKind::SymbolInstance { symbol, .. } => { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 43d462c6..fb0e1722 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -1,7 +1,6 @@ //! `document.open`: every readable format into a new document. use std::io::Cursor; -use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; @@ -132,7 +131,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } /// Open a file's bytes as the new active document (what `document.open` does) → -/// `{index, title, format, warnings}`. `path` is kept for Save only for a native, non-template file. +/// `{index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks}`. `path` is kept +/// for Save only for a native, non-template file; the document's linked files are looked for from +/// it ([`crate::cmd::links::resolve`]). pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option) -> Result { open_bytes_with(s, name, bytes, path, &Value::Null) } @@ -141,6 +142,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let opts = LoadOptions::from_params("document.open", p)?; let Loaded { mut doc, format, warnings } = load_with(name, bytes, &opts)?; + let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically"); // A template (saved by Save as Template, or an .ait file) opens as a new untitled document. let template = doc.template || format.id == "ait"; if template { @@ -150,7 +152,7 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option Result { if width == 0 || height == 0 { return Err(err("the image is empty")); } - let blob = ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) }; + let blob = ImageBlob::new(mime, bytes); Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi }) } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 059e549d..66f27724 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)", always, load::open ), @@ -364,6 +364,35 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } +/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system +/// keeps one); `None` when there is no file at `path`. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option)> { + let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?; + let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64); + Some((m.len(), modified)) +} + +/// `path` made absolute against the working directory (as given when absolute already, or when +/// that fails). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn absolute_path(path: &str) -> String { + if std::path::Path::new(path).is_absolute() { + return path.to_string(); + } + std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option)> { + None +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn absolute_path(path: &str) -> String { + path.to_string() +} + #[cfg(target_arch = "wasm32")] fn no_fs(path: &str) -> EngineError { EngineError::Other(format!("{path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64)")) diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index ad500329..01420a2f 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -20,13 +20,15 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res } } -/// What Save writes for `st` in format `f` (see [`save_format`]) → the encoded file and the -/// options to remember for the next Save. An SVG save takes the SVG options in `p`, else the ones -/// the document was last saved with, and keeps hidden layers (hidden) unless they say otherwise. -pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, Value)> { +/// What Save writes for `st` in format `f` (see [`save_format`]) to `path` → the encoded file and +/// the options to remember for the next Save. An SVG save takes the SVG options in `p`, else the +/// ones the document was last saved with, and keeps hidden layers (hidden) unless they say +/// otherwise. A native file records its links' paths relative to `path`. +pub fn save_encoding(st: &DocState, f: &Format, p: &Value, path: Option<&str>) -> Result<(Encoded, Value)> { const C: &str = "document.save"; if f.id == "vectorcraft" { - return Ok((encode_all(&st.doc, f.id, p)?, Value::Null)); + let relative = path.and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path)); + return Ok((encode_all(relative.as_ref().unwrap_or(&st.doc), f.id, p)?, Value::Null)); } let given = super::svg_options(p).map_err(|e| bad(C, e))?; let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) }; @@ -56,7 +58,7 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result { stamp_save_dates(s.doc_mut()?); } let st = s.doc()?; - let (enc, opts) = save_encoding(st, f, p)?; + let (enc, opts) = save_encoding(st, f, p, path.as_deref())?; let Some(path) = path else { // Nowhere to save to (web, agents): hand the bytes back; the document stays modified. return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, json!({})); diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index fa8ab22d..ea55ee96 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1038,7 +1038,7 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); - d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(r.png) }); + d.images.insert(key.clone(), ImageBlob::new("image/png", r.png)); let image = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None })); let Some(path) = r.clip else { return image }; super::rasterfx::clip_group(d, path, FillRule::NonZero, image) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs new file mode 100644 index 00000000..4f5dddeb --- /dev/null +++ b/crates/engine/src/cmd/links.rs @@ -0,0 +1,477 @@ +//! Linked images (File → Place with Link): finding their files, telling when they changed, and +//! reading them again. +//! +//! A linked image keeps its file's absolute path, the path relative to the document (written on +//! save), the file's size, modification time and hash ([`LinkInfo`]), and a low-resolution +//! preview that the document saves in place of the pixels ([`ImageBlob::proxy`]). A file is looked +//! for at its path, then at its relative path and by name in the document's folder. When a +//! document opens ([`resolve`]), the files found unchanged are read again, modified ones follow +//! Preferences → File Handling → Update Links, and missing ones show their preview. +//! +//! `links.check` reports, `links.update` reads modified files again, `links.relink` points images +//! at other files. + +use std::collections::BTreeMap; +use std::path::{Component, Path}; + +use serde_json::{Value, json}; +use vectorcraft_doc::links::hash_bytes; +use vectorcraft_doc::{Document, ImageBlob, LinkInfo, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::fileio::{self, RasterImage, absolute_path, file_stamp, read_file}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "links.check", + "Check Links", + [], + None, + "{ids?: [image ids] (default: every linked image)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}", + has_doc, + check + ), + cmd!( + "links.update", + "Update Links", + [], + None, + "{ids?: [image ids] (default: the linked images whose file was modified or is showing its preview)} read their linked files again; each image keeps its bounds. One undo step → {updated: [ids], missing: [ids]}", + has_doc, + update + ), + cmd!( + "links.relink", + "Relink", + [], + None, + "{ids?: [image ids] (default: the selected images; with folder and nothing selected, every missing link), path | folder} link images to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}", + has_doc, + relink + ), + ] +} + +// ---------- finding files ---------- + +/// What became of a linked file since it was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Ok, + Modified, + Missing, +} + +impl Status { + pub fn as_str(self) -> &'static str { + match self { + Status::Ok => "ok", + Status::Modified => "modified", + Status::Missing => "missing", + } + } +} + +/// The image objects that show one linked file as one content (same path, same image). +struct Group { + link: LinkInfo, + key: String, + /// The objects in the layers (images in symbols and patterns share the content, not an id). + ids: Vec, +} + +/// The linked images of `d` by file and content: those in the layers (only `ids` when given), then, +/// without `ids`, the ones only symbols and patterns show. +fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { + let mut out: Vec = vec![]; + let mut index: BTreeMap<(String, String), usize> = BTreeMap::new(); + let mut add = |link: &LinkInfo, key: &str, id: Option| { + let i = *index.entry((link.path.clone(), key.to_string())).or_insert_with(|| { + out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![] }); + out.len() - 1 + }); + if let (Some(id), Some(g)) = (id, out.get_mut(i)) { + g.ids.push(id); + } + }; + d.walk(|n| { + if let NodeKind::Image(im) = &n.kind + && let Some(l) = &im.link + && ids.is_none_or(|ids| ids.contains(&n.id)) + { + add(l, &im.key, Some(n.id)); + } + }); + if ids.is_none() { + d.visit_images(|_, im| { + if let Some(l) = &im.link { + add(l, &im.key, None); + } + }); + } + out +} + +/// A linked file where it was found. +struct Found { + path: String, + size: u64, + modified: Option, + /// Read when telling whether it changed needed them. + bytes: Option>, +} + +impl Found { + /// The file's bytes (read now unless already read). + fn read(&mut self) -> Result> { + match self.bytes.take() { + Some(b) => Ok(b), + None => read_file(&self.path), + } + } + + /// `base` pointing at this file as read now (`bytes`). + fn link(&self, bytes: &[u8], base: &LinkInfo) -> LinkInfo { + LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash_bytes(bytes)), ..base.clone() } + } +} + +/// The link to the file at `path` whose bytes were just read. +pub(crate) fn link_info(path: &str, bytes: &[u8]) -> LinkInfo { + let path = absolute_path(path); + LinkInfo { + size: Some(bytes.len() as u64), + modified: file_stamp(&path).and_then(|(_, m)| m), + hash: Some(hash_bytes(bytes)), + ..LinkInfo::new(path) + } +} + +/// The file at `path` when there is one. +fn found_at(path: String) -> Option { + let (size, modified) = file_stamp(&path)?; + Some(Found { path, size, modified, bytes: None }) +} + +/// Where the file of `link` is: at its path, else at its relative path or by name in the +/// document's folder `dir`. +fn locate(link: &LinkInfo, dir: Option<&Path>) -> Option { + let mut paths = vec![link.path.clone()]; + if let Some(dir) = dir { + let near = link.relative.iter().map(|r| dir.join(r)).chain([dir.join(link.name())]); + paths.extend(near.map(|p| p.to_string_lossy().into_owned())); + } + paths.into_iter().find_map(found_at) +} + +/// Where the file of group `g` is and whether it still has the content shown. Its bytes are read +/// only when its size or modification time changed. +fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option) { + let Some(mut f) = locate(&g.link, dir) else { return (Status::Missing, None) }; + let l = &g.link; + if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified { + return (Status::Ok, Some(f)); + } + let Ok(bytes) = f.read() else { return (Status::Missing, None) }; + let same = match &l.hash { + Some(h) => *h == hash_bytes(&bytes), + // Linked before links kept a hash: the image the file decodes to. + None => fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key), + }; + f.bytes = Some(bytes); + (if same { Status::Ok } else { Status::Modified }, Some(f)) +} + +/// The folder of the document saved at `doc_path`. +fn folder(doc_path: Option<&str>) -> Option<&Path> { + doc_path.and_then(|p| Path::new(p).parent()) +} + +/// Is image `key` of `d` showing its preview (its file not read)? +fn previewing(d: &Document, key: &str) -> bool { + d.images.get(key).is_none_or(ImageBlob::is_proxy) +} + +// ---------- reading files into the document ---------- + +/// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a +/// preview there is kept, and a linked image gets one. +pub(crate) fn store_image(d: &mut Document, key: &str, full: ImageBlob, linked: bool) { + let blob = match d.images.get(key) { + Some(b) if !b.is_proxy() => b.clone(), + old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()), ..full }, + }; + d.images.insert(key.to_string(), if linked { blob.with_proxy() } else { blob }); +} + +/// The file read for a link: the link to it and its image. +type FileRead = (LinkInfo, RasterImage); + +/// The file at `f` read for a link like `base` (its image decoded). +fn read_image(f: &mut Found, base: &LinkInfo) -> Result { + let bytes = f.read()?; + Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?)) +} + +/// Image objects `ids` show the file read for `link` (`img`), each keeping its bounds. +fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead) { + store_image(d, &img.key, img.blob, true); + for id in ids { + if let Some(n) = d.node_mut(*id) + && let NodeKind::Image(im) = &mut n.kind + { + if (im.width, im.height) != (img.width, img.height) { + im.xf *= Affine::scale_non_uniform(im.width as f64 / img.width as f64, im.height as f64 / img.height as f64); + } + (im.key, im.width, im.height, im.link) = (img.key.clone(), img.width, img.height, Some(link.clone())); + } + } +} + +/// Groups as `{name, path, ids}` rows (those with objects in the layers). +fn rows<'a>(gs: impl Iterator) -> Vec { + gs.filter(|g| !g.ids.is_empty()).map(|g| json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) })).collect() +} + +fn ids_json(ids: &[NodeId]) -> Vec { + ids.iter().map(|id| id.0).collect() +} + +/// What [`resolve`] found when a document opened. +#[derive(Default)] +pub struct Resolved { + /// `{name, path, ids}` of files that weren't found: their images show the preview. + pub missing: Vec, + /// Modified files left as they were (Update Links isn't Automatically). + pub modified: Vec, + /// Modified files read again (Update Links: Automatically). + pub updated: Vec, +} + +impl Resolved { + /// The `document.open` result fields. + pub fn to_json(&self) -> Value { + json!({ "missingLinks": self.missing, "modifiedLinks": self.modified, "updatedLinks": self.updated }) + } +} + +/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read +/// again (their images had the preview), modified ones are read again when `update`, links found +/// away from their path take the new path. Nothing here is an undo step. +pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved { + let mut out = Resolved::default(); + for g in groups(d, None) { + let row = rows(std::iter::once(&g)); + match probe(&g, folder(doc_path)) { + (Status::Missing, _) => out.missing.extend(row), + (Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link) { + Ok(read) => { + install(d, &g.ids, read); + out.updated.extend(row); + } + Err(_) => out.missing.extend(row), + }, + (Status::Modified, _) => out.modified.extend(row), + (Status::Ok, Some(mut f)) => { + let preview = previewing(d, &g.key); + // Read when the images show the preview, or when the file's details changed. + let bytes = if preview || f.bytes.is_some() { f.read().ok() } else { None }; + if preview { + let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else { + out.missing.extend(row); + continue; + }; + store_image(d, &g.key, img.blob, true); + } else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) { + // Linked before links kept a preview. + let b = b.clone().with_proxy(); + d.images.insert(g.key.clone(), b); + } + // Found away from its path, or read again: the link follows. + let link = match &bytes { + Some(b) => f.link(b, &g.link), + None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + }; + if link != g.link { + set_links(d, &g.ids, &link); + } + } + (Status::Ok, None) => {} + } + } + out +} + +/// Image objects `ids` link to `link`. +fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { + for id in ids { + if let Some(n) = d.node_mut(*id) + && let NodeKind::Image(im) = &mut n.kind + { + im.link = Some(link.clone()); + } + } +} + +// ---------- relative paths ---------- + +/// `target` relative to folder `base` with `/` separators; `None` when they share no root (another +/// drive, or either is relative). +fn relative_path(target: &Path, base: &Path) -> Option { + let (t, b): (Vec, Vec) = (target.components().collect(), base.components().collect()); + if !matches!(t.first(), Some(Component::Prefix(_) | Component::RootDir)) { + return None; + } + // Windows paths compare without case. + let same = |x: &Component, y: &Component| if cfg!(windows) { x.as_os_str().eq_ignore_ascii_case(y.as_os_str()) } else { x == y }; + let common = t.iter().zip(&b).take_while(|(x, y)| same(x, y)).count(); + if common == 0 { + return None; + } + let ups = std::iter::repeat_n(std::borrow::Cow::Borrowed(".."), b.len().saturating_sub(common)); + let rest = t.iter().skip(common).map(|c| c.as_os_str().to_string_lossy()); + Some(ups.chain(rest).collect::>>().join("/")) +} + +/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they +/// are already. +pub fn with_relative_paths(d: &Document, dest: &str) -> Option { + let dest = absolute_path(dest); + let dir = Path::new(&dest).parent()?; + let mut changes = vec![]; + d.walk(|n| { + if let NodeKind::Image(im) = &n.kind + && let Some(l) = &im.link + { + let r = relative_path(Path::new(&l.path), dir); + if r != l.relative { + changes.push((n.id, r)); + } + } + }); + if changes.is_empty() { + return None; + } + let mut d = d.clone(); + for (id, r) in changes { + if let Some(n) = d.node_mut(id) + && let NodeKind::Image(im) = &mut n.kind + && let Some(l) = &mut im.link + { + l.relative = r; + } + } + Some(d) +} + +// ---------- commands ---------- + +fn check(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let ids = ids_param(p, "ids"); + let (mut missing, mut modified) = (0, 0); + let mut links = vec![]; + for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) { + let (status, found) = probe(g, folder(st.path.as_deref())); + missing += usize::from(status == Status::Missing); + modified += usize::from(status == Status::Modified); + let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) }); + if let Some(f) = found.filter(|f| f.path != g.link.path) { + row["found"] = json!(f.path); + } + if previewing(&st.doc, &g.key) { + row["preview"] = json!(true); + } + links.push(row); + } + Ok(json!({ "links": links, "missing": missing, "modified": modified })) +} + +fn update(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let ids = ids_param(p, "ids"); + let (mut reads, mut missing) = (vec![], vec![]); + for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) { + match probe(&g, folder(st.path.as_deref())) { + (Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)), + // Unchanged and showing the file: nothing to read unless asked for by id. + (Status::Ok, Some(_)) if ids.is_none() && !previewing(&st.doc, &g.key) => {} + // Unreadable as an image counts as missing. + (_, Some(mut f)) => match read_image(&mut f, &g.link) { + Ok(read) => reads.push((g.ids, read)), + Err(_) => missing.extend(ids_json(&g.ids)), + }, + } + } + let updated: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); + if !reads.is_empty() { + s.edit("Update Links", |d, _| { + reads.into_iter().for_each(|(ids, read)| install(d, &ids, read)); + Ok(()) + })?; + } + Ok(json!({ "updated": updated, "missing": missing })) +} + +fn relink(s: &mut Session, p: &Value) -> Result { + const C: &str = "links.relink"; + let st = s.doc()?; + let folder_param = str_param(p, "folder"); + let image = |id: &NodeId| st.doc.node(*id).and_then(|n| if let NodeKind::Image(im) = &n.kind { Some(im) } else { None }); + let ids: Vec = match ids_param(p, "ids") { + Some(ids) => ids, + None => { + let selected: Vec = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect(); + match folder_param { + Some(_) if selected.is_empty() => groups(&st.doc, None) + .into_iter() + .filter(|g| probe(g, folder(st.path.as_deref())).0 == Status::Missing) + .flat_map(|g| g.ids) + .collect(), + _ => selected, + } + } + }; + if let Some(id) = ids.iter().find(|id| image(id).is_none()) { + return Err(bad(C, format!("object {} is not an image", id.0))); + } + if ids.is_empty() { + return Err(bad(C, "select the images to relink, or pass ids")); + } + // (ids, the file) per file to read. + let mut files: Vec<(Vec, String)> = vec![]; + let mut not_found = vec![]; + match (str_param(p, "path"), folder_param) { + (Some(path), _) => files.push((ids, absolute_path(path))), + (None, Some(dir)) => { + for id in ids { + let Some(link) = image(&id).and_then(|im| im.link.as_ref()) else { continue }; + let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy()); + match files.iter_mut().find(|(_, p)| *p == path) { + Some((ids, _)) => ids.push(id), + None if file_stamp(&path).is_some() => files.push((vec![id], path)), + None => not_found.push(link.name().to_string()), + } + } + } + (None, None) => return Err(bad(C, "give path (a file) or folder")), + } + let mut reads = vec![]; + for (ids, path) in files { + let bytes = read_file(&path)?; + let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?; + reads.push((ids, (link_info(&path, &bytes), img))); + } + let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); + if !reads.is_empty() { + s.edit("Relink", |d, _| { + reads.into_iter().for_each(|(ids, read)| install(d, &ids, read)); + Ok(()) + })?; + } + not_found.sort(); + not_found.dedup(); + Ok(json!({ "relinked": relinked, "notFound": not_found })) +} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 3d6a2d91..c1b26c4f 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -395,7 +395,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { let id = s.edit("Rasterize", |d, sel| { let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; let key = unique_key(d, "raster"); - d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); let id = d.alloc_id(); let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None })); @@ -547,7 +547,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?; s.edit("Crop Image", |d, _| { let key = unique_key(d, "crop"); - d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { n.kind = NodeKind::Image(ImageObject { key, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5696374f..f725c6a7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -26,6 +26,7 @@ pub(crate) mod gradient; pub(crate) mod graph; pub mod help; mod layer; +pub mod links; mod live; pub(crate) mod maskedit; pub(crate) mod menucmds; @@ -218,6 +219,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(docsetup::specs()); v.extend(newdoc::specs()); v.extend(place::specs()); + v.extend(links::specs()); v }) } diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index d42f52fe..a781fec9 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -2,8 +2,8 @@ //! the clipboard. //! //! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution -//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its path with -//! `link`. +//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with +//! `link` ([`super::links`]). //! - An SVG becomes one group of its art. //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page //! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art @@ -20,7 +20,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_pdf::CropTo; @@ -98,8 +98,8 @@ struct Loaded { /// The art's natural box (what `at` centres and `rect` fits). natural: Rect, warnings: Vec, - /// The file's path (for Link). - path: Option, + /// The link to a raster image's file (for Link), when read from a path. + link: Option, } /// Points per pixel of a raster image at the resolution it declares (72 ppi when none). @@ -127,12 +127,12 @@ fn load(p: &Value, cmd: &str) -> Result { )); } let crop = opts.crop != CropTo::Bounding; - let path = src.path.map(str::to_string); if format.raster { let img = fileio::raster_image(&src.bytes)?; let (sx, sy) = pt_per_px(img.ppi); let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy); - return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], path }); + let link = src.path.map(|path| super::links::link_info(path, &src.bytes)); + return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link }); } let (mut doc, warnings) = match format.id { // Only the page placed is read, its artboard the box asked for (Bounding Box: the art's @@ -142,7 +142,9 @@ fn load(p: &Value, cmd: &str) -> Result { fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))? } _ => { - let l = fileio::load(src.name, &src.bytes)?; + let mut l = fileio::load(src.name, &src.bytes)?; + // Linked images (a native document's) show their files, found from its folder. + super::links::resolve(&mut l.doc, src.path, false); (l.doc, l.warnings) } }; @@ -167,7 +169,7 @@ fn load(p: &Value, cmd: &str) -> Result { }; let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds()))); let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; - Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, path }) + Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None }) } /// A visible, non-template layer. @@ -191,11 +193,11 @@ fn art_of<'a>(nodes: impl Iterator>) -> Vec { } /// The art as one object of `d` (its resources added), at its natural size and position. -fn build(d: &mut Document, l: Loaded, link: Option) -> Node { +fn build(d: &mut Document, l: Loaded, link: Option) -> Node { let mut node = match l.art { Art::Image(img) => { let (sx, sy) = pt_per_px(img.ppi); - d.images.entry(img.key.clone()).or_insert(img.blob); + super::links::store_image(d, &img.key, img.blob, link.is_some()); let im = ImageObject { key: img.key, width: img.width, height: img.height, xf: Affine::scale_non_uniform(sx, sy), link }; Node::new(d.alloc_id(), NodeKind::Image(im)) } @@ -288,7 +290,7 @@ fn place(s: &mut Session, p: &Value) -> Result { }; let parent = st.insertion_parent(); let below = st.current_layer().or_else(|| parent.and_then(|p| st.doc.layer_of(p))); - let link = loaded.path.take().filter(|_| bool_or(p, "link", true) && loaded.format.raster); + let link = loaded.link.take().filter(|_| bool_or(p, "link", true)); let linked = link.is_some(); let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings)); let (id, size) = s.edit("Place", |d, sel| { @@ -462,7 +464,7 @@ fn image_info(s: &mut Session, p: &Value) -> Result { "id": id.0, "name": n.display_name(), "linked": im.link.is_some(), - "link": im.link, + "link": im.link.as_ref().map(|l| &l.path), "colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)), "pixelWidth": im.width, "pixelHeight": im.height, diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 1a670115..e7ed3754 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -201,7 +201,7 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N while out.images.contains_key(&key) { key.push('+'); } - out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + out.images.insert(key.clone(), ImageBlob::new("image/png", png)); let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); let mut image = Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None })); image.name = Some("Raster effect".into()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 63ce7d18..ddbb686c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -963,6 +963,8 @@ mod tests_layerclip; #[cfg(test)] mod tests_linked_stops; #[cfg(test)] +mod tests_links; +#[cfg(test)] mod tests_live; #[cfg(test)] mod tests_maskview; diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index a8b5e56e..080eedce 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -325,7 +325,7 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node let png = r.encode_png(); let (w, h) = (r.width, r.height); s.edit("img", |d, sel| { - d.images.insert("img1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + d.images.insert("img1".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); let l = d.default_layer(); d.insert(l, 0, Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None })))?; diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs new file mode 100644 index 00000000..c82a0bc9 --- /dev/null +++ b/crates/engine/src/tests_links.rs @@ -0,0 +1,324 @@ +//! 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 super::*; + +/// A fresh folder for one test (removed when dropped). +struct Folder(PathBuf); + +impl Folder { + 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) + } + 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. +fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { + 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 +} + +const RED: [u8; 3] = [230, 20, 20]; +const BLUE: [u8; 3] = [20, 20, 230]; + +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(); +} + +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. +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()) +} + +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:?}"), + } +} + +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. +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] +} + +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"] +} + +fn save(s: &mut Session, path: &str) { + s.execute("document.save", &json!({"path": path})).unwrap(); +} + +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"); +} + +#[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)); +} + +#[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"); +} + +#[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"); +} + +#[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"))) }; + d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(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(); + s.execute("file.place", &json!({"path": doc_path})).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"); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 20fa8817..6f2aa4c6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -6,12 +6,13 @@ //! "document": { …vectorcraft_doc::Document… }, //! "images": { "": { "mime": "image/png", "data": "" } } } //! ``` +//! An image only linked images show is saved as its low-resolution preview (`"proxy": true`); the +//! engine reads the linked file again when the document opens. //! It is lossless for everything in the document model and preserves unknown fields under //! `document.unknown`. Readers must reject files whose `version` is newer than they support. #![forbid(unsafe_code)] use std::collections::BTreeMap; -use std::sync::Arc; use serde::{Deserialize, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; @@ -46,6 +47,9 @@ pub enum FormatError { struct Image { mime: String, data: String, + /// `data` is a linked image's preview ([`ImageBlob::proxy`]), not the file's bytes. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + proxy: bool, } #[derive(Serialize, Deserialize)] @@ -63,7 +67,18 @@ struct File { /// ([`Document::without_edit_modes`]). pub fn save(doc: &Document, pretty: bool) -> Vec { let doc = doc.without_edit_modes(); - let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect(); + let linked = doc.linked_only_images(); + let images = doc + .images + .iter() + .map(|(k, b)| { + let image = match b.proxy.as_ref().filter(|_| linked.contains(k)) { + Some(p) => Image { mime: vectorcraft_doc::links::PROXY_MIME.into(), data: base64_encode(p), proxy: true }, + None => Image { mime: b.mime.clone(), data: base64_encode(&b.bytes), proxy: false }, + }; + (k.clone(), image) + }) + .collect(); let f = File { format: "vectorcraft".into(), version: VERSION, @@ -95,7 +110,12 @@ pub fn load(bytes: &[u8]) -> Result { let mut doc = f.document; for (k, img) in f.images { let bytes = base64_decode(&img.data).ok_or_else(|| FormatError::BadImage(k.clone()))?; - doc.images.insert(k, ImageBlob { mime: img.mime, bytes: Arc::new(bytes) }); + let mut blob = ImageBlob::new(img.mime, bytes); + // Until the linked file is read, the preview stands in for it. + if img.proxy { + blob.proxy = Some(blob.bytes.clone()); + } + doc.images.insert(k, blob); } // Saved mid-edit by an older version: drop the opacity-mask editing layer. doc.drop_edit_modes(); @@ -177,7 +197,7 @@ mod tests { let l = d.layers[0].id; let id = d.alloc_id(); d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); - d.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![1, 2, 3, 250]) }); + d.images.insert("k".into(), ImageBlob::new("image/png", vec![1, 2, 3, 250])); let bytes = save(&d, true); assert!(sniff(&bytes)); let back = load(&bytes).unwrap(); diff --git a/crates/format/tests/links.rs b/crates/format/tests/links.rs new file mode 100644 index 00000000..82d0ad20 --- /dev/null +++ b/crates/format/tests/links.rs @@ -0,0 +1,63 @@ +//! Linked images in `.vectorcraft` files: their link details round-trip, and an image only linked +//! images show is saved as its preview. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind}; +use vectorcraft_format::{base64_decode, load, save}; +use vectorcraft_geom::Affine; + +fn image(d: &mut Document, key: &str, link: Option) { + let (id, layer) = (d.alloc_id(), d.layers[0].id); + let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link }; + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); +} + +fn blob(full: &[u8], proxy: Option<&[u8]>) -> ImageBlob { + ImageBlob { proxy: proxy.map(|p| Arc::new(p.to_vec())), ..ImageBlob::new("image/jpeg", full.to_vec()) } +} + +#[test] +fn link_details_round_trip_and_linked_only_images_save_their_preview() { + let mut d = Document::new(100.0, 100.0); + let link = LinkInfo { + relative: Some("art/photo.jpg".into()), + modified: Some(1_700_000_000_000), + size: Some(4), + hash: Some("img0123456789abcdef".into()), + ..LinkInfo::new("/work/art/photo.jpg") + }; + d.images.insert("linked".into(), blob(&[1, 2, 3, 4], Some(&[9, 9]))); + d.images.insert("both".into(), blob(&[5, 6, 7], Some(&[8]))); + d.images.insert("small".into(), blob(&[4, 4], None)); + image(&mut d, "linked", Some(link.clone())); + image(&mut d, "both", Some(LinkInfo::new("/work/b.jpg"))); + image(&mut d, "both", None); + image(&mut d, "small", Some(LinkInfo::new("/work/s.jpg"))); + let bytes = save(&d, false); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let saved = |k: &str| { + ( + v["images"][k]["mime"].as_str().unwrap().to_string(), + base64_decode(v["images"][k]["data"].as_str().unwrap()).unwrap(), + v["images"][k]["proxy"].clone(), + ) + }; + assert_eq!(saved("linked"), ("image/png".into(), vec![9, 9], Value::Bool(true)), "only linked images show it: the preview"); + assert_eq!(saved("both"), ("image/jpeg".into(), vec![5, 6, 7], Value::Null), "an embedded image shows it too: the full bytes"); + assert_eq!(saved("small"), ("image/jpeg".into(), vec![4, 4], Value::Null), "no preview: the full bytes"); + + let back = load(&bytes).unwrap(); + let linked = &back.images["linked"]; + assert_eq!((linked.bytes.as_slice(), linked.is_proxy()), (&[9u8, 9][..], true), "the preview stands in until the file is read"); + assert!(!back.images["both"].is_proxy() && back.images["both"].proxy.is_none()); + let mut links = vec![]; + back.visit_images(|_, im| links.push(im.link.clone())); + assert!(links.contains(&Some(link)), "{links:?}"); + // Saved again unread, the preview stays the preview. + let again: Value = serde_json::from_slice(&save(&back, false)).unwrap(); + assert_eq!(again["images"]["linked"], v["images"]["linked"]); +} diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index eea139d9..016ab98b 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -2,8 +2,6 @@ // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use std::sync::Arc; - use proptest::prelude::*; use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageBlob, NodeId}; @@ -73,7 +71,7 @@ proptest! { #[test] fn images_roundtrip(data in prop::collection::vec(any::(), 0..2000), key in "[a-z]{1,8}") { let mut d = Document::new(10.0, 10.0); - d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(data.clone()) }); + d.images.insert(key.clone(), ImageBlob::new("image/png", data.clone())); let back = load(&save(&d, false)).unwrap(); prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data)); } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 3f639cd6..39807d6c 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -38,6 +38,8 @@ mod tests_freeform; #[cfg(test)] mod tests_gradient; #[cfg(test)] +mod tests_links; +#[cfg(test)] mod tests_place; #[cfg(test)] mod tests_svg; diff --git a/crates/mcp/src/tests_links.rs b/crates/mcp/src/tests_links.rs new file mode 100644 index 00000000..20298fc6 --- /dev/null +++ b/crates/mcp/src/tests_links.rs @@ -0,0 +1,34 @@ +//! Linked images over MCP, headless: a document opened without its linked file reports it, and +//! `links.relink` / `links.check` reach it through `run_command`. + +use serde_json::{Value, json}; + +use crate::{Headless, call_tool}; + +fn run(h: &mut Headless, command: &str, params: Value) -> Value { + let r = call_tool(h, "run_command", &json!({"command": command, "params": params})); + assert!(!r.is_error, "{command}: {r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap() +} + +#[test] +fn headless_links_report_missing_files_and_relink() { + let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-links-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = |n: &str| dir.join(n).to_string_lossy().into_owned(); + let mut h = Headless::new(); + run(&mut h, "file.new", json!({"width": 300, "height": 200})); + let png = run(&mut h, "document.serialize", json!({"format": "png"})); + std::fs::write(file("a.png"), vectorcraft_format::base64_decode(png["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + std::fs::write(file("b.png"), vectorcraft_format::base64_decode(png["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + let id = run(&mut h, "file.place", json!({"path": file("a.png")}))["ids"][0].clone(); + run(&mut h, "document.save", json!({"path": file("doc.vectorcraft")})); + std::fs::remove_file(file("a.png")).unwrap(); + let r = run(&mut h, "document.open", json!({"path": file("doc.vectorcraft")})); + assert_eq!(r["missingLinks"][0]["ids"], json!([id]), "{r}"); + assert_eq!(run(&mut h, "links.check", json!({}))["missing"], 1); + let r = run(&mut h, "links.relink", json!({"ids": [id], "path": file("b.png")})); + assert_eq!(r["relinked"], json!([id])); + assert_eq!(run(&mut h, "links.check", json!({}))["links"][0]["status"], "ok"); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 8fc6b143..0279c972 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -638,7 +638,7 @@ impl<'a> Device<'a> for Builder<'_> { if jpeg { let (w, h) = (r.width(), r.height()); let bytes = st.raw_data().to_vec(); - self.add_image(key, || Some((ImageBlob { mime: "image/jpeg".into(), bytes: Arc::new(bytes) }, w, h)), transform); + self.add_image(key, || Some((ImageBlob::new("image/jpeg", bytes), w, h)), transform); return; } let mut decoded: Option = None; @@ -659,7 +659,7 @@ impl<'a> Device<'a> for Builder<'_> { return; }; let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); - self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)), xf); + self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); } Image::Stencil(s) => { let key = hayro_interpret::CacheKey::cache_key(&s); @@ -677,11 +677,7 @@ impl<'a> Device<'a> for Builder<'_> { ); let Some((rgba, w, h, sf)) = decoded else { return }; let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); - self.add_image( - key ^ 0x5_7e9c, - || rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)), - xf, - ); + self.add_image(key ^ 0x5_7e9c, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); } } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index c9850ae9..dbbe1ecf 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -365,7 +365,7 @@ fn tiny_png() -> Vec { #[test] fn image_roundtrip() { let mut d = doc(200.0, 200.0); - d.images.insert("img1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(tiny_png()) }); + d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png())); let im = ImageObject { key: "img1".into(), width: 4, height: 2, xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), link: None }; add(&mut d, Node::new(NodeId(0), NodeKind::Image(im))); let out = roundtrip(&d); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d71935e1..b6e9f6cb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1231,9 +1231,14 @@ impl Renderer { // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline // Mode its pixels in greyscale under the frame. let outline = f.opts.outline; - let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline) }; + // A linked image's preview is cached apart from the file's pixels, which share its key. + let cache_key = match f.doc.images.get(&im.key) { + Some(b) if b.is_proxy() => std::borrow::Cow::Owned(format!("{}\u{0}proxy", im.key)), + _ => std::borrow::Cow::Borrowed(im.key.as_str()), + }; + let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, &cache_key, outline) }; if let Some(pm) = pixels { - let pm = if outline { pm } else { self.ink_image(&im.key, &pm, f.ink) }; + let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink) }; let sx = im.width as f64 / pm.width().max(1) as f64; let sy = im.height as f64 / pm.height().max(1) as f64; ctx.set_transform(f.view * im.xf); @@ -1249,20 +1254,20 @@ impl Renderer { } } - /// The decoded pixels of image blob `key` (cached), or a greyscale copy of them. - fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool) -> Option> { - let colour = match self.images.get(key) { + /// The decoded pixels of image blob `key` (cached as `cache_key`), or a greyscale copy of them. + fn image_pixmap(&mut self, doc: &Document, key: &str, cache_key: &str, grey: bool) -> Option> { + let colour = match self.images.get(cache_key) { Some(p) => p.clone(), None => { let pm = Arc::new(paint::decode_pixmap(&doc.images.get(key)?.bytes)?); - self.images.insert(key.to_string(), pm.clone()); + self.images.insert(cache_key.to_string(), pm.clone()); pm } }; if !grey { return Some(colour); } - let grey_key = format!("{key}\u{0}grey"); + let grey_key = format!("{cache_key}\u{0}grey"); if let Some(p) = self.images.get(&grey_key) { return Some(p.clone()); } diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 1e0547a1..11aa720f 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -113,7 +113,7 @@ fn placed_images_are_separated_not_blackened() { let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])); let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + d.images.insert("red".into(), ImageBlob::new("image/png", png)); let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None })); add(&mut d, n); let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1); diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index eae7df07..63ca53d1 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -46,7 +46,7 @@ fn image_doc(effects: Vec) -> Document { let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])); let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + d.images.insert("red".into(), ImageBlob::new("image/png", png)); let mut n = Node::new( NodeId(0), NodeKind::Image(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None }), diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests_setup.rs index 00b10cd7..52647eec 100644 --- a/crates/render/src/tests_setup.rs +++ b/crates/render/src/tests_setup.rs @@ -1,7 +1,5 @@ //! Document Setup options the renderer honours. -use std::sync::Arc; - use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use super::*; @@ -10,7 +8,7 @@ fn doc_with_red_image() -> Document { let mut d = Document::new(40.0, 40.0); let mut png = Vec::new(); image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + d.images.insert("red".into(), ImageBlob::new("image/png", png)); let (id, layer) = (d.alloc_id(), d.layers[0].id); let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 82be15b8..273458f5 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1154,7 +1154,7 @@ impl Writer<'_> { NodeKind::Image(im) => { let link = self.opts.images == ImageMode::Link; let href = match (im.link.as_ref().filter(|_| link), self.doc.images.get(&im.key)) { - (Some(l), _) => l.clone(), + (Some(l), _) => l.path.clone(), (None, Some(b)) if !b.bytes.is_empty() && link => { // Named after the bytes: image keys ("raster-1"…) repeat across documents. let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); @@ -1165,7 +1165,7 @@ impl Writer<'_> { } (None, Some(b)) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)), _ => match &im.link { - Some(l) => l.clone(), + Some(l) => l.path.clone(), None => return, }, }; diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 9b3aa16e..79c7530e 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -770,7 +770,7 @@ impl Importer { .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)); - self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob { mime: mime.into(), bytes: Arc::new(bytes.to_vec()) }); + self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob::new(mime, bytes.to_vec())); let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link: None }))) } diff --git a/crates/svg/tests/options.rs b/crates/svg/tests/options.rs index 26d95c92..8079569a 100644 --- a/crates/svg/tests/options.rs +++ b/crates/svg/tests/options.rs @@ -3,8 +3,6 @@ // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use std::sync::Arc; - use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, ImageBlob, ImageObject, Node, NodeKind, TextKind, TextObject, TextRun}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; @@ -55,7 +53,7 @@ fn every_combination_reads_back() { /// A document with one embedded image under `key`. fn image_doc(key: &str, bytes: Vec) -> Document { let mut d = Document::new(100.0, 100.0); - d.images.insert(key.into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(bytes) }); + d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let l = d.layers[0].id; let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None }; let n = Node::new(d.alloc_id(), NodeKind::Image(im)); diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 3ed604fa..045732f8 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -343,7 +343,7 @@ fn image_roundtrip() { NodeKind::Image(ImageObject { key: "k".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0), link: None }), ); let mut d = doc_with(vec![n]); - d.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png.clone()) }); + d.images.insert("k".into(), ImageBlob::new("image/png", png.clone())); let s = export(&d, &ExportOptions::default()); assert!(s.contains("data:image/png;base64,"), "{s}"); let r = roundtrip(&d); diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 71f171b9..a094f904 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -57,7 +57,7 @@ fn image_color_at(cx: &ToolContext, n: &Node, p: Point) -> Option { return None; } let px = im.xf.inverse() * p; - let [r, g, b, _] = cx.doc.images.get(&im.key)?.sample(px.x, px.y, cx.raster_sample)?; + let [r, g, b, _] = cx.doc.images.get(&im.key)?.sample_object(px.x, px.y, (im.width, im.height), cx.raster_sample)?; Some(Color::rgb8(r, g, b)) } diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs new file mode 100644 index 00000000..93c00d8c --- /dev/null +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -0,0 +1,254 @@ +//! The linked files a document opened without, one at a time: Replace picks another file +//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its +//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the +//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are +//! offered for update (`links.update`). +//! +//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of +//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty) +//! and `discard` (Ignore). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of a missing linked file. +pub const KIND: &str = "missingLinks"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Linked File Not Found".into(), + body, + confirm, + ok: Some("Replace…"), + discard: Some("Ignore"), + min_width: 380.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// The ids in `rows` (`[{ids}]`). +fn ids(rows: &[Value]) -> Vec { + rows.iter().flat_map(|r| r["ids"].as_array().cloned().unwrap_or_default()).collect() +} + +/// After a document opened (`document.open`'s result `r`): ask about its missing linked files, +/// then about its modified ones. The web reads no linked files, so missing ones just show their +/// previews there. +pub fn after_open(app: &mut VectorcraftApp, r: &Value) { + let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default(); + let (missing, modified) = (rows("missingLinks"), ids(&rows("modifiedLinks"))); + if missing.is_empty() || cfg!(target_arch = "wasm32") { + if !missing.is_empty() { + app.status(format!("{} linked file(s) can't be read here: their previews show", missing.len())); + } + return ask_update(app, modified); + } + open(app, missing, modified); +} + +fn open(app: &mut VectorcraftApp, missing: Vec, modified: Vec) { + app.ui.dialog = Some(Dialog::new(KIND, json!({ "missing": missing, "modified": modified, "applyToAll": false, "path": "" }))); +} + +/// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again. +fn ask_update(app: &mut VectorcraftApp, ids: Vec) { + if ids.is_empty() { + return; + } + if app.session.prefs.update_links != "askWhenModified" { + app.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", ids.len())); + return; + } + let detail = format!("{} linked image(s) changed since this document was saved. Show the new versions?", ids.len()); + super::confirm::ask(app, "Update Modified Links", &detail, "links.update", json!({ "ids": ids })); +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + let Some(current) = missing.first() else { return true }; + let rest = missing.len() - 1; + let name = current["name"].as_str().unwrap_or_default(); + widgets::list_box(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + ui.label(egui::RichText::new(name).font(theme::semibold(12.0)).color(t.text_strong)); + let path = current["path"].as_str().unwrap_or_default(); + ui.add(egui::Label::new(egui::RichText::new(path).size(11.5).color(t.text_dim)).truncate()).on_hover_text(path); + ui.add_space(3.0); + }); + }); + }); + ui.add_space(8.0); + widgets::dim_label(ui, "Replace it with another file, or ignore it: its images keep showing the preview saved with the document."); + if rest > 0 { + ui.add_space(10.0); + let all = d.bool("applyToAll"); + let label = format!("Apply to All ({} more missing)", rest); + if widgets::check(ui, &label, all, true) { + d.fields.insert("applyToAll".into(), json!(!all)); + } + } + false +} + +/// Replace (with `path`, else a picked file) or Ignore (`discard`) the first missing file, then +/// ask about the next. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let missing = d.fields.get("missing").and_then(Value::as_array).cloned().unwrap_or_default(); + let modified = d.fields.get("modified").and_then(Value::as_array).cloned().unwrap_or_default(); + let all = d.bool("applyToAll"); + let Some((current, rest)) = missing.split_first() else { + app.ui.dialog = None; + return Ok(Value::Null); + }; + let mut rest = rest.to_vec(); + let mut out = Value::Null; + if d.bool("discard") { + if all { + rest.clear(); + } + } else { + let path = match d.str("path") { + p if !p.is_empty() => p, + _ => app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?, + }; + out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?; + // Apply to All: the others by name in the same folder; any not there are asked about. + let folder = std::path::Path::new(&path).parent().map(|p| p.to_string_lossy().into_owned()); + if let (true, false, Some(folder)) = (all, rest.is_empty(), folder) { + let r = app.run("links.relink", json!({ "ids": ids(&rest), "folder": folder }))?; + let done = r["relinked"].as_array().cloned().unwrap_or_default(); + rest.retain(|row| row["ids"].as_array().is_none_or(|ids| ids.iter().any(|id| !done.contains(id)))); + } + } + if rest.is_empty() { + app.ui.dialog = None; + ask_update(app, modified); + } else { + open(app, rest, modified); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + fn png(rgb: [u8; 3]) -> Vec { + let mut out = vec![]; + let [r, g, b] = rgb; + image::RgbaImage::from_pixel(400, 300, image::Rgba([r, g, b, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + /// A document with two placed linked files, saved to a fresh folder; the files are then + /// deleted → (app, folder, document path). + fn opened_without_links(name: &str) -> (VectorcraftApp, std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("vectorcraft-missing-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for n in ["a.png", "b.png"] { + let p = dir.join(n); + std::fs::write(&p, png([200, 0, 0])).unwrap(); + app.run("file.place", json!({"path": p.to_string_lossy()})).unwrap(); + } + let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + app.run("document.save", json!({"path": doc})).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::remove_file(dir.join(n)).unwrap(); + } + crate::io::open_path(&mut app, &doc).unwrap(); + (app, dir, doc) + } + + #[test] + fn opening_without_linked_files_asks_to_replace_or_ignore_each() { + let (mut app, dir, _) = opened_without_links("each"); + let d = app.ui.dialog.as_ref().expect("the dialog"); + assert_eq!((d.kind.as_str(), d.fields["missing"].as_array().unwrap().len()), (KIND, 2)); + let text = frame(&mut app); + for label in ["Linked File Not Found", "a.png", "Apply to All (1 more missing)", "Replace…", "Ignore", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + // Replace the first with a file given by path (the file picker otherwise). + let blue = dir.join("new/blue.png"); + std::fs::create_dir_all(blue.parent().unwrap()).unwrap(); + std::fs::write(&blue, png([0, 0, 200])).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(blue.to_string_lossy())); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["relinked"].as_array().unwrap().len(), 1); + let d = app.ui.dialog.as_ref().expect("asks about the next"); + assert_eq!(d.fields["missing"][0]["name"], "b.png"); + assert!(!d.fields.contains_key("discard")); + assert!(frame(&mut app).contains("b.png")); + // Ignore: done, the image keeps its preview. + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let c = app.run("links.check", json!({})).unwrap(); + let status: Vec<&str> = c["links"].as_array().unwrap().iter().map(|l| l["status"].as_str().unwrap()).collect(); + assert_eq!((status, c["missing"].as_u64()), (vec!["ok", "missing"], Some(1))); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn apply_to_all_replaces_the_others_from_the_same_folder() { + let (mut app, dir, _) = opened_without_links("all"); + let found = dir.join("found"); + std::fs::create_dir_all(&found).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::write(found.join(n), png([0, 0, 200])).unwrap(); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("applyToAll".into(), json!(true)); + d.fields.insert("path".into(), json!(found.join("a.png").to_string_lossy())); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "both relinked"); + let c = app.run("links.check", json!({})).unwrap(); + assert_eq!(c["missing"], 0, "{c}"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn modified_links_are_offered_for_update_when_the_preference_asks() { + let dir = std::env::temp_dir().join(format!("vectorcraft-modified-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let pic = dir.join("a.png"); + std::fs::write(&pic, png([200, 0, 0])).unwrap(); + app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap(); + let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + app.run("document.save", json!({"path": doc})).unwrap(); + std::fs::write(&pic, png([0, 0, 200])).unwrap(); + crate::io::open_path(&mut app, &doc).unwrap(); + let d = app.ui.dialog.as_ref().expect("asks"); + assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string())); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.run("links.check", json!({})).unwrap()["modified"], 0); + // Manually: no question. + app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap(); + std::fs::write(&pic, png([0, 200, 0])).unwrap(); + crate::io::open_path(&mut app, &doc).unwrap(); + assert!(app.ui.dialog.is_none()); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 31a7030a..d550f0f8 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -27,6 +27,7 @@ mod form; mod gradient_stop; pub mod graphic_style_options; pub mod import_pdf; +pub mod missing_links; pub mod new_color_group; mod new_document; pub mod new_swatch; @@ -194,6 +195,7 @@ registry! { SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC, FileInfo: [file_info::KIND] => file_info::SPEC, RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC, + MissingLinks: [missing_links::KIND] => missing_links::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 4df83cde..4261c67f 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -40,6 +40,7 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default())); } + crate::dialogs::missing_links::after_open(app, &r); Ok(()) } @@ -111,7 +112,7 @@ pub fn save(app: &mut VectorcraftApp, path: Option, save_as: bool, param let f = fileio::save_format(format_param(params), Some(&path))?; fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?); let st = app.session.active().ok_or("no document")?; - let (enc, opts) = fileio::save_encoding(st, f, params).map_err(|e| e.to_string())?; + let (enc, opts) = fileio::save_encoding(st, f, params, Some(&path)).map_err(|e| e.to_string())?; let doc = st.doc.clone(); write_encoded(app, &doc, &path, &enc)?; if let Some(st) = app.session.active_mut() { @@ -139,7 +140,8 @@ pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result