diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 44331764..db1aed4e 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -173,10 +173,31 @@ fn services() -> Services { let _ = webbrowser::open(url); })), reveal: Some(Box::new(reveal)), + // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. + open_file: Some(Box::new(open_file)), + pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))), ..Default::default() } } +/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it. +fn open_file(path: &str) -> Result<(), String> { + #[cfg(windows)] + let mut c = { + use std::os::windows::process::CommandExt as _; + let mut c = std::process::Command::new("explorer"); + c.raw_arg(format!("\"{}\"", path.replace('/', "\\"))); + c + }; + #[cfg(target_os = "macos")] + let mut c = std::process::Command::new("open"); + #[cfg(not(any(target_os = "macos", windows)))] + let mut c = std::process::Command::new("xdg-open"); + #[cfg(not(windows))] + c.arg(path); + c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) +} + /// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets /// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches /// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon. diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 879fb187..08916a8c 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -328,7 +328,14 @@ fn images_link_png_or_jpeg_files() { let mut png = vec![]; image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 128])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let mut d = doc_with(|d| { - let im = ImageObject { key: "raster-1".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), link: None }; + let im = ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + }; vec![Node::new(d.alloc_id(), NodeKind::Image(im))] }); d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b3125304..22690ac4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -48,7 +48,7 @@ pub use appearance::{ }; pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; -pub use links::LinkInfo; +pub use links::{LinkInfo, PlacementOptions}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 7a9e0211..2cd24d5a 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -156,6 +156,136 @@ impl Document { } } +/// 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 { + /// 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) = &mut n.mask { + 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.patterns.iter_mut().flat_map(|p| &mut p.art)); + for root in roots { + update(root, &pick, &mut f); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -171,8 +301,15 @@ mod tests { 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() + d.insert( + Some(layer), + 0, + Node::new( + id, + NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }), + ), + ) + .unwrap() } #[test] diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 197c16e2..378d4b2d 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -204,6 +204,9 @@ pub struct ImageObject { /// 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, + /// Links panel → Placement Options: how a file read again takes this image's place. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub placement: crate::links::PlacementOptions, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index b04dad02..083470a0 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -251,7 +251,8 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?; let (sx, sy) = pt_per_px(img.ppi); let (w, h) = (img.width, img.height); - let image = ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None }; + let image = + ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() }; let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() }; clip.images.insert(img.key, img.blob); let count = s.load_clipboard(clip, p)?; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index efec2464..3b335234 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -280,7 +280,10 @@ fn raster_doc(name: &str, bytes: &[u8]) -> Result { let mut d = Document::new(width as f64, height as f64); let layer = d.layers[0].id; let id = d.alloc_id(); - let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None })); + let mut n = Node::new( + id, + NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + ); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 217c07f0..e18efbbc 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -514,6 +514,18 @@ pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option)> { Some((m.len(), modified)) } +/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn file_created(path: &str) -> Option { + let t = std::fs::metadata(path).ok()?.created().ok()?; + t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn file_created(_: &str) -> Option { + None +} + /// `path` made absolute against the working directory (as given when absolute already, or when /// that fails). #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index ea55ee96..e55b54c8 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1039,7 +1039,10 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); 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 image = Node::new( + d.alloc_id(), + NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() }), + ); 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 index 4f5dddeb..9d72f3e2 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -9,17 +9,23 @@ //! 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. +//! at other files. A file read again takes the image's place as its Placement Options say +//! ([`PlacementOptions`]: keep the bounds or the transforms, fit or fill the bounds…). +//! +//! The Links panel lists every image (`links.list`) with its Link Info (`links.info`); Go To +//! selects one (`links.goTo`), Embed keeps a linked file's pixels in the document +//! (`links.embed`) and Unembed writes an embedded image to a file it then links to +//! (`links.unembed`). 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 vectorcraft_doc::links::{Align, Preserve, hash_bytes}; +use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacementOptions}; +use vectorcraft_geom::{Affine, Rect, shapes}; -use super::fileio::{self, RasterImage, absolute_path, file_stamp, read_file}; +use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; use super::*; pub fn specs() -> Vec { @@ -51,6 +57,60 @@ pub fn specs() -> Vec { has_doc, relink ), + cmd!( + query "links.list", + "Links", + [], + None, + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview)}], missing, modified, embedded}", + has_doc, + list + ), + cmd!( + "links.goTo", + "Go To Link", + [], + None, + "{id?} select image `id` (default: the first selected image); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}", + has_doc, + go_to + ), + cmd!( + "links.embed", + "Embed Image", + [], + None, + "{ids?: [image ids] (default: the selected linked images)} keep the linked files' pixels in the document and drop the links (an image whose file can't be found and that shows the saved preview stays linked: relink it first). One undo step → {embedded: [ids], missing: [ids]}", + has_doc, + embed + ), + cmd!( + "links.unembed", + "Unembed", + [], + None, + "{id?: an embedded image (default: the one selected), path?} write the image's pixels to the file at path (PNG, JPEG, GIF or WebP as stored, other images as PNG; a path ending in .png/.jpg/.jpeg/.gif/.webp/.tif/.tiff/.bmp converts them) and link the image to it, keeping its bounds. One undo step → {id, path}; no path → {name, dataBase64} (nothing changes: there is no file to link to)", + has_doc, + unembed + ), + cmd!( + query "links.info", + "Link Info", + [], + None, + "{id?} an image's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch)", + has_doc, + info + ), + cmd!( + "links.placementOptions", + "Placement Options", + [], + None, + "{ids?: [image ids] (default: the selected images), preserve?: transforms|bounds (default)|fileDimensions|fit|fill, align?: topLeft|top|topRight|left|center (default)|right|bottomLeft|bottom|bottomRight (where the new art sits; not for bounds), clip?: false (clip it to the old bounds where it is larger)} how a file read again (links.relink, links.update) takes each image's place: transforms keeps its scale (relative to each file's 100% size), rotation and position; bounds stretches it into the old bounds; fileDimensions puts it at 100%, unrotated; fit and fill scale it proportionally to fit inside or cover the old bounds. Without options → {placement} of the first image; else one undo step → {ids, placement}", + has_doc, + placement_options + ), ] } @@ -215,19 +275,103 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result { 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); +/// Points per pixel of the file image `key` of `d` shows, at the resolution it declares (72 ppi +/// when it declares none, or when only its preview is loaded). +fn file_pt(d: &Document, key: &str) -> (f64, f64) { + super::place::pt_per_px(file_ppi(d, key)) +} + +/// The resolution the file image `key` of `d` shows declares (none while only its preview is +/// loaded). +fn file_ppi(d: &Document, key: &str) -> Option<(f64, f64)> { + d.images.get(key).filter(|b| !b.is_proxy()).and_then(|b| fileio::ppi::resolution(&b.bytes)) +} + +/// Where the clip of an image goes: the old bounds' frame (bounds space to the document) and size. +type Clip = (Affine, (f64, f64)); + +/// The transform an image placed with `xf` showing `old` pixels (`old_pt`: points per pixel at +/// 100%) gives a new file of `new` pixels (`new_pt`), as Placement Options `o` say; and the clip +/// when `o.clip` and the new art overflows the old bounds. +fn placed_xf(xf: Affine, old: (u32, u32), old_pt: (f64, f64), new: (u32, u32), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option) { + let (ow, oh, nw, nh) = (old.0 as f64, old.1 as f64, new.0.max(1) as f64, new.1.max(1) as f64); + let [a, b, c, d, ..] = xf.as_coeffs(); + // The bounds' frame: the transform without its scale (rotation, reflection and position). + let (su, sv) = (a.hypot(b), c.hypot(d)); + if o.preserve == Preserve::Bounds || su < 1e-12 || sv < 1e-12 { + return (xf * Affine::scale_non_uniform(ow / nw, oh / nh), None); + } + let (frame, room) = match o.preserve { + Preserve::FileDimensions => { + let r = xf.transform_rect_bbox(Rect::new(0.0, 0.0, ow, oh)); + (Affine::translate(r.origin().to_vec2()), (r.width(), r.height())) + } + _ => (xf * Affine::scale_non_uniform(1.0 / su, 1.0 / sv), (ow * su, oh * sv)), + }; + // The new file's size at 100% (pt), then as placed. + let natural = (nw * new_pt.0, nh * new_pt.1); + let size = match o.preserve { + Preserve::Transforms => (natural.0 * su / old_pt.0, natural.1 * sv / old_pt.1), + Preserve::Fit | Preserve::Fill => { + let (kx, ky) = (room.0 / natural.0, room.1 / natural.1); + let k = if o.preserve == Preserve::Fit { kx.min(ky) } else { kx.max(ky) }; + (natural.0 * k, natural.1 * k) + } + _ => natural, + }; + let (fx, fy) = o.align.fractions(); + let at = Affine::translate(((room.0 - size.0) * fx, (room.1 - size.1) * fy)); + let over = size.0 > room.0 + 1e-6 || size.1 > room.1 + 1e-6; + (frame * at * Affine::scale_non_uniform(size.0 / nw, size.1 / nh), (o.clip && over).then_some((frame, room))) +} + +/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just +/// it changes, else the image goes into a new clip group. +fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> { + let path = shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame); + let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind { + NodeKind::Group { children, clip: true } => match &children[..] { + [c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id), + _ => None, + }, + _ => None, + }); + if let Some(c) = clip_path { + if let Some(Node { kind: NodeKind::Path { path: p, .. }, .. }) = d.node_mut(c) { + *p = path; + } + return Ok(()); + } + let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + let image = d.remove(id)?; + let none = vectorcraft_color::Paint::None; + let mut clip = Node::path(d.alloc_id(), path, Appearance::basic(none.clone(), none, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), image], clip: true }); + d.insert(par, index, group)?; + Ok(()) +} + +/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement +/// Options say (`keep_bounds`: in its bounds, whatever they say). +fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, keep_bounds: bool) -> Result<()> { + let new_pt = super::place::pt_per_px(img.ppi); + let mut clips = vec![]; for id in ids { + let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue }; + let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..im.placement } } else { im.placement }; + let (xf, clip) = placed_xf(im.xf, (im.width, im.height), file_pt(d, &im.key), (img.width, img.height), new_pt, o); 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())); + (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); } + clips.extend(clip.map(|c| (*id, c))); } + store_image(d, &img.key, img.blob, true); + clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip)) } /// Groups as `{name, path, ids}` rows (those with objects in the layers). @@ -266,11 +410,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv 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); - } + (Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link).and_then(|read| install(d, &g.ids, read, false)) { + Ok(()) => out.updated.extend(row), Err(_) => out.missing.extend(row), }, (Status::Modified, _) => out.modified.extend(row), @@ -407,10 +548,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } 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(()) - })?; + s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?; } Ok(json!({ "updated": updated, "missing": missing })) } @@ -466,12 +604,314 @@ fn relink(s: &mut Session, p: &Value) -> Result { } 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(()) - })?; + s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?; } not_found.sort(); not_found.dedup(); Ok(json!({ "relinked": relinked, "notFound": not_found })) } + +// ---------- the Links panel ---------- + +/// Image object `id` of `d`. +fn image_of(d: &Document, id: NodeId) -> Option<&ImageObject> { + match &d.node(id)?.kind { + NodeKind::Image(im) => Some(im), + _ => None, + } +} + +/// The selected image objects of `st` that `keep` accepts. +fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) -> Vec { + st.selection.objects.iter().copied().filter(|id| image_of(&st.doc, *id).is_some_and(&keep)).collect() +} + +/// `ids` (else the selected images `keep` accepts), checked to be images; an error when none. +fn images_param(st: &crate::DocState, p: &Value, cmd: &str, keep: impl Fn(&ImageObject) -> bool) -> Result> { + let ids = ids_param(p, "ids").unwrap_or_else(|| selected_images(st, keep)); + if let Some(id) = ids.iter().find(|id| image_of(&st.doc, **id).is_none()) { + return Err(bad(cmd, format!("object {} is not an image", id.0))); + } + if ids.is_empty() { + return Err(bad(cmd, "select the images, or pass ids")); + } + Ok(ids) +} + +/// `id`, else the first selected image. +fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { + let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; + match image_of(&st.doc, id) { + Some(_) => Ok(id), + None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None => Err(bad(cmd, format!("object {} is not an image", id.0))), + } +} + +/// Per linked image in the layers of `st`: its file's status, where it was found when away from +/// its path, and whether the image shows its preview (each file probed once). +fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { + let mut out = BTreeMap::new(); + for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) { + let (status, found) = probe(g, folder(st.path.as_deref())); + let found = found.map(|f| f.path).filter(|p| *p != g.link.path); + let preview = previewing(&st.doc, &g.key); + out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview)))); + } + out +} + +/// The format of the file image `im` of `d` shows: its link's extension, else its pixels' type. +fn format_label(d: &Document, im: &ImageObject) -> &'static str { + im.link + .as_ref() + .and_then(|l| fileio::format_for_name(l.name())) + .or_else(|| d.images.get(&im.key).and_then(|b| fileio::FORMATS.iter().find(|f| f.raster && f.mime == b.mime))) + .map_or("Image", |f| f.label) +} + +/// Sort rank of a `links.list` status: missing, modified, ok, embedded. +fn status_rank(status: &str) -> usize { + ["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4) +} + +fn list(s: &mut Session, p: &Value) -> Result { + const C: &str = "links.list"; + let st = s.doc()?; + let show = str_param(p, "show").unwrap_or("all"); + if !matches!(show, "all" | "missing" | "modified" | "embedded") { + return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded"))); + } + let statuses = statuses(st); + let mut images = vec![]; + st.doc.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + images.push((n, im)); + } + }); + // Top first, as the Layers panel lists them. + images.reverse(); + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + let mut rows = vec![]; + for (n, im) in images { + let (status, found, preview) = match statuses.get(&n.id) { + Some((s, f, p)) => (s.as_str(), f.as_deref(), *p), + None => ("embedded", None, false), + }; + *counts.entry(status).or_default() += 1; + if show != "all" && show != status { + continue; + } + let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string()); + let mut row = json!({ + "id": n.id.0, + "name": name, + "linked": im.link.is_some(), + "status": status, + "format": format_label(&st.doc, im), + "pixelWidth": im.width, + "pixelHeight": im.height, + }); + if let Some(l) = &im.link { + row["path"] = json!(l.path); + if let Some(page) = l.page { + row["page"] = json!(page); + } + } + if let Some(f) = found { + row["found"] = json!(f); + } + if preview { + row["preview"] = json!(true); + } + rows.push(row); + } + let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase(); + match str_param(p, "sort") { + None => {} + Some("name") => rows.sort_by_cached_key(|r| text(r, "name")), + Some("kind") => rows.sort_by_cached_key(|r| (text(r, "format"), text(r, "name"))), + Some("status") => rows.sort_by_cached_key(|r| (status_rank(r["status"].as_str().unwrap_or_default()), text(r, "name"))), + Some(x) => return Err(bad(C, format!("sort `{x}`: name, kind or status"))), + } + let count = |k: &str| counts.get(k).copied().unwrap_or(0); + Ok(json!({ "links": rows, "missing": count("missing"), "modified": count("modified"), "embedded": count("embedded") })) +} + +fn go_to(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = image_param(st, p, "links.goTo")?; + let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default(); + s.select(|_, sel| sel.set([id]))?; + Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] })) +} + +fn embed(s: &mut Session, p: &Value) -> Result { + const C: &str = "links.embed"; + let st = s.doc()?; + let ids = images_param(st, p, C, |im| im.link.is_some())?; + // Images showing their preview take their file's pixels first. + let (mut done, mut missing, mut reads) = (vec![], vec![], vec![]); + for g in groups(&st.doc, Some(&ids)) { + if !previewing(&st.doc, &g.key) { + done.extend(g.ids); + continue; + } + match probe(&g, folder(st.path.as_deref())) { + (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) { + Ok(read) => { + done.extend(&g.ids); + reads.push((g.ids, read)); + } + Err(_) => missing.extend(g.ids), + }, + _ => missing.extend(g.ids), + } + } + if !done.is_empty() { + s.edit("Embed", |d, _| { + for (ids, read) in reads { + install(d, &ids, read, true)?; + } + for id in &done { + if let Some(n) = d.node_mut(*id) + && let NodeKind::Image(im) = &mut n.kind + { + im.link = None; + } + } + Ok(()) + })?; + } + Ok(json!({ "embedded": ids_json(&done), "missing": ids_json(&missing) })) +} + +/// The raster formats Unembed writes, by extension. +const UNEMBED_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; + +/// Encoded image `bytes` re-encoded in the format of extension `ext` (JPEG without alpha). +fn convert_image(bytes: &[u8], ext: &str) -> std::result::Result, String> { + let format = image::ImageFormat::from_extension(ext).ok_or_else(|| format!("can't write .{ext} images"))?; + let img = image::load_from_memory(bytes).map_err(|e| e.to_string())?; + let img = if format == image::ImageFormat::Jpeg { image::DynamicImage::ImageRgb8(img.to_rgb8()) } else { img }; + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).map_err(|e| e.to_string())?; + Ok(out) +} + +fn unembed(s: &mut Session, p: &Value) -> Result { + const C: &str = "links.unembed"; + let st = s.doc()?; + let id = image_param(st, p, C)?; + let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; + if im.link.is_some() { + return Err(bad(C, format!("image {} is linked already", id.0))); + } + let blob = st.doc.images.get(&im.key).filter(|b| !b.is_proxy()).ok_or_else(|| bad(C, "the image's pixels aren't in the document"))?; + // As stored (PNG, JPEG, GIF, WebP), unless the path names another format. + let stored = fileio::FORMATS.iter().find(|f| f.raster && f.mime == blob.mime).map_or("png", |f| f.extensions.first().copied().unwrap_or("png")); + let path = str_param(p, "path"); + let ext = match path.map(fileio::extension) { + Some(e) if UNEMBED_EXTS.contains(&e.as_str()) => e, + Some(e) if !e.is_empty() => return Err(bad(C, format!("can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp"))), + _ => stored.to_string(), + }; + let same = fileio::format(&ext).is_some_and(|f| f.mime == blob.mime); + let bytes = if same { blob.bytes.to_vec() } else { convert_image(&blob.bytes, &ext).map_err(|e| bad(C, e))? }; + let Some(path) = path else { + let name = n.display_name(); + let stem = Path::new(&name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + return Ok(json!({ "name": format!("{stem}.{ext}"), "dataBase64": vectorcraft_format::base64_encode(&bytes) })); + }; + let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() }); + write_file(&path, &bytes)?; + let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?); + s.edit("Unembed", |d, _| install(d, &[id], read, true))?; + Ok(json!({ "id": id.0, "path": path })) +} + +/// Placement Options as `{preserve, align, clip}`. +fn placement_json(o: PlacementOptions) -> Value { + json!({ "preserve": o.preserve.id(), "align": o.align.id(), "clip": o.clip }) +} + +fn info(s: &mut Session, p: &Value) -> Result { + const C: &str = "links.info"; + let id = image_param(s.doc()?, p, C)?; + let mut out = super::place::image_info(s, &json!({ "id": id.0 }))?; + let st = s.doc()?; + let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; + let (px, py) = file_pt(&st.doc, &im.key); + let [a, b, c, d, ..] = im.xf.as_coeffs(); + out["effectivePpi"] = out["ppi"].take(); + out["ppi"] = json!([72.0 / px, 72.0 / py]); + out["scale"] = json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]); + // Counter-clockwise on the page (y points down). + // Adding 0 turns -0 into 0. + out["rotation"] = json!((-b).atan2(a).to_degrees() + 0.0); + out["format"] = json!(format_label(&st.doc, im)); + out["placement"] = placement_json(im.placement); + out["status"] = json!("embedded"); + if let Some(l) = &im.link { + let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0); + let stamp = file_stamp(&l.path); + let folder_len = l.path.len().saturating_sub(l.name().len()); + out["status"] = json!(status.as_str()); + out["path"] = json!(l.path); + out["fileName"] = json!(l.name()); + out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\'])); + for (k, v) in [ + ("page", l.page.map(u64::from)), + ("fileSize", stamp.map(|s| s.0).or(l.size)), + ("modified", stamp.and_then(|s| s.1).or(l.modified)), + ("created", file_created(&l.path)), + ] { + if let Some(v) = v { + out[k] = json!(v); + } + } + } + Ok(out) +} + +fn placement_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "links.placementOptions"; + let st = s.doc()?; + let ids = images_param(st, p, C, |_| true)?; + let choice = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().map(Some).ok_or_else(|| bad(C, format!("{k} must be a string"))), + } + }; + let unknown = |k: &str, v: &str| bad(C, format!("{k} `{v}` is not one of the choices")); + let preserve = choice("preserve")?.map(|v| Preserve::from_id(v).ok_or_else(|| unknown("preserve", v))).transpose()?; + let align = choice("align")?.map(|v| Align::from_id(v).ok_or_else(|| unknown("align", v))).transpose()?; + let clip = match p.get("clip") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "clip must be true or false"))?), + }; + let first = ids.first().and_then(|id| image_of(&st.doc, *id)).map(|im| im.placement).unwrap_or_default(); + if preserve.is_none() && align.is_none() && clip.is_none() { + return Ok(json!({ "placement": placement_json(first) })); + } + let set = |o: &mut PlacementOptions| { + o.preserve = preserve.unwrap_or(o.preserve); + o.align = align.unwrap_or(o.align); + o.clip = clip.unwrap_or(o.clip); + }; + s.edit("Placement Options", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) + && let NodeKind::Image(im) = &mut n.kind + { + set(&mut im.placement); + } + } + Ok(()) + })?; + let mut shown = first; + set(&mut shown); + Ok(json!({ "ids": ids_json(&ids), "placement": placement_json(shown) })) +} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index c1b26c4f..c4f6f67e 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -398,7 +398,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { 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 })); + let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })); if let Some((path, rule)) = clip { node = super::rasterfx::clip_group(d, path, rule, node); } @@ -555,6 +555,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { height: h, xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale), link: None, + placement: Default::default(), }); } Ok(()) diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 7878470d..02b6ac43 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -213,7 +213,14 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { Art::Image(img) => { let (sx, sy) = pt_per_px(img.ppi); 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 }; + let im = ImageObject { + key: img.key, + width: img.width, + height: img.height, + xf: Affine::scale_non_uniform(sx, sy), + link, + placement: Default::default(), + }; Node::new(d.alloc_id(), NodeKind::Image(im)) } Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))), @@ -480,7 +487,7 @@ fn info(_: &mut Session, p: &Value) -> Result { Ok(out) } -fn image_info(s: &mut Session, p: &Value) -> Result { +pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { const C: &str = "image.info"; let st = s.doc()?; let id = match (id_param(p, "id"), &st.selection.objects[..]) { diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index e8c7896b..88ccc4a1 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -195,7 +195,8 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N } 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 })); + let mut image = + Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() })); image.name = Some("Raster effect".into()); Some(image) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25f37246..ed93359b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1060,6 +1060,8 @@ mod tests_linked_stops; #[cfg(test)] mod tests_links; #[cfg(test)] +mod tests_linkspanel; +#[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 080eedce..bb8dc6ff 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -328,7 +328,11 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node 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 })))?; + d.insert( + l, + 0, + Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + )?; sel.set([id]); Ok(id) }) diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index e802bb22..c3413d12 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -137,7 +137,14 @@ fn image_node(s: &mut Session) -> NodeId { s.edit("Place", |d, sel| { d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(bytes)); let id = d.alloc_id(); - let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 2, height: 2, xf: Default::default(), link: None }; + let im = vectorcraft_doc::ImageObject { + key: "pic".into(), + width: 2, + height: 2, + xf: Default::default(), + link: None, + placement: Default::default(), + }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; sel.set([id]); Ok(id) diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index fb5e51f8..e704d441 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -157,7 +157,7 @@ fn clicking_an_image_samples_its_pixel() { let l = d.layers[0].id; let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); - let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None }; + let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).map_err(|e| crate::EngineError::Other(e.to_string())) }) .unwrap(); diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index c82a0bc9..5585ef99 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -11,16 +11,16 @@ use vectorcraft_geom::Rect; use super::*; /// A fresh folder for one test (removed when dropped). -struct Folder(PathBuf); +pub(crate) struct Folder(pub(crate) PathBuf); impl Folder { - fn new(name: &str) -> Self { + pub(crate) fn new(name: &str) -> Self { let dir = std::env::temp_dir().join(format!("vectorcraft-links-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); Self(dir) } - fn file(&self, name: &str) -> String { + pub(crate) fn file(&self, name: &str) -> String { self.0.join(name).to_string_lossy().into_owned() } } @@ -32,55 +32,55 @@ impl Drop for Folder { } /// A `w`×`h` PNG of one colour. -fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { +pub(crate) 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]; +pub(crate) const RED: [u8; 3] = [230, 20, 20]; +pub(crate) const BLUE: [u8; 3] = [20, 20, 230]; -fn write(path: &str, bytes: &[u8]) { +pub(crate) fn write(path: &str, bytes: &[u8]) { if let Some(dir) = Path::new(path).parent() { std::fs::create_dir_all(dir).unwrap(); } std::fs::write(path, bytes).unwrap(); } -fn session() -> Session { +pub(crate) fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); s } /// Place the file at `path` (linked) → its id. -fn place(s: &mut Session, path: &str) -> NodeId { +pub(crate) fn place(s: &mut Session, path: &str) -> NodeId { let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap(); assert_eq!(r["linked"], true, "{r}"); NodeId(r["ids"][0].as_u64().unwrap()) } -fn image(s: &Session, id: NodeId) -> ImageObject { +pub(crate) fn image(s: &Session, id: NodeId) -> ImageObject { match &s.doc().unwrap().doc.node(id).unwrap().kind { NodeKind::Image(im) => im.clone(), k => panic!("not an image: {k:?}"), } } -fn bounds(s: &Session, id: NodeId) -> Rect { +pub(crate) fn bounds(s: &Session, id: NodeId) -> Rect { s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() } /// The colour at the centre of the active document's artboard. -fn centre_colour(doc: &Document) -> [u8; 3] { +pub(crate) fn centre_colour(doc: &Document) -> [u8; 3] { let img = vectorcraft_render::Renderer::new().render_region(doc, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true); let [r, g, b, _] = img.pixel(200, 150); [r, g, b] } -fn near(a: [u8; 3], b: [u8; 3]) -> bool { +pub(crate) fn near(a: [u8; 3], b: [u8; 3]) -> bool { a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 6) } @@ -89,11 +89,11 @@ fn saved_image(v: &mut Value) -> &mut Value { &mut v["document"]["layers"][0]["kind"]["children"][0]["kind"] } -fn save(s: &mut Session, path: &str) { +pub(crate) fn save(s: &mut Session, path: &str) { s.execute("document.save", &json!({"path": path})).unwrap(); } -fn open(s: &mut Session, path: &str) -> Value { +pub(crate) fn open(s: &mut Session, path: &str) -> Value { s.execute("document.open", &json!({"path": path})).unwrap() } @@ -285,8 +285,14 @@ fn relative_paths_are_written_against_the_saved_file() { 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"))) }; + let im = ImageObject { + key: "k".into(), + width: 1, + height: 1, + xf: Default::default(), + link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))), + placement: Default::default(), + }; 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()); diff --git a/crates/engine/src/tests_linkspanel.rs b/crates/engine/src/tests_linkspanel.rs new file mode 100644 index 00000000..15809965 --- /dev/null +++ b/crates/engine/src/tests_linkspanel.rs @@ -0,0 +1,284 @@ +//! The Links panel's commands: listing images with their status (`links.list`), Go To, Embed and +//! Unembed, Link Info and the Placement Options a relinked file follows. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeKind, PlacementOptions}; + +use super::tests_links::{BLUE, Folder, RED, bounds, centre_colour, image, near, open, place, png, save, session, write}; +use super::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +/// Place the file at `path` embedded → its id. +fn place_embedded(s: &mut Session, path: &str) -> NodeId { + let r = run(s, "file.place", json!({"path": path, "at": [200, 150], "link": false})); + NodeId(r["ids"][0].as_u64().unwrap()) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn embed_keeps_the_pixels_without_the_file() { + let dir = Folder::new("embed"); + let pic = dir.file("photo.png"); + let bytes = png(600, 300, RED); + write(&pic, &bytes); + let mut s = session(); + let id = place(&mut s, &pic); + let placed = bounds(&s, id); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("links.embed", &json!({})).is_err(), "nothing selected"); + run(&mut s, "select.set", json!({"ids": [id.0]})); + assert_eq!(run(&mut s, "links.embed", json!({})), json!({"embedded": [id.0], "missing": []})); + let im = image(&s, id); + assert!(im.link.is_none()); + assert_eq!(s.doc().unwrap().doc.images[&im.key].bytes.as_slice(), bytes.as_slice(), "the file's own bytes"); + assert_eq!(bounds(&s, id), placed); + run(&mut s, "edit.undo", json!({})); + assert!(image(&s, id).link.is_some(), "one undo step"); + run(&mut s, "edit.redo", json!({})); + // Saved and reopened without the file: the full pixels, nothing missing. + 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!([]), "{r}"); + let im = image(&s, id); + assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy()); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); +} + +#[test] +fn a_missing_file_showing_its_preview_stays_linked() { + let dir = Folder::new("embed-missing"); + let pic = dir.file("photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + std::fs::remove_file(&pic).unwrap(); + open(&mut s, &doc_path); + assert_eq!(run(&mut s, "links.embed", json!({"ids": [id.0]})), json!({"embedded": [], "missing": [id.0]})); + assert!(image(&s, id).link.is_some()); + assert!(s.doc().unwrap().history.undo.is_empty(), "nothing changed"); +} + +#[test] +fn unembed_writes_the_bytes_and_links_to_them() { + let dir = Folder::new("unembed"); + let pic = dir.file("photo.png"); + let bytes = png(600, 300, RED); + write(&pic, &bytes); + let mut s = session(); + let id = place_embedded(&mut s, &pic); + let placed = bounds(&s, id); + // No path: the file's bytes come back and nothing changes. + let r = run(&mut s, "links.unembed", json!({"id": id.0})); + assert_eq!(r["name"], "photo.png"); + assert_eq!(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), bytes); + assert!(image(&s, id).link.is_none()); + // To a path: written there, and the image links to it. + let out = dir.file("out/photo.png"); + std::fs::create_dir_all(dir.file("out")).unwrap(); + let r = run(&mut s, "links.unembed", json!({"id": id.0, "path": out})); + assert_eq!(r, json!({"id": id.0, "path": out})); + assert_eq!(std::fs::read(&out).unwrap(), bytes); + let link = image(&s, id).link.unwrap(); + assert_eq!((link.path.as_str(), link.size), (out.as_str(), Some(bytes.len() as u64))); + assert_eq!(bounds(&s, id), placed); + assert_eq!(run(&mut s, "links.check", json!({}))["links"][0]["status"], "ok"); + assert!(s.execute("links.unembed", &json!({"id": id.0, "path": out})).is_err(), "linked already"); + run(&mut s, "edit.undo", json!({})); + assert!(image(&s, id).link.is_none(), "one undo step"); + // Another extension converts. + let jpg = dir.file("photo.jpg"); + run(&mut s, "links.unembed", json!({"id": id.0, "path": jpg})); + assert_eq!(std::fs::read(&jpg).unwrap()[..2], [0xff, 0xd8]); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + run(&mut s, "edit.undo", json!({})); + assert!(s.execute("links.unembed", &json!({"id": id.0, "path": dir.file("photo.psd")})).is_err(), "not a format it writes"); +} + +#[test] +fn go_to_selects_the_image() { + let dir = Folder::new("goto"); + let pic = dir.file("photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + assert_ne!(s.doc().unwrap().selection.objects, vec![id]); + let r = run(&mut s, "links.goTo", json!({"id": id.0})); + assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0]})); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[1].id; + assert!(s.execute("links.goTo", &json!({"id": rect.0})).is_err(), "not an image"); +} + +#[test] +fn the_list_shows_each_image_with_its_status() { + let dir = Folder::new("list"); + let (a, b) = (dir.file("a.png"), dir.file("b.png")); + write(&a, &png(600, 300, RED)); + write(&b, &png(40, 20, BLUE)); + let mut s = session(); + let linked = place(&mut s, &a); + let embedded = place_embedded(&mut s, &b); + let l = run(&mut s, "links.list", json!({})); + let rows = l["links"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + // Top first. + assert_eq!((rows[0]["id"].as_u64(), rows[0]["status"].as_str(), rows[0]["name"].as_str()), (Some(embedded.0), Some("embedded"), Some("b.png"))); + assert_eq!( + (rows[1]["id"].as_u64(), rows[1]["status"].as_str(), rows[1]["path"].as_str(), rows[1]["format"].as_str()), + (Some(linked.0), Some("ok"), Some(a.as_str()), Some("PNG")) + ); + assert_eq!((rows[1]["pixelWidth"].as_u64(), rows[1]["pixelHeight"].as_u64()), (Some(600), Some(300))); + write(&a, &png(300, 300, BLUE)); + let l = run(&mut s, "links.list", json!({"sort": "status"})); + assert_eq!((l["modified"].as_u64(), l["embedded"].as_u64(), l["missing"].as_u64()), (Some(1), Some(1), Some(0))); + assert_eq!(l["links"][0]["status"], "modified", "modified before embedded"); + let l = run(&mut s, "links.list", json!({"show": "embedded"})); + assert_eq!(l["links"].as_array().unwrap().len(), 1); + std::fs::remove_file(&a).unwrap(); + assert_eq!(run(&mut s, "links.list", json!({"show": "missing"}))["links"][0]["id"], linked.0); + assert_eq!(run(&mut s, "links.list", json!({"sort": "name"}))["links"][0]["name"], "a.png"); + assert!(s.execute("links.list", &json!({"show": "broken"})).is_err()); + assert!(s.execute("links.list", &json!({"sort": "size"})).is_err()); +} + +#[test] +fn link_info_has_the_file_and_placement_details() { + let dir = Folder::new("info"); + let pic = dir.file("photo.png"); + let bytes = png(600, 300, RED); + write(&pic, &bytes); + let mut s = session(); + let id = place(&mut s, &pic); + let i = run(&mut s, "links.info", json!({"id": id.0})); + assert_eq!((i["status"].as_str(), i["fileName"].as_str(), i["format"].as_str()), (Some("ok"), Some("photo.png"), Some("PNG"))); + assert_eq!(std::path::Path::new(i["location"].as_str().unwrap()), dir.0.as_path()); + assert_eq!((i["fileSize"].as_u64(), i["pixelWidth"].as_u64(), i["colorMode"].as_str()), (Some(bytes.len() as u64), Some(600), Some("RGB"))); + assert_eq!((i["ppi"].clone(), i["effectivePpi"].clone(), i["scale"].clone()), (json!([72.0, 72.0]), json!([72.0, 72.0]), json!([100.0, 100.0]))); + assert!(i["modified"].as_u64().is_some()); + assert_eq!(i["placement"], json!({"preserve": "bounds", "align": "center", "clip": false})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.scale", json!({"sx": 50})); + run(&mut s, "object.rotate", json!({"angle": 30})); + let i = run(&mut s, "links.info", json!({})); + let num = |v: &Value, k: usize| v.get(k).and_then(Value::as_f64).unwrap(); + assert!(close(num(&i["scale"], 0), 50.0) && close(num(&i["effectivePpi"], 0), 144.0), "{i}"); + assert!(close(i["rotation"].as_f64().unwrap(), 30.0), "counter-clockwise: {i}"); + let embedded = place_embedded(&mut s, &pic); + let i = run(&mut s, "links.info", json!({"id": embedded.0})); + assert_eq!((i["status"].as_str(), i["linked"].as_bool(), i["fileName"].as_str()), (Some("embedded"), Some(false), None)); +} + +/// A 600×300 pt image at the centre of the artboard, its placement options set to `options`, +/// relinked to a 300×100 px file → the image's id and session. +fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) { + let dir = Folder::new(name); + let (pic, other) = (dir.file("photo.png"), dir.file("blue.png")); + write(&pic, &png(600, 300, RED)); + write(&other, &png(300, 100, BLUE)); + let mut s = session(); + let id = place(&mut s, &pic); + let mut p = options; + p["ids"] = json!([id.0]); + run(&mut s, "links.placementOptions", p); + run(&mut s, "links.relink", json!({"ids": [id.0], "path": other})); + (s, id, dir) +} + +#[test] +fn a_relinked_file_follows_the_placement_options() { + let b = |s: &Session, id| { + let r = bounds(s, id); + [r.x0, r.y0, r.x1, r.y1].map(|v| (v * 1e6).round() / 1e6) + }; + // The old bounds: -100, 0, 500, 300. + let (s, id, _d) = relinked("pl-bounds", json!({"preserve": "bounds"})); + assert_eq!(b(&s, id), [-100.0, 0.0, 500.0, 300.0], "stretched into the bounds"); + let (s, id, _d) = relinked("pl-fit", json!({"preserve": "fit"})); + assert_eq!(b(&s, id), [-100.0, 50.0, 500.0, 250.0], "proportional, centred"); + let (s, id, _d) = relinked("pl-transforms", json!({"preserve": "transforms"})); + assert_eq!(b(&s, id), [50.0, 100.0, 350.0, 200.0], "at the same 100%"); + let (s, id, _d) = relinked("pl-file", json!({"preserve": "fileDimensions", "align": "topLeft"})); + assert_eq!(b(&s, id), [-100.0, 0.0, 200.0, 100.0]); + let (mut s, id, d) = relinked("pl-fill", json!({"preserve": "fill", "align": "left", "clip": true})); + assert_eq!(b(&s, id), [-100.0, 0.0, 800.0, 300.0], "covers the bounds"); + let clip_group = |s: &Session| { + let doc = &s.doc().unwrap().doc; + let g = doc.node(doc.parent_of(id).unwrap()).unwrap(); + let NodeKind::Group { children, clip: true } = &g.kind else { panic!("not clipped: {g:?}") }; + (g.id, children[0].geometric_bounds().unwrap()) + }; + let (group, clip) = clip_group(&s); + assert_eq!(clip, vectorcraft_geom::Rect::new(-100.0, 0.0, 500.0, 300.0), "clipped to the old bounds"); + // Relinked again: the same clip group, its path following. + run(&mut s, "links.relink", json!({"ids": [id.0], "path": d.file("photo.png")})); + assert_eq!(clip_group(&s).0, group); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!( + matches!(s.doc().unwrap().doc.node(s.doc().unwrap().doc.parent_of(id).unwrap()).unwrap().kind, NodeKind::Layer { .. }), + "one undo step each" + ); +} + +#[test] +fn placement_options_read_set_and_round_trip() { + let dir = Folder::new("placement"); + let pic = dir.file("photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + run(&mut s, "select.set", json!({"ids": [id.0]})); + assert_eq!(run(&mut s, "links.placementOptions", json!({})), json!({"placement": {"preserve": "bounds", "align": "center", "clip": false}})); + let r = run(&mut s, "links.placementOptions", json!({"preserve": "fill", "align": "topRight", "clip": true})); + assert_eq!(r, json!({"ids": [id.0], "placement": {"preserve": "fill", "align": "topRight", "clip": true}})); + assert!(s.execute("links.placementOptions", &json!({"preserve": "stretch"})).is_err()); + assert!(s.execute("links.placementOptions", &json!({"align": "middle"})).is_err()); + // Saved and opened again. + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + assert_eq!( + saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["placement"], + json!({"preserve": "fill", "align": "topRight", "clip": true}) + ); + open(&mut s, &doc_path); + let want = PlacementOptions { preserve: vectorcraft_doc::links::Preserve::Fill, align: vectorcraft_doc::links::Align::TopRight, clip: true }; + assert_eq!(image(&s, id).placement, want); + run(&mut s, "select.all", json!({})); + run(&mut s, "links.placementOptions", json!({"preserve": "bounds", "align": "center", "clip": false})); + save(&mut s, &doc_path); + let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + assert_eq!(saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["placement"], Value::Null, "the default isn't written"); +} + +#[test] +fn the_links_commands_are_reachable_headless() { + for id in [ + "links.list", + "links.goTo", + "links.embed", + "links.unembed", + "links.info", + "links.placementOptions", + "links.relink", + "links.update", + "links.check", + ] { + let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} is not a command")); + assert!(!c.params.is_empty(), "{id} documents its params"); + } + // Without a document every one says why instead of failing silently. + let mut s = Session::new(); + assert!(s.execute("links.list", &json!({})).is_err()); +} diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests_pdfraster.rs index bf10064f..a6a63000 100644 --- a/crates/engine/src/tests_pdfraster.rs +++ b/crates/engine/src/tests_pdfraster.rs @@ -101,7 +101,10 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() { let mut d = Document::new(200.0, 200.0); d.images.insert("pic".into(), ImageBlob::new("image/png", png(40, 30))); let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5); - let im = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None })); + let im = Node::new( + d.alloc_id(), + NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() }), + ); add(&mut d, with(im, shadow())); assert_written("image", &d); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 0fed0cd4..5c50878b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -451,7 +451,7 @@ mod tests { d.images.insert("k".into(), ImageBlob::new("image/png", vec![1, 2, 3, 250])); // Only images in use are saved. let img = d.alloc_id(); - let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None }; + let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; d.insert(Some(l), 1, Node::new(img, NodeKind::Image(im))).unwrap(); let bytes = save(&d, true); assert!(sniff(&bytes)); diff --git a/crates/format/tests/links.rs b/crates/format/tests/links.rs index 82d0ad20..e65685ef 100644 --- a/crates/format/tests/links.rs +++ b/crates/format/tests/links.rs @@ -12,7 +12,7 @@ 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 }; + let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default() }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); } diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index 3e4fc2ee..ecd94493 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -33,7 +33,10 @@ fn blob(bytes: &[u8]) -> ImageBlob { fn image_node(d: &mut Document, key: &str) -> Node { let id = d.alloc_id(); - Node::new(id, NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None })) + Node::new( + id, + NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + ) } fn opts(version: u32, compress: bool) -> SaveOptions { diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index ef41d467..ca591d1e 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -75,7 +75,7 @@ proptest! { d.images.insert(key.clone(), ImageBlob::new("image/png", data.clone())); let layer = d.layers[0].id; let id = d.alloc_id(); - let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None }; + let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); for pretty in [false, true] { let back = load(&save(&d, pretty)).unwrap(); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 754c7136..0edd1d9f 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -404,7 +404,7 @@ impl<'p> Builder<'p> { (k, w, h) }; let id = self.id(); - self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None }))); + self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }))); } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index dbbe1ecf..0f97c5ad 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -366,7 +366,14 @@ fn tiny_png() -> Vec { fn image_roundtrip() { let mut d = doc(200.0, 200.0); 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 }; + let im = ImageObject { + key: "img1".into(), + width: 4, + height: 2, + xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), + link: None, + placement: Default::default(), + }; add(&mut d, Node::new(NodeId(0), NodeKind::Image(im))); let out = roundtrip(&d); let l = leaves(&out); diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs index c65742ac..596984cc 100644 --- a/crates/pdf/src/tests_compression.rs +++ b/crates/pdf/src/tests_compression.rs @@ -27,7 +27,10 @@ fn doc(bytes: Vec, mime: &str) -> Document { let mut d = Document::new(100.0, 100.0); d.images.insert("img".into(), ImageBlob::new(mime, bytes)); let xf = Affine::scale(72.0 / 600.0); - let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None })); + let n = Node::new( + d.alloc_id(), + NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() }), + ); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 11aa720f..1eda1150 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -114,7 +114,10 @@ fn placed_images_are_separated_not_blackened() { let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); 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 })); + let n = Node::new( + d.alloc_id(), + NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + ); add(&mut d, n); let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1); let cms = vectorcraft_color::cms::active(); diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 63ca53d1..1026829e 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -49,7 +49,14 @@ fn image_doc(effects: Vec) -> Document { 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 }), + NodeKind::Image(ImageObject { + key: "red".into(), + width: 30, + height: 30, + xf: Affine::translate((20.0, 20.0)), + link: None, + placement: Default::default(), + }), ); n.appearance.effects = effects; doc_with(d, n) diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests_setup.rs index 52647eec..d64ffcdd 100644 --- a/crates/render/src/tests_setup.rs +++ b/crates/render/src/tests_setup.rs @@ -10,7 +10,7 @@ fn doc_with_red_image() -> Document { 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::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 }; + let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default() }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); d } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 1c5e367f..83da2c12 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -1019,7 +1019,7 @@ impl Importer { if link.is_some() && !missing { blob.with_proxy() } else { blob } }); 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 }))) + Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() }))) } /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. diff --git a/crates/svg/tests/options.rs b/crates/svg/tests/options.rs index 8079569a..1a8737a3 100644 --- a/crates/svg/tests/options.rs +++ b/crates/svg/tests/options.rs @@ -55,7 +55,7 @@ fn image_doc(key: &str, bytes: Vec) -> Document { let mut d = Document::new(100.0, 100.0); 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 im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }; let n = Node::new(d.alloc_id(), NodeKind::Image(im)); d.insert(Some(l), usize::MAX, n).unwrap(); d diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 045732f8..26509408 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -340,7 +340,14 @@ fn image_roundtrip() { let id = d.alloc_id(); let n = Node::new( id, - NodeKind::Image(ImageObject { key: "k".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0), link: None }), + NodeKind::Image(ImageObject { + key: "k".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0), + link: None, + placement: Default::default(), + }), ); let mut d = doc_with(vec![n]); d.images.insert("k".into(), ImageBlob::new("image/png", png.clone())); diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index a094f904..6a0c62fa 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -199,7 +199,14 @@ mod tests { let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); d.images.insert("px".into(), ImageBlob::png(png)); - let im = ImageObject { key: "px".into(), width: 3, height: 1, xf: vectorcraft_geom::Affine::scale(10.0), link: None }; + let im = ImageObject { + key: "px".into(), + width: 3, + height: 1, + xf: vectorcraft_geom::Affine::scale(10.0), + link: None, + placement: Default::default(), + }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); let (s, p) = (Selection::default(), paint()); let mut cx = cx(&d, &s, &p); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 1662a834..317becf3 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -36,6 +36,7 @@ pub mod office_export; mod path_ops; pub mod pdf_presets; pub mod place; +pub mod placement_options; mod png_options; pub mod raster_effects; pub mod recolor; @@ -209,6 +210,7 @@ registry! { TextExport: [text_export::KIND] => text_export::SPEC, OfficeExport: [office_export::KIND] => office_export::SPEC, DxfOptions: [dxf_options::KIND] => dxf_options::SPEC, + PlacementOptions: [placement_options::KIND] => placement_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/placement_options.rs b/crates/ui-egui/src/dialogs/placement_options.rs new file mode 100644 index 00000000..3d5092e2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/placement_options.rs @@ -0,0 +1,75 @@ +//! Links panel → Placement Options: how a file read again (Relink, Update Link) takes the place of +//! the selected images: what it preserves, where it aligns in the old bounds, and whether it is +//! clipped to them. +//! +//! Fields: `ids` (the images; null: the selected ones), `preserve` (transforms|bounds|fileDimensions|fit|fill), `align` +//! (topLeft … bottomRight) and `clip`. OK runs `links.placementOptions` with them: one undo step. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use super::form; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +pub(super) const KIND: &str = "placementOptions"; +const CMD: &str = "links.placementOptions"; +/// (value, label) of each Preserve choice. +const PRESERVE: [(&str, &str); 5] = [ + ("transforms", "Transforms"), + ("bounds", "Bounds"), + ("fileDimensions", "File Dimensions"), + ("fit", "Bounds (Fit Proportionally)"), + ("fill", "Bounds (Fill Proportionally)"), +]; +/// The alignment points, row by row from the top left (the reference point widget's order). +const ALIGN: [&str; 9] = ["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"]; +const LABEL: f32 = 90.0; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Placement Options".into(), body, confirm, min_width: 400.0, max_width: Some(420.0), ..DialogSpec::FORM }; + +/// Open the dialog on the placement options of images `ids` (default: the selected ones). +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let ids = p.get("ids").cloned().unwrap_or(Value::Null); + let r = app.session.execute(CMD, &json!({ "ids": ids })).map_err(|e| e.to_string())?; + let mut fields = r["placement"].clone(); + fields["ids"] = ids; + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = app.run(CMD, Value::Object(d.fields.clone())); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + form::choice(ui, d, "preserve", "Preserve:", (LABEL, 240.0), &PRESERVE); + ui.add_space(8.0); + // Bounds stretches the art into the old bounds: nothing to align or clip. + let free = d.str("preserve") != "bounds"; + ui.add_enabled_ui(free, |ui| { + widgets::label_row(ui, "Alignment:", LABEL, |ui| { + let current = ALIGN.iter().position(|a| *a == d.str("align")).unwrap_or(4); + if let Some(i) = widgets::reference_point(ui, current) + && let Some(a) = ALIGN.get(i) + { + d.fields.insert("align".into(), json!(a)); + } + }); + ui.add_space(8.0); + widgets::label_row(ui, "", LABEL, |ui| { + let clip = d.bool("clip"); + if widgets::check(ui, "Clip to Bounding Box", clip, free) { + d.fields.insert("clip".into(), json!(!clip)); + } + }); + }); + ui.add_space(6.0); + widgets::dim_label(ui, "Applies when the linked file is relinked or updated."); + false +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 1a95dd61..98dc0bb7 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -479,10 +479,20 @@ pub fn ask_revert(app: &mut VectorcraftApp) -> Result { /// File → Show in Folder: the document's file in the system file manager. pub fn reveal(app: &mut VectorcraftApp) -> Result { let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?; - app.services.reveal.as_mut().ok_or("no file manager here")?(&path)?; + reveal_path(app, &path)?; Ok(json!({ "path": path })) } +/// Show the file at `path` in the system file manager (desktop). +pub(crate) fn reveal_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { + app.services.reveal.as_mut().ok_or("no file manager here")?(path) +} + +/// Open `path` (a file or a folder) in the system's default app for it (desktop). +pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { + app.services.open_file.as_mut().ok_or("no app to open files here")?(path) +} + /// Place a file's bytes (no path, so embedded) centred in the view: `file.place`. pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a2a51da..6c8c496a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,7 +102,7 @@ pub type DownloadFn = Box; /// Opens a URL in the system browser. pub type OpenUrlFn = Box; -/// Shows a file in the system file manager. +/// Shows a file in the system file manager, or opens it in its app. pub type RevealFn = Box Result<(), String>>; /// Platform services injected by the host app (desktop or web). @@ -141,6 +141,11 @@ pub struct Services { /// Write a file from any thread (desktop): lets Background Save and Export write off the UI /// thread ([`background`]). Without it they run at once. pub write_shared: Option, + /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show + /// Package). Without it (the web) those answer with an error. + pub open_file: Option, + /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). + pub pick_folder: Option Option>>, } /// Cached canvas raster. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 903df7b4..d112e12d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -431,6 +431,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", ), + ( + "links.editOriginal", + "Edit Original", + "", + "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original) → {path}; edits saved there show after links.update", + ), + ( + "links.reveal", + "Show in Folder", + "", + "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}", + ), + ( + "ui.placementOptionsDialog", + "Placement Options…", + "", + "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -804,6 +822,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::links::open_file(app, p, false), + "links.reveal" => crate::panels::links::open_file(app, p, true), + "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), _ => return None, }; Some(r) @@ -1031,10 +1052,19 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.spotColors" => app.session.active().is_some(), "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.dxfOptionsDialog" => app.session.active().is_some(), + "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()), + "ui.placementOptionsDialog" => selected_image(app, |_| true), _ => true, } } +/// Is an image `keep` accepts selected? +fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool { + app.session.active().is_some_and(|st| { + st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(&n.kind, vectorcraft_doc::NodeKind::Image(im) if keep(im)))) + }) +} + pub fn menu_tree() -> Vec<(&'static str, Vec)> { let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id })); vec![ @@ -1154,7 +1184,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Saturate…", "ui.saturateDialog"), ], ), - todo("Edit Original"), + c("Edit Original", "links.editOriginal"), Sep, c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), todo("Print Presets…"), @@ -1604,7 +1634,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Info", "info"), panel("Layers", "layers"), panel("Libraries", "libraries"), - todo("Links"), + panel("Links", crate::panels::links::ID), panel("Magic Wand", "magicWand"), panel("Navigator", "navigator"), panel("Pathfinder", "pathfinder"), diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index ab959fd9..baad743c 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -28,7 +28,7 @@ const OBJECT_LABELS: [(&str, &str); 16] = [ ("guides", "Guides"), ]; -fn row(ui: &mut Ui, label: &str, value: String) { +pub(crate) fn row(ui: &mut Ui, label: &str, value: String) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.label(egui::RichText::new(label).size(12.0).color(t.text_dim)); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 802734b7..2a86bbe3 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -337,7 +337,7 @@ fn has_styled_target(n: &Node) -> bool { /// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc` /// allocation, so the address is a free change detector). Only rendered for visible rows. -fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool { +pub(crate) fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool { use std::cell::RefCell; use std::collections::HashMap; thread_local! { diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs new file mode 100644 index 00000000..617aa9ad --- /dev/null +++ b/crates/ui-egui/src/panels/links.rs @@ -0,0 +1,464 @@ +//! Links panel: every image in the document, linked files with their status (missing, modified) +//! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects +//! its image; the buttons Relink, Go To, Update and Edit Original act on the selected rows; Link +//! Info shows the details of the first. The flyout adds Relink to Folder, Embed / Unembed, +//! Placement Options, Show filters, Sort, thumbnail size and Show in Folder. +//! +//! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI +//! commands: they hand the file to the system). + +use egui::{Sense, Ui, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use super::{pstate, set_pstate}; +use crate::theme::{self, Tokens}; +use crate::widgets::{self, menu_item}; +use crate::{VectorcraftApp, icons}; + +pub const ID: &str = "links"; +/// Seconds between two looks at the linked files (they change outside the app). +const RECHECK: f64 = 2.0; +const SHOW: [(&str, &str); 4] = [("all", "Show All"), ("missing", "Show Missing"), ("modified", "Show Modified"), ("embedded", "Show Embedded")]; +const SORT: [(&str, &str); 3] = [("name", "Sort by Name"), ("kind", "Sort by Kind"), ("status", "Sort by Status")]; +const THUMBS: [(f32, &str); 4] = [(0.0, "No Thumbnails"), (18.0, "Small Thumbnails"), (28.0, "Medium Thumbnails"), (44.0, "Large Thumbnails")]; + +/// The panel's options (flyout). +#[derive(Clone)] +struct Options { + show: &'static str, + sort: Option<&'static str>, + /// Thumbnail side (pt); 0 hides them. + thumb: f32, + /// The Link Info section is open. + info: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { show: "all", sort: None, thumb: 28.0, info: true } + } +} + +/// `links.list` and the first selected image's `links.info`, with what they were fetched for. +#[derive(Clone, Default)] +struct Cache { + /// (document uid, revision, show, sort). + key: Option<(u64, u64, &'static str, Option<&'static str>)>, + at: f64, + list: Value, + info: Option<(u64, Value)>, +} + +fn options(ctx: &egui::Context) -> Options { + pstate(ctx, "links-options") +} + +fn set_options(ctx: &egui::Context, f: impl FnOnce(&mut Options)) { + let mut o = options(ctx); + f(&mut o); + set_pstate(ctx, "links-options", o); +} + +/// The rows to show (cached per document revision, the files looked at again every [`RECHECK`] +/// seconds) and the Link Info of the first selected image when the section is open. +fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option { + let st = app.session.active()?; + let key = Some((st.uid, st.revision, o.show, o.sort)); + let first = selected_images(app).first().copied(); + let now = ctx.input(|i| i.time); + let mut c: Cache = pstate(ctx, "links-cache"); + let stale = c.key != key || now - c.at > RECHECK; + if stale { + c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort })).ok()?; + (c.key, c.at) = (key, now); + } + let want = first.filter(|_| o.info); + if stale || c.info.as_ref().map(|(id, _)| *id) != want { + c.info = want.and_then(|id| Some((id, app.session.execute("links.info", &json!({ "id": id })).ok()?))); + } + set_pstate(ctx, "links-cache", c.clone()); + // Files change on disk without a frame of ours: look again later. + ctx.request_repaint_after(std::time::Duration::from_secs_f64(RECHECK)); + Some(c) +} + +/// The ids of the selected image objects. +fn selected_images(app: &VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let image = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))); + st.selection.objects.iter().filter(|id| image(id)).map(|id| id.0).collect() +} + +/// The rows of `list` whose image is selected. +fn selected_rows<'a>(list: &'a Value, selected: &[u64]) -> Vec<&'a Value> { + list["links"].as_array().into_iter().flatten().filter(|r| r["id"].as_u64().is_some_and(|id| selected.contains(&id))).collect() +} + +/// The ids of `rows` that `keep` accepts. +fn ids(rows: &[&Value], keep: impl Fn(&Value) -> bool) -> Vec { + rows.iter().filter(|r| keep(r)).filter_map(|r| r["id"].as_u64()).collect() +} + +fn status_is(r: &Value, status: &str) -> bool { + r["status"].as_str() == Some(status) +} + +fn report(app: &mut VectorcraftApp, r: Result, done: impl FnOnce(&Value) -> String) { + let msg = match r { + Ok(v) => done(&v), + Err(e) => e, + }; + app.status(msg); +} + +/// Relink images `ids` to a picked file. +pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec) { + let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; + let Some(path) = app.services.pick_open.as_mut().and_then(|f| f(&pick)) else { return }; + let r = app.run("links.relink", json!({ "ids": ids, "path": path })); + report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len))); +} + +/// Relink images `ids` (none: every missing one) to the files of their names in a picked folder. +fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec) { + let Some(folder) = app.services.pick_folder.as_mut().and_then(|f| f()) else { return }; + let ids = Some(ids).filter(|i| !i.is_empty()); + let r = app.run("links.relink", json!({ "ids": ids, "folder": folder })); + report(app, r, |v| { + let missed: Vec<&str> = v["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + let n = v["relinked"].as_array().map_or(0, Vec::len); + if missed.is_empty() { format!("Relinked {n} image(s)") } else { format!("Relinked {n} image(s); not in that folder: {}", missed.join(", ")) } + }); +} + +/// Select image `id` and scroll it into view. +pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) { + let Ok(r) = app.run("links.goTo", json!({ "id": id })) else { return }; + let b: Vec = r["bounds"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); + if let (&[x0, y0, x1, y1], Some(v)) = (&b[..], app.view_mut()) { + v.center = vectorcraft_geom::Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); + v.fitted = true; + } +} + +/// Write embedded image `id` to a picked file (the web downloads it) and link it there. +pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) { + if let Some(dl) = app.services.download.as_mut() { + if let Ok(v) = app.session.execute("links.unembed", &json!({ "id": id })) + && let (Some(name), Some(bytes)) = (v["name"].as_str(), v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode)) + { + dl(name, &bytes); + } + return; + } + let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named(name))) else { return }; + let r = app.run("links.unembed", json!({ "id": id, "path": path })); + report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default())); +} + +/// The UI commands `links.editOriginal` (open the linked file in its app) and `links.reveal` +/// (show it in its folder) for image `id` (default: the selected linked image) → `{path}`. +pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Result { + let id = p.get("id").and_then(Value::as_u64).or_else(|| { + let list = app.session.execute("links.list", &json!({})).ok()?; + let sel = selected_images(app); + ids(&selected_rows(&list, &sel), |r| r["linked"] == true).first().copied() + }); + let info = app.session.execute("links.info", &json!({ "id": id })).map_err(|e| e.to_string())?; + let path = info["path"].as_str().ok_or("select a linked image: an embedded one has no file")?.to_string(); + if info["status"] == "missing" { + return Err(format!("{path} can't be found: relink it first")); + } + if reveal { crate::io::reveal_path(app, &path) } else { crate::io::open_in_app(app, &path) }?; + Ok(json!({ "path": path })) +} + +/// A status badge: missing (red, ?), modified (amber, !), embedded (the image icon). +fn badge(ui: &Ui, r: egui::Rect, status: &str) { + let t = Tokens::get(ui.ctx()); + let c = r.center(); + let p = ui.painter(); + let font = theme::semibold(10.0); + match status { + "missing" => { + p.circle_filled(c, 6.5, t.error); + p.text(c, egui::Align2::CENTER_CENTER, "?", font, egui::Color32::WHITE); + } + "modified" => { + let pts = vec![c + vec2(0.0, -7.0), c + vec2(7.0, 6.0), c + vec2(-7.0, 6.0)]; + p.add(egui::Shape::convex_polygon(pts, t.warning, egui::Stroke::NONE)); + p.text(c + vec2(0.0, 1.5), egui::Align2::CENTER_CENTER, "!", font, egui::Color32::BLACK); + } + "embedded" => icons::paint(ui, "image", egui::Rect::from_center_size(c, vec2(13.0, 13.0)), t.text_dim), + _ => {} + } +} + +fn status_tip(r: &Value) -> String { + let path = r["found"].as_str().or(r["path"].as_str()).unwrap_or_default(); + match r["status"].as_str().unwrap_or_default() { + "missing" => format!("Missing: {path}"), + "modified" => format!("Modified since it was read: {path}"), + "embedded" => "Embedded".into(), + _ => path.to_string(), + } +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let o = options(ui.ctx()); + let Some(cache) = cached(app, ui.ctx(), &o) else { + widgets::dim_label(ui, "No document"); + return; + }; + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return }; + let selected = selected_images(app); + let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice); + let mut clicked: Option<(u64, bool, bool)> = None; + widgets::list_box(ui, |ui| { + ui.set_min_height(110.0); + ui.set_width(ui.available_width()); + if rows.is_empty() { + let detail = + if o.show == "all" { "File › Place… brings in an image, linked or embedded." } else { "Nothing matches the Show filter." }; + super::empty_state(ui, "link", "No images", detail); + return; + } + egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, true]).show(ui, |ui| { + let h = o.thumb.max(16.0) + 6.0; + for r in rows { + let Some(id) = r["id"].as_u64() else { continue }; + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); + if !ui.is_rect_visible(rect) { + continue; + } + if selected.contains(&id) { + ui.painter().rect_filled(rect, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, t.hover); + } + let mut x = rect.left() + 6.0; + if o.thumb > 0.0 { + let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb)); + ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE); + if let Some(n) = doc.node(NodeId(id)) { + super::layers::real_thumb(ui, &doc, n, tr); + } + ui.painter().rect_stroke(tr, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + x = tr.right() + 6.0; + } + let right = rect.right() - 22.0; + badge( + ui, + egui::Rect::from_center_size(egui::pos2(right + 10.0, rect.center().y), vec2(16.0, 16.0)), + r["status"].as_str().unwrap_or_default(), + ); + let mut text_right = right - 4.0; + if let Some(page) = r["page"].as_u64() { + let g = ui.painter().layout_no_wrap(format!("{page}"), egui::FontId::proportional(11.5), t.text_dim); + text_right -= g.size().x; + ui.painter().galley(egui::pos2(text_right, rect.center().y - g.size().y / 2.0), g, t.text_dim); + text_right -= 6.0; + } + let name = r["name"].as_str().unwrap_or_default(); + let clip = egui::Rect::from_min_max(egui::pos2(x, rect.top()), egui::pos2(text_right.max(x), rect.bottom())); + ui.painter().with_clip_rect(clip).text( + egui::pos2(x, rect.center().y), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(12.5), + t.text, + ); + let resp = resp.on_hover_text(status_tip(r)); + if resp.double_clicked() { + clicked = Some((id, false, true)); + } else if resp.clicked() { + clicked = Some((id, ui.input(|i| i.modifiers.shift || i.modifiers.command), false)); + } + } + }); + }); + match clicked { + Some((id, _, true)) => go_to(app, id), + Some((id, true, _)) => { + app.run("select.toggle", json!({ "id": id })).ok(); + } + Some((id, false, _)) => { + app.run("select.set", json!({ "ids": [id] })).ok(); + } + None => {} + } + if o.info { + link_info(ui, cache.info.as_ref().map(|(_, i)| i)); + } + let sel = selected_rows(&cache.list, &selected); + let modified = ids(&sel, |r| status_is(r, "modified") || r["preview"] == true && !status_is(r, "missing")); + let can_edit = sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")); + widgets::bottom_bar(ui, |ui| { + let info = !o.info; + if widgets::icon_button(ui, if o.info { "chevron-down" } else { "chevron-right" }, "Show Link Info", false, 24.0).clicked() { + set_options(ui.ctx(), |o| o.info = info); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::icon_button_enabled(ui, "pencil", "Edit Original", false, can_edit, 24.0).clicked() { + crate::menus::invoke(app, "links.editOriginal", json!({})); + } + if widgets::icon_button_enabled(ui, "rotate-cw", "Update Link", false, !modified.is_empty(), 24.0).clicked() { + let r = app.run("links.update", json!({ "ids": modified })); + report(app, r, |v| format!("Updated {} image(s)", v["updated"].as_array().map_or(0, Vec::len))); + } + if widgets::icon_button_enabled(ui, "zoom-in", "Go To Link", false, sel.len() == 1, 24.0).clicked() + && let Some(id) = sel.first().and_then(|r| r["id"].as_u64()) + { + go_to(app, id); + } + if widgets::icon_button_enabled(ui, "link", "Relink…", false, !sel.is_empty() && can_pick(app), 24.0).clicked() { + relink(app, ids(&sel, |_| true)); + } + }); + }); +} + +/// Pick-a-file actions need a file dialog (the desktop app). +fn can_pick(app: &VectorcraftApp) -> bool { + app.services.pick_open.is_some() +} + +/// Bytes as B, KB or MB. +fn size_label(bytes: u64) -> String { + match bytes { + b if b < 1024 => format!("{b} B"), + b if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0), + b => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)), + } +} + +/// Milliseconds since the Unix epoch as a UTC date and time. +fn date_label(ms: u64) -> String { + let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(i64::try_from(ms / 1000).unwrap_or(i64::MAX)); + format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02} UTC") +} + +/// The Link Info section: the details of `info` (`links.info`), or a hint. +fn link_info(ui: &mut Ui, info: Option<&Value>) { + widgets::divider(ui); + let Some(i) = info else { + widgets::dim_label(ui, "Select an image to see its Link Info."); + return; + }; + let pair = |k: &str| { + let n: Vec = i[k].as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); + match n[..] { + [x, y] if (x - y).abs() < 0.05 => format!("{x:.0}"), + [x, y] => format!("{x:.0} × {y:.0}"), + _ => String::new(), + } + }; + let row = super::doc_info::row; + row(ui, "Name", i["fileName"].as_str().or(i["name"].as_str()).unwrap_or_default().into()); + row(ui, "Format", i["format"].as_str().unwrap_or_default().into()); + row(ui, "Color Space", i["colorMode"].as_str().unwrap_or_default().into()); + if let Some(l) = i["location"].as_str() { + row(ui, "Location", l.into()); + } + row(ui, "PPI", pair("ppi")); + row(ui, "Effective PPI", pair("effectivePpi")); + row(ui, "Dimensions", format!("{} × {} px", i["pixelWidth"], i["pixelHeight"])); + let scale: Vec = i["scale"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); + if let [x, y] = scale[..] { + row(ui, "Scale", format!("H: {x:.1}% V: {y:.1}%")); + } + row(ui, "Rotation", format!("{:.1}°", i["rotation"].as_f64().unwrap_or(0.0))); + if let Some(b) = i["fileSize"].as_u64() { + row(ui, "Size", size_label(b)); + } + for (k, label) in [("created", "Created"), ("modified", "Modified")] { + if let Some(ms) = i[k].as_u64() { + row(ui, label, date_label(ms)); + } + } + if let Some(p) = i["page"].as_u64() { + row(ui, "Page", p.to_string()); + } + let status = match i["status"].as_str().unwrap_or_default() { + "ok" => "Linked", + "missing" => "Missing", + "modified" => "Modified", + _ => "Embedded", + }; + row(ui, "Status", status.into()); +} + +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let o = options(ui.ctx()); + // The rows the panel shows (the menu opens from it). + let list = pstate::(ui.ctx(), "links-cache").list; + let selected = selected_images(app); + let sel = selected_rows(&list, &selected); + let linked = ids(&sel, |r| r["linked"] == true); + let embedded: Vec<&Value> = sel.iter().copied().filter(|r| status_is(r, "embedded")).collect(); + let any = !sel.is_empty(); + let missing_any = list["missing"].as_u64().unwrap_or(0) > 0; + if menu_item(ui, "Relink…", any && can_pick(app), false) { + relink(app, ids(&sel, |_| true)); + } + if menu_item(ui, "Relink to Folder…", (any || missing_any) && app.services.pick_folder.is_some(), false) { + relink_to_folder(app, ids(&sel, |_| true)); + } + if menu_item(ui, "Go To Link", sel.len() == 1, false) + && let Some(id) = sel.first().and_then(|r| r["id"].as_u64()) + { + go_to(app, id); + } + if menu_item(ui, "Update Link", sel.iter().any(|r| status_is(r, "modified")), false) { + let r = app.run("links.update", json!({ "ids": ids(&sel, |r| status_is(r, "modified")) })); + report(app, r, |v| format!("Updated {} image(s)", v["updated"].as_array().map_or(0, Vec::len))); + } + if menu_item(ui, "Edit Original", sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { + crate::menus::invoke(app, "links.editOriginal", json!({})); + } + ui.separator(); + if menu_item(ui, "Embed Image(s)", !linked.is_empty(), false) { + let r = app.run("links.embed", json!({ "ids": linked })); + report(app, r, |v| { + let missing = v["missing"].as_array().map_or(0, Vec::len); + let n = v["embedded"].as_array().map_or(0, Vec::len); + if missing == 0 { format!("Embedded {n} image(s)") } else { format!("Embedded {n} image(s); {missing} missing: relink them first") } + }); + } + if menu_item(ui, "Unembed…", embedded.len() == 1, false) + && let Some(r) = embedded.first() + && let Some(id) = r["id"].as_u64() + { + let name = r["name"].as_str().unwrap_or("Image").to_string(); + unembed(app, id, &name); + } + if menu_item(ui, "Placement Options…", any, false) { + crate::menus::invoke(app, "ui.placementOptionsDialog", json!({ "ids": ids(&sel, |_| true) })); + } + if menu_item(ui, "Show in Folder", sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { + crate::menus::invoke(app, "links.reveal", json!({})); + } + ui.separator(); + for (id, label) in SHOW { + if menu_item(ui, label, true, o.show == id) { + set_options(ui.ctx(), |o| o.show = id); + } + } + ui.separator(); + for (id, label) in SORT { + if menu_item(ui, label, true, o.sort == Some(id)) { + set_options(ui.ctx(), |o| o.sort = if o.sort == Some(id) { None } else { Some(id) }); + } + } + ui.separator(); + for (px, label) in THUMBS { + if menu_item(ui, label, true, o.thumb == px) { + set_options(ui.ctx(), |o| o.thumb = px); + } + } + if menu_item(ui, "Show Link Info", true, o.info) { + set_options(ui.ctx(), |o| o.info = !o.info); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 198cf910..2cc7a194 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -23,6 +23,7 @@ pub mod image_trace; pub mod info; pub mod layers; pub mod library_panel; +pub mod links; pub mod magic_wand; pub mod navigator; pub mod opentype; @@ -94,6 +95,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { flattener_preview::ID => flattener_preview::show(app, ui), "attributes" => attributes::show(app, ui), "colorThemes" => color_themes::show(app, ui), + links::ID => links::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -136,6 +138,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "attributes" => attributes::menu(app, ui), "colorThemes" => color_themes::menu(app, ui), "layers" => layers::menu(app, ui), + links::ID => links::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } @@ -446,6 +449,8 @@ mod tests_effectedit; #[cfg(test)] mod tests_freeform; #[cfg(test)] +mod tests_links; +#[cfg(test)] mod tests_maskview; #[cfg(test)] mod tests_stroke; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 1d9d553c..e200e9fb 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -30,6 +30,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } transform_section(app, ui); divider(ui); + if n_sel == 1 && matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Image(_))) { + image_section(app, ui); + divider(ui); + } if matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Text(_))) { type_sections(app, ui); divider(ui); @@ -109,6 +113,46 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The one selected image: its file (or Embedded), colour mode and resolution, and the Links +/// panel's actions for it. +fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(i) = crate::place::selected_image_info(app) else { return }; + let (name, details) = crate::place::image_summary(i); + let (Some(id), linked) = (i["id"].as_u64(), i["linked"] == true) else { return }; + section_header(ui, if linked { "Linked File" } else { "Embedded Image" }); + ui.add(egui::Label::new(egui::RichText::new(&name).size(12.0).color(t.text_strong)).truncate()); + dim_label(ui, &details); + ui.add_space(4.0); + let w = (ui.available_width() - 6.0) / 2.0; + let links = crate::panels::links::ID; + ui.horizontal(|ui| { + if linked { + if widgets::flat_button(ui, "Embed", w).clicked() { + app.run("links.embed", json!({ "ids": [id] })).ok(); + } + if widgets::flat_button(ui, "Edit Original", w).clicked() { + crate::menus::invoke(app, "links.editOriginal", json!({ "id": id })); + } + } else { + if widgets::flat_button(ui, "Unembed…", w).clicked() { + crate::panels::links::unembed(app, id, &name); + } + if widgets::flat_button(ui, "Image Trace", w).clicked() { + app.ui.open_panel = Some("imageTrace".into()); + } + } + }); + ui.horizontal(|ui| { + if app.services.pick_open.is_some() && widgets::flat_button(ui, "Relink…", w).clicked() { + crate::panels::links::relink(app, vec![id]); + } + if widgets::flat_button(ui, "Links", w).clicked() { + app.ui.open_panel = Some(links.into()); + } + }); +} + /// Whether the selection uses more than one colour (`recolor.colors`), which offers the Recolor /// quick action; cached per document revision. fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs new file mode 100644 index 00000000..f27a42b5 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -0,0 +1,154 @@ +//! The Links panel, its flyout, Link Info, Edit Original / Show in Folder, the Placement Options +//! dialog and the Properties panel's image section, drawn headless. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; +use crate::tests_labels::painted_text; + +/// A fresh folder for one test (removed when dropped). +struct Folder(std::path::PathBuf); + +impl Folder { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("vectorcraft-ui-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, bytes: &[u8]) -> String { + let p = self.0.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().into_owned() + } +} + +impl Drop for Folder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn png(w: u32, h: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out +} + +/// A document with a linked `linked.png` and an embedded `embedded.png` (the selection). +fn app(dir: &Folder) -> (VectorcraftApp, u64, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let a = dir.file("linked.png", &png(300, 200)); + let b = dir.file("embedded.png", &png(40, 20)); + let linked = app.run("file.place", json!({"path": a, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + let embedded = app.run("file.place", json!({"path": b, "at": [300, 200], "link": false})).unwrap()["ids"][0].as_u64().unwrap(); + (app, linked, embedded) +} + +fn panel(app: &mut VectorcraftApp) -> String { + painted_text(app, super::links::show) +} + +#[test] +fn the_panel_lists_the_images_with_link_info_for_the_selected_one() { + let dir = Folder::new("panel"); + let (mut app, linked, _) = app(&dir); + let text = panel(&mut app); + assert!(text.contains("linked.png") && text.contains("embedded.png"), "{text}"); + // The selection is the embedded image. + assert!(text.contains("Embedded") && text.contains("Effective PPI"), "{text}"); + app.run("select.set", json!({"ids": [linked]})).unwrap(); + let text = panel(&mut app); + assert!(text.contains("Linked") && text.contains("300 × 200 px") && text.contains("Location"), "{text}"); + // The flyout draws with every item. + let menu = painted_text(&mut app, super::links::menu); + for item in ["Relink to Folder…", "Embed Image(s)", "Placement Options…", "Show Missing", "Sort by Status", "Large Thumbnails", "Show in Folder"] + { + assert!(menu.contains(item), "{item}: {menu}"); + } +} + +#[test] +fn an_empty_document_shows_how_to_add_images() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + assert!(panel(&mut app).contains("No images")); +} + +#[test] +fn edit_original_and_show_in_folder_hand_the_file_to_the_system() { + let dir = Folder::new("open"); + let (mut app, linked, embedded) = app(&dir); + let opened: Rc>> = Rc::default(); + let (o, r) = (opened.clone(), opened.clone()); + app.services.open_file = Some(Box::new(move |p: &str| { + o.borrow_mut().push((p.to_string(), false)); + Ok(()) + })); + app.services.reveal = Some(Box::new(move |p: &str| { + r.borrow_mut().push((p.to_string(), true)); + Ok(()) + })); + assert!(!crate::menus::enabled(&app, "links.editOriginal"), "the embedded image is selected"); + assert!(app.run("links.editOriginal", json!({})).is_err()); + app.run("select.set", json!({"ids": [linked, embedded]})).unwrap(); + assert!(crate::menus::enabled(&app, "links.editOriginal")); + let r = app.run("links.editOriginal", json!({})).unwrap(); + let path = r["path"].as_str().unwrap().to_string(); + assert!(path.ends_with("linked.png")); + app.run("links.reveal", json!({"id": linked})).unwrap(); + assert_eq!(*opened.borrow(), [(path.clone(), false), (path, true)]); + // Without the services (the web): an error, nothing opened. + (app.services.open_file, app.services.reveal) = (None, None); + assert!(app.run("links.reveal", json!({"id": linked})).is_err()); + assert!(app.run("links.editOriginal", json!({"id": linked})).is_err()); +} + +#[test] +fn placement_options_dialog_sets_the_options() { + let dir = Folder::new("dialog"); + let (mut app, linked, _) = app(&dir); + app.run("select.set", json!({"ids": [linked]})).unwrap(); + assert!(crate::menus::enabled(&app, "ui.placementOptionsDialog")); + app.run("ui.placementOptionsDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("preserve"), d.str("align")), ("placementOptions", "bounds".into(), "center".into())); + let text = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(text.contains("Clip to Bounding Box") && text.contains("Preserve"), "{text}"); + let fields = &mut app.ui.dialog.as_mut().unwrap().fields; + fields.insert("preserve".into(), json!("fit")); + fields.insert("align".into(), json!("bottomRight")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let r = app.run("links.placementOptions", json!({"ids": [linked]})).unwrap(); + assert_eq!(r["placement"], json!({"preserve": "fit", "align": "bottomRight", "clip": false})); +} + +#[test] +fn properties_shows_the_selected_image_and_its_link_actions() { + let dir = Folder::new("props"); + let (mut app, linked, _) = app(&dir); + let text = painted_text(&mut app, super::properties::show); + assert!(text.contains("Embedded Image") && text.contains("embedded.png") && text.contains("Unembed…"), "{text}"); + app.run("select.set", json!({"ids": [linked]})).unwrap(); + let text = painted_text(&mut app, super::properties::show); + assert!(text.contains("Linked File") && text.contains("Edit Original") && text.contains("Embed"), "{text}"); +} + +#[test] +fn the_links_menu_items_run_commands() { + let tree = crate::menus::menu_tree(); + let ids = |label: &str| { + let (_, items) = tree.iter().find(|(t, _)| *t == label).unwrap(); + format!("{items:?}") + }; + assert!(ids("Edit").contains("links.editOriginal")); + assert!(ids("Window").contains(super::links::ID)); +} diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index aaa571ff..503544a5 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -335,24 +335,36 @@ pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: // ---------- the Control bar ---------- -/// The Control bar's details for the one selected image: Linked File or Embedded, its file name, -/// colour mode and effective resolution (`image.info`, fetched again only when it changes). -pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) { - let Some(st) = app.session.active() else { return }; +/// `image.info` of the one selected image, fetched again only when it changes (the Control bar's +/// and the Properties panel's image details). +pub fn selected_image_info(app: &mut VectorcraftApp) -> Option<&Value> { + let st = app.session.active()?; let key = match &st.selection.objects[..] { [id] if st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Image(_))) => (st.uid, st.revision, id.0), - _ => return, + _ => return None, }; if app.place.info.as_ref().is_none_or(|(k, _)| *k != key) { let info = app.session.execute("image.info", &json!({ "id": key.2 })).unwrap_or_default(); app.place.info = Some((key, info)); } - let Some((_, i)) = &app.place.info else { return }; - let t = Tokens::get(ui.ctx()); + app.place.info.as_ref().map(|(_, i)| i) +} + +/// An image's file name, and its colour mode and effective resolution (`image.info` `i`). +pub fn image_summary(i: &Value) -> (String, String) { let name = i["link"].as_str().map(fileio::file_name).or_else(|| i["name"].as_str().map(str::to_string)).unwrap_or_default(); + let ppi = i["ppi"].as_array().and_then(|a| a.first()).and_then(Value::as_f64).unwrap_or(0.0); + (name, format!("{} PPI: {}", i["colorMode"].as_str().unwrap_or("RGB"), fmt_ppi(ppi))) +} + +/// The Control bar's details for the one selected image: Linked File or Embedded, its file name, +/// colour mode and effective resolution. +pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + let Some(i) = selected_image_info(app) else { return }; + let (name, details) = image_summary(i); + let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(name).size(12.0).color(t.text_strong)); ui.separator(); - let ppi = i["ppi"].as_array().and_then(|a| a.first()).and_then(Value::as_f64).unwrap_or(0.0); - ui.label(egui::RichText::new(format!("{} PPI: {}", i["colorMode"].as_str().unwrap_or("RGB"), fmt_ppi(ppi))).size(12.0).color(t.text_dim)); + ui.label(egui::RichText::new(details).size(12.0).color(t.text_dim)); ui.separator(); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 691f04bb..7dce851f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -106,6 +106,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::flattener_preview::ID, "Flattener Preview", "eye"), ("attributes", "Attributes", "settings"), ("colorThemes", "Color Themes", "sun"), + (crate::panels::links::ID, "Links", "link"), ]; /// Groups of icon panels separated by dividers in the collapsed column. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4eeb1023..e4b95f72 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -85,6 +85,10 @@ pub struct Tokens { pub highlight: Color32, /// The bleed outline around artboards (Document Setup → Bleed). pub bleed: Color32, + /// Error marks (a missing linked file). + pub error: Color32, + /// Warning marks (a modified linked file). + pub warning: Color32, } impl Tokens { @@ -127,6 +131,8 @@ impl Tokens { caption_close_text: Color32::WHITE, highlight: hex(0xff3030), bleed: hex(0xf03030), + error: hex(0xe34850), + warning: hex(0xf0a330), }; match b { Brightness::Dark => base, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cae57cba..2a3c5a5e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -305,3 +305,10 @@ DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to `dwg` shows, and on confirm answers, the hint to export DXF instead. + +Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?}` for agents) opens the +`placementOptions` dialog for the selected images (fields `ids`, `preserve`: `transforms`/`bounds`/`fileDimensions`/ +`fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as +one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's +`links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to +the system and return `{path}`. diff --git a/docs/mcp.md b/docs/mcp.md index be6fef95..3754fddd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1073,3 +1073,24 @@ opened. {"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}} {"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}} ``` + +## Links panel + +`links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top +first, as the Links panel does: `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, +pixelHeight, path?, found?, page?, preview?}`. `links.info {id?}` is the Link Info: `image.info`'s fields plus +`status`, `format`, the file's `ppi` and the `effectivePpi`, `scale` (% of 100%), `rotation` (degrees, +counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`. +`links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose +file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file +(another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. +`links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, +clip?}` decides how a relinked or updated file takes an image's place (default `bounds`: stretched into the old +bounds; `clip` puts it in a clip group of the old bounds when it is larger). The UI commands `links.editOriginal` and +`links.reveal` open the linked file in its app or show it in its folder (desktop). + +```json +{"name":"run_command","arguments":{"command":"links.list","params":{"show":"missing"}}} +{"name":"run_command","arguments":{"command":"links.placementOptions","params":{"ids":[12],"preserve":"fit","align":"top"}}} +{"name":"run_command","arguments":{"command":"links.unembed","params":{"id":14,"path":"/tmp/logo.png"}}} +```