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