//! 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, })) } /// The FNV-1a offset basis: the hash of no bytes ([`fnv1a`]). const FNV_BASIS: u64 = 0xcbf29ce484222325; /// The 64-bit FNV-1a hash `h` of some bytes, carried on over `bytes`. fn fnv1a(h: u64, bytes: &[u8]) -> u64 { bytes.iter().fold(h, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3)) } /// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits. pub fn hash_bytes(bytes: &[u8]) -> String { format!("img{:016x}", fnv1a(FNV_BASIS, bytes)) } /// [`hash_bytes`] of everything `r` reads, read 64 KB at a time. pub fn hash_reader(mut r: impl std::io::Read) -> std::io::Result { let mut buf = vec![0; 1 << 16]; let mut h = FNV_BASIS; loop { match r.read(&mut buf) { Ok(0) => return Ok(format!("img{h:016x}")), Ok(n) => h = fnv1a(h, buf.get(..n).unwrap_or_default()), Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} Err(e) => return Err(e), } } } 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.extras().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.graph_designs.iter().map(|g| &g.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 (or placed documents, always linked) 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; } }); // A placed document saves its file's preview the same way. self.visit_placed(|_, p| uses.entry(p.key.as_str()).or_default().0 = true); uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect() } } /// Links panel → Placement Options: how a file read again (Relink, Update Link) takes the place of /// the art it replaces. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PlacementOptions { #[serde(default)] pub preserve: Preserve, /// Where the new art sits in the old bounds (all but [`Preserve::Bounds`]). #[serde(default)] pub align: Align, /// Clip the new art to the old bounds where it is larger. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub clip: bool, } /// What a relinked image keeps of the art it replaces. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Preserve { /// Its scale (relative to each file's 100% size), rotation and position. Transforms, /// Its bounds: the new art is stretched into them. #[default] Bounds, /// Nothing but its position: the new art at 100%, unrotated. FileDimensions, /// Its bounds: the new art scaled proportionally to fit inside them. Fit, /// Its bounds: the new art scaled proportionally to fill them. Fill, } impl Preserve { pub const ALL: [Preserve; 5] = [Preserve::Transforms, Preserve::Bounds, Preserve::FileDimensions, Preserve::Fit, Preserve::Fill]; /// The id commands use (`transforms`, `bounds`, `fileDimensions`, `fit`, `fill`). pub fn id(self) -> &'static str { match self { Preserve::Transforms => "transforms", Preserve::Bounds => "bounds", Preserve::FileDimensions => "fileDimensions", Preserve::Fit => "fit", Preserve::Fill => "fill", } } pub fn from_id(id: &str) -> Option { Self::ALL.into_iter().find(|p| p.id() == id) } } /// A point of the 3×3 alignment grid, row by row from the top left. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Align { TopLeft, Top, TopRight, Left, #[default] Center, Right, BottomLeft, Bottom, BottomRight, } impl Align { pub const ALL: [Align; 9] = [Align::TopLeft, Align::Top, Align::TopRight, Align::Left, Align::Center, Align::Right, Align::BottomLeft, Align::Bottom, Align::BottomRight]; /// The id commands use (`topLeft` … `bottomRight`). pub fn id(self) -> &'static str { match self { Align::TopLeft => "topLeft", Align::Top => "top", Align::TopRight => "topRight", Align::Left => "left", Align::Center => "center", Align::Right => "right", Align::BottomLeft => "bottomLeft", Align::Bottom => "bottom", Align::BottomRight => "bottomRight", } } pub fn from_id(id: &str) -> Option { Self::ALL.into_iter().find(|a| a.id() == id) } /// The fractions (0, ½ or 1) of the free room left of and above the new art. pub fn fractions(self) -> (f64, f64) { let i = Self::ALL.iter().position(|a| *a == self).unwrap_or(4); ((i % 3) as f64 / 2.0, (i / 3) as f64 / 2.0) } } impl Document { /// Rewrite linked file locations in both raster images and placed VectorCraft documents, /// including those inside symbols, patterns and opacity masks. Used to make packages portable. pub fn update_links(&mut self, mut f: impl FnMut(&mut LinkInfo)) { fn walk(n: &mut Arc, f: &mut dyn FnMut(&mut LinkInfo)) { let n = Arc::make_mut(n); match &mut n.kind { NodeKind::Image(image) => { if let Some(link) = &mut image.link { f(link); } } NodeKind::PlacedDocument(placed) => f(&mut placed.link), _ => {} } if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { walk(&mut mask.art, f); } for child in n.children_mut().into_iter().flatten() { walk(child, f); } } let roots = self .layers .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); for root in roots { walk(root, &mut f); } } /// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask /// art included) for which `pick` holds; only the subtrees holding one are copied. pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) { fn holds(n: &Node, pick: &dyn Fn(&ImageObject) -> bool) -> bool { let mut hit = false; walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::Image(im) if pick(im))); hit } fn update(n: &mut Arc, pick: &dyn Fn(&ImageObject) -> bool, f: &mut dyn FnMut(&mut ImageObject)) { if !holds(n, pick) { return; } let n = Arc::make_mut(n); if let NodeKind::Image(im) = &mut n.kind && pick(im) { f(im); } if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { update(&mut m.art, pick, f); } for c in n.children_mut().into_iter().flatten() { update(c, pick, f); } } let roots = self .layers .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); for root in roots { update(root, &pick, &mut f); } } } #[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(Box::new(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default(), })), ), ) .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 a_reader_hashes_as_its_bytes_do() { // Several 64 KB reads, and none. let bytes: Vec = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect(); assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes)); assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string())); } #[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"]); } }