diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index c8db5897..0e93921d 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -1,6 +1,7 @@ //! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills -//! (Fill) into plain art in one undo step. A linear or radial gradient fill becomes a gradient mesh, -//! or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform +//! (Fill) into plain art in one undo step. Placed documents (Object) become editable copies of the +//! art they show, their resources joining the document. A linear or radial gradient fill becomes a +//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling. use std::sync::Arc; @@ -36,7 +37,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", has_selection, expand ), @@ -55,6 +56,21 @@ pub fn specs() -> Vec { ] } +/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it +/// shows, keeping its id and place; the resources the art uses join `d` (a name `d` uses for +/// something else gets a free variant). +fn expand_placed_under(d: &mut Document, roots: &[NodeId]) -> Result<()> { + let mut ids = vec![]; + for n in roots.iter().filter_map(|r| d.node(*r)) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::PlacedDocument(_)) { + ids.push(c.id); + } + }); + } + ids.into_iter().try_for_each(|id| super::place::document::expand(d, id, C)) +} + /// Expand Gradient To. #[derive(Clone, Copy, Debug, PartialEq)] enum GradientTo { @@ -88,6 +104,8 @@ struct Expandable { envelope: bool, /// Live blends (Object expands them into groups of their steps). blends: bool, + /// Placed documents (Object expands them into the art they show). + placed: bool, } impl Expandable { @@ -101,6 +119,7 @@ impl Expandable { e.effects |= !c.appearance.effects.is_empty(); e.envelope |= matches!(c.kind, NodeKind::Envelope { .. }); e.blends |= matches!(c.kind, NodeKind::Blend { .. }); + e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); e.stroke |= shape && !c.appearance.stroke_paint().is_none(); e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), @@ -112,7 +131,7 @@ impl Expandable { } fn object(&self) -> bool { - self.text || self.live || self.effects || self.envelope || self.blends + self.text || self.live || self.effects || self.envelope || self.blends || self.placed } } @@ -135,6 +154,11 @@ fn expand(s: &mut Session, p: &Value) -> Result { let from = s.doc()?.history.undo.len(); let rev0 = s.doc()?.revision; let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + // Placed documents first: their art (type, strokes, gradients) expands below. + if object && e.placed { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| expand_placed_under(d, &roots))?; + } // Blends first: what their steps hold (type, effects, strokes, gradients) expands below. if object && e.blends { let roots = selected_roots(s)?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 11ff3090..ed776be7 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -450,12 +450,17 @@ impl SaveJob { /// its own also ends its Data Recovery copy. pub fn complete(self, s: &mut Session, uid: u64) { let saved = self.plan.retargets() && self.plan.path.is_some(); + // The other open documents that place this one show the new version. + let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); if let Some(st) = s.document_mut(uid) { self.finish(st); } if saved { crate::cmd::recovery::forget(s, uid); } + if let Some(path) = written { + crate::cmd::links::refresh_placed(s, uid, &path); + } } } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index e9260936..575a6f26 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -16,13 +16,17 @@ //! 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`). +//! +//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their +//! files the same way: checking, updating, relinking and finding them on open work alike, and they +//! are listed. Embed (Break Link) turns one into an editable copy of its art. use std::collections::BTreeMap; use std::path::{Component, Path}; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacementOptions}; +use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions}; use vectorcraft_geom::{Affine, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -35,7 +39,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image ids] (default: every linked image)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}", + "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}", has_doc, check ), @@ -44,7 +48,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image ids] (default: the linked images whose file was modified or is showing its preview)} read their linked files again; each image keeps its bounds. One undo step → {updated: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. One undo step → {updated: [ids], missing: [ids]}", has_doc, update ), @@ -53,7 +57,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image ids] (default: the selected images; with folder and nothing selected, every missing link), path | folder} link images to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}", has_doc, relink ), @@ -62,7 +66,7 @@ pub fn specs() -> Vec { "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}", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object and placed document 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), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", has_doc, list ), @@ -71,7 +75,7 @@ pub fn specs() -> Vec { "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]}", + "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}", has_doc, go_to ), @@ -80,7 +84,7 @@ pub fn specs() -> Vec { "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]}", + "{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. One undo step → {embedded: [ids], missing: [ids]}", has_doc, embed ), @@ -98,7 +102,7 @@ pub fn specs() -> Vec { "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)", + "{id?} an image's or placed document'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); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size", has_doc, info ), @@ -107,7 +111,7 @@ pub fn specs() -> Vec { "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}", + "{ids?: [image or placed document ids] (default: the selected ones), 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 ), @@ -134,22 +138,34 @@ impl Status { } } -/// The image objects that show one linked file as one content (same path, same image). +/// The image objects that show one linked file as one content (same path, same image), or the +/// placed documents that show one artboard of it. struct Group { link: LinkInfo, key: String, /// The objects in the layers (images in symbols and patterns share the content, not an id). ids: Vec, + /// Placed documents: whether the art's bounds (not the artboard) are shown. + document: Option, } -/// The linked images of `d` by file and content: those in the layers (only `ids` when given), then, -/// without `ids`, the ones only symbols and patterns show. +/// The linked object `n` as (link, content key, placed document's bounding). +fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { + match &n.kind { + NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)), + NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))), + _ => None, + } +} + +/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when +/// given), then, without `ids`, the ones only symbols and patterns show. fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let mut out: Vec = vec![]; - let mut index: BTreeMap<(String, String), usize> = BTreeMap::new(); - let mut add = |link: &LinkInfo, key: &str, id: Option| { - let i = *index.entry((link.path.clone(), key.to_string())).or_insert_with(|| { - out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![] }); + let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); + let mut add = |link: &LinkInfo, key: &str, document: Option, id: Option| { + let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| { + out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document }); out.len() - 1 }); if let (Some(id), Some(g)) = (id, out.get_mut(i)) { @@ -157,19 +173,19 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { } }; d.walk(|n| { - if let NodeKind::Image(im) = &n.kind - && let Some(l) = &im.link + if let Some((l, key, document)) = linked(n) && ids.is_none_or(|ids| ids.contains(&n.id)) { - add(l, &im.key, Some(n.id)); + add(l, key, document, Some(n.id)); } }); if ids.is_none() { d.visit_images(|_, im| { if let Some(l) = &im.link { - add(l, &im.key, None); + add(l, &im.key, None, None); } }); + d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None)); } out } @@ -238,7 +254,7 @@ fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option) { let same = match &l.hash { Some(h) => *h == hash_bytes(&bytes), // Linked before links kept a hash: the image the file decodes to. - None => fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key), + None => g.document.is_none() && fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key), }; f.bytes = Some(bytes); (if same { Status::Ok } else { Status::Modified }, Some(f)) @@ -254,6 +270,15 @@ fn previewing(d: &Document, key: &str) -> bool { d.images.get(key).is_none_or(ImageBlob::is_proxy) } +/// Does group `g` of `d` show something other than its file: an image its preview, a placed +/// document nothing (its file's bytes are gone)? +fn stale(d: &Document, g: &Group) -> bool { + match g.document { + None => previewing(d, &g.key), + Some(_) => !d.images.contains_key(&g.key), + } +} + /// A linked file of a document (File → Package): its path, name and bytes. pub(crate) struct LinkedFile { pub path: String, @@ -301,6 +326,51 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result { Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?)) } +/// A linked file read for the objects that show it. +enum Read { + Image(FileRead), + /// A VectorCraft document read for placed documents: the link to it and what they show. + Document(LinkInfo, super::place::document::Source), +} + +/// The file at `f` read for group `g`: its image, or the artboard `g` shows. +fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result { + let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) }; + let bytes = f.read()?; + let page = g.link.page.unwrap_or(1); + let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?; + Ok(Read::Document(f.link(&bytes, &g.link), src)) +} + +/// Objects `ids` show what was read (see [`install`], [`install_document`]). +fn install_read(d: &mut Document, ids: &[NodeId], read: Read, keep_bounds: bool) -> Result<()> { + match read { + Read::Image(r) => install(d, ids, r, keep_bounds), + Read::Document(link, src) => install_document(d, ids, &link, &src, keep_bounds), + } +} + +/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its +/// Placement Options say (`keep_bounds`: in its bounds, whatever they say). +fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, keep_bounds: bool) -> Result<()> { + let mut clips = vec![]; + let (w, h) = (src.frame.width(), src.frame.height()); + for id in ids { + let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue }; + let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..p.placement } } else { p.placement }; + let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o); + if let Some(n) = d.node_mut(*id) + && let NodeKind::PlacedDocument(p) = &mut n.kind + { + let p: &mut PlacedDocument = p; + (p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone()); + } + clips.extend(clip.map(|c| (*id, c))); + } + super::place::document::store(d, src); + clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip)) +} + /// 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) { @@ -318,9 +388,10 @@ 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); +/// when `o.clip` and the new art overflows the old bounds. (A placed document's "pixels" are points, +/// one point each.) +fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option) { + let (ow, oh, nw, nh) = (old.0, old.1, new.0.max(1e-9), new.1.max(1e-9)); 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)); @@ -388,7 +459,8 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, keep_bounds: 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); + let size = |w: u32, h: u32| (w as f64, h as f64); + let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o); if let Some(n) = d.node_mut(*id) && let NodeKind::Image(im) = &mut n.kind { @@ -436,11 +508,34 @@ 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).and_then(|read| install(d, &g.ids, read, false)) { - Ok(()) => out.updated.extend(row), - Err(_) => out.missing.extend(row), - }, + (Status::Modified, Some(mut f)) if update => { + match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, false)) { + Ok(()) => out.updated.extend(row), + Err(_) => out.missing.extend(row), + } + } (Status::Modified, _) => out.modified.extend(row), + // A placed document keeps its file's bytes: only lost ones are read again. + (Status::Ok, Some(mut f)) if g.document.is_some() => { + let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None }; + match read { + Some(Ok(read)) => { + if install_read(d, &g.ids, read, true).is_err() { + out.missing.extend(row); + } + } + Some(Err(_)) => out.missing.extend(row), + None => { + let link = match f.bytes.take() { + Some(b) => f.link(&b, &g.link), + None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + }; + if link != g.link { + set_links(d, &g.ids, &link); + } + } + } + } (Status::Ok, Some(mut f)) => { let preview = previewing(d, &g.key); // Read when the images show the preview, or when the file's details changed. @@ -471,17 +566,58 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv out } -/// Image objects `ids` link to `link`. +/// The link of linked object `n` (a linked image or a placed document) to change. +fn link_mut(n: &mut Node) -> Option<&mut LinkInfo> { + match &mut n.kind { + NodeKind::Image(im) => im.link.as_mut(), + NodeKind::PlacedDocument(p) => Some(&mut p.link), + _ => None, + } +} + +/// Linked image objects (or placed documents) `ids` link to `link`. fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { for id in ids { - if let Some(n) = d.node_mut(*id) - && let NodeKind::Image(im) = &mut n.kind - { - im.link = Some(link.clone()); + if let Some(l) = d.node_mut(*id).and_then(link_mut) { + *l = link.clone(); } } } +/// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open +/// documents: read again from the new file, each as one Update Links step of its document. +pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) { + let path = absolute_path(path); + let targets: Vec<(usize, Vec)> = s + .documents() + .iter() + .enumerate() + .filter(|(_, st)| st.uid != saved) + .filter_map(|(i, st)| { + let mut ids = vec![]; + st.doc.walk(|n| { + if let NodeKind::PlacedDocument(p) = &n.kind + && p.link.path == path + { + ids.push(n.id.0); + } + }); + (!ids.is_empty()).then_some((i, ids)) + }) + .collect(); + if targets.is_empty() { + return; + } + // Each document is updated as the active one, without switching tools. + let back = s.active; + for (i, ids) in targets { + s.active = Some(i); + // A file that can't be read leaves what its objects show: links.check reports it. + let _ = update(s, &json!({ "ids": ids })); + } + s.active = back; +} + // ---------- relative paths ---------- /// `target` relative to folder `base` with `/` separators; `None` when they share no root (another @@ -509,9 +645,7 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option { let dir = Path::new(&dest).parent()?; let mut changes = vec![]; d.walk(|n| { - if let NodeKind::Image(im) = &n.kind - && let Some(l) = &im.link - { + if let Some((l, _, _)) = linked(n) { let r = relative_path(Path::new(&l.path), dir); if r != l.relative { changes.push((n.id, r)); @@ -523,10 +657,7 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option { } let mut d = d.clone(); for (id, r) in changes { - if let Some(n) = d.node_mut(id) - && let NodeKind::Image(im) = &mut n.kind - && let Some(l) = &mut im.link - { + if let Some(l) = d.node_mut(id).and_then(link_mut) { l.relative = r; } } @@ -548,7 +679,7 @@ fn check(s: &mut Session, p: &Value) -> Result { if let Some(f) = found.filter(|f| f.path != g.link.path) { row["found"] = json!(f.path); } - if previewing(&st.doc, &g.key) { + if stale(&st.doc, g) { row["preview"] = json!(true); } links.push(row); @@ -564,9 +695,9 @@ fn update(s: &mut Session, p: &Value) -> Result { match probe(&g, folder(st.path.as_deref())) { (Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)), // Unchanged and showing the file: nothing to read unless asked for by id. - (Status::Ok, Some(_)) if ids.is_none() && !previewing(&st.doc, &g.key) => {} - // Unreadable as an image counts as missing. - (_, Some(mut f)) => match read_image(&mut f, &g.link) { + (Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {} + // Unreadable as an image (or a document) counts as missing. + (_, Some(mut f)) => match read_for(&mut f, &g, "links.update") { Ok(read) => reads.push((g.ids, read)), Err(_) => missing.extend(ids_json(&g.ids)), }, @@ -574,7 +705,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().try_for_each(|(ids, read)| install(d, &ids, read, false)))?; + s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?; } Ok(json!({ "updated": updated, "missing": missing })) } @@ -583,7 +714,14 @@ fn relink(s: &mut Session, p: &Value) -> Result { const C: &str = "links.relink"; let st = s.doc()?; let folder_param = str_param(p, "folder"); - let image = |id: &NodeId| st.doc.node(*id).and_then(|n| if let NodeKind::Image(im) = &n.kind { Some(im) } else { None }); + // An image or a placed document, with its link. + let image = |id: &NodeId| { + st.doc.node(*id).and_then(|n| match &n.kind { + NodeKind::Image(im) => Some(im.link.as_ref()), + NodeKind::PlacedDocument(p) => Some(Some(&p.link)), + _ => None, + }) + }; let ids: Vec = match ids_param(p, "ids") { Some(ids) => ids, None => { @@ -599,7 +737,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { } }; if let Some(id) = ids.iter().find(|id| image(id).is_none()) { - return Err(bad(C, format!("object {} is not an image", id.0))); + return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); @@ -611,7 +749,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { (Some(path), _) => files.push((ids, absolute_path(path))), (None, Some(dir)) => { for id in ids { - let Some(link) = image(&id).and_then(|im| im.link.as_ref()) else { continue }; + let Some(link) = image(&id).flatten() else { continue }; let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy()); match files.iter_mut().find(|(_, p)| *p == path) { Some((ids, _)) => ids.push(id), @@ -625,12 +763,34 @@ fn relink(s: &mut Session, p: &Value) -> Result { let mut reads = vec![]; for (ids, path) in files { let bytes = read_file(&path)?; - let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?; - reads.push((ids, (link_info(&path, &bytes), img))); + let link = link_info(&path, &bytes); + // Images show the file; placed documents each show their artboard of it. + let mut images = vec![]; + let mut documents: Vec<((u32, bool), Vec)> = vec![]; + for id in ids { + match st.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::PlacedDocument(pl)) => { + let k = (pl.page(), pl.bounding); + match documents.iter_mut().find(|(e, _)| *e == k) { + Some((_, ids)) => ids.push(id), + None => documents.push((k, vec![id])), + } + } + _ => images.push(id), + } + } + if !images.is_empty() { + let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?; + reads.push((images, Read::Image((link.clone(), img)))); + } + for ((page, bounding), ids) in documents { + let src = super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?; + reads.push((ids, Read::Document(LinkInfo { page: Some(page), ..link.clone() }, src))); + } } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); if !reads.is_empty() { - s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?; + s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?; } not_found.sort(); not_found.dedup(); @@ -652,18 +812,6 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) -> 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"))?; @@ -674,6 +822,35 @@ fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { } } +/// The Placement Options of image or placed document `n`. +fn placement_mut(n: &mut Node) -> Option<&mut PlacementOptions> { + match &mut n.kind { + NodeKind::Image(im) => Some(&mut im.placement), + NodeKind::PlacedDocument(p) => Some(&mut p.placement), + _ => None, + } +} + +/// The Placement Options of image or placed document `id` of `d`. +fn placement_of(d: &Document, id: NodeId) -> Option { + match &d.node(id)?.kind { + NodeKind::Image(im) => Some(im.placement), + NodeKind::PlacedDocument(p) => Some(p.placement), + _ => None, + } +} + +/// `id`, else the first selected image or placed document. +fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { + let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some()); + let id = id_param(p, "id").or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; + match placement_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 or a linked file", 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)> { @@ -681,7 +858,7 @@ fn statuses(st: &crate::DocState) -> BTreeMap, b 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); + let preview = stale(&st.doc, g); out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview)))); } out @@ -696,6 +873,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str { .map_or("Image", |f| f.label) } +/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size +/// (`pageWidth`, `pageHeight`, pt) instead of pixels. +fn document_row(p: &PlacedDocument) -> Value { + json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height }) +} + /// 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) @@ -709,17 +892,17 @@ fn list(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded"))); } let statuses = statuses(st); - let mut images = vec![]; + let mut objects = vec![]; st.doc.walk(|n| { - if let NodeKind::Image(im) = &n.kind { - images.push((n, im)); + if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) { + objects.push(n); } }); // Top first, as the Layers panel lists them. - images.reverse(); + objects.reverse(); let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut rows = vec![]; - for (n, im) in images { + for n in objects { let (status, found, preview) = match statuses.get(&n.id) { Some((s, f, p)) => (s.as_str(), f.as_deref(), *p), None => ("embedded", None, false), @@ -728,17 +911,21 @@ fn list(s: &mut Session, p: &Value) -> Result { 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 { + let (link, mut row) = match &n.kind { + NodeKind::Image(im) => { + (im.link.as_ref(), json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height })) + } + NodeKind::PlacedDocument(p) => (Some(&p.link), document_row(p)), + _ => continue, + }; + let name = link.map_or_else(|| n.display_name(), |l| l.name().to_string()); + if let Some(o) = row.as_object_mut() { + o.insert("id".into(), json!(n.id.0)); + o.insert("name".into(), json!(name)); + o.insert("linked".into(), json!(link.is_some())); + o.insert("status".into(), json!(status)); + } + if let Some(l) = link { row["path"] = json!(l.path); if let Some(page) = l.page { row["page"] = json!(page); @@ -766,7 +953,7 @@ fn list(s: &mut Session, p: &Value) -> Result { fn go_to(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = image_param(st, p, "links.goTo")?; + let id = linked_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] })) @@ -775,9 +962,29 @@ fn go_to(s: &mut Session, p: &Value) -> Result { 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())?; + let kind = |id: &NodeId| st.doc.node(*id).map(|n| &n.kind); + let linked = |id: &NodeId| match kind(id) { + Some(NodeKind::Image(im)) => im.link.is_some(), + Some(NodeKind::PlacedDocument(_)) => true, + _ => false, + }; + let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect()); + if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) { + return Err(bad(C, format!("object {} is not an image or a placed document", id.0))); + } + if ids.is_empty() { + return Err(bad(C, "select the linked images or placed documents, or pass ids")); + } + // Placed documents become editable copies of their art: those whose file can be read. + let (mut documents, mut missing) = (vec![], vec![]); + for id in ids.iter().copied() { + let Some(NodeKind::PlacedDocument(p)) = kind(&id) else { continue }; + let readable = st.doc.images.get(&p.key).is_some_and(|b| vectorcraft_doc::placed_document::full_bytes(p, b).is_some()); + if readable { documents.push(id) } else { missing.push(id) } + } + let ids: Vec = ids.into_iter().filter(|id| matches!(kind(id), Some(NodeKind::Image(_)))).collect(); // Images showing their preview take their file's pixels first. - let (mut done, mut missing, mut reads) = (vec![], vec![], vec![]); + let (mut done, mut reads) = (vec![], vec![]); for g in groups(&st.doc, Some(&ids)) { if !previewing(&st.doc, &g.key) { done.extend(g.ids); @@ -794,21 +1001,20 @@ fn embed(s: &mut Session, p: &Value) -> Result { _ => missing.extend(g.ids), } } - if !done.is_empty() { + if !done.is_empty() || !documents.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 - { + if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { im.link = None; } } - Ok(()) + documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C)) })?; } + done.extend(documents); Ok(json!({ "embedded": ids_json(&done), "missing": ids_json(&missing) })) } @@ -862,41 +1068,77 @@ fn placement_json(o: PlacementOptions) -> Value { json!({ "preserve": o.preserve.id(), "align": o.align.id(), "clip": o.clip }) } +/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`. +fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { + 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); + } + } +} + +/// Scale (% of 100%, given points per unit at 100%) and counter-clockwise rotation of `xf`. +fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) { + let [a, b, c, d, ..] = xf.as_coeffs(); + // Counter-clockwise on the page (y points down). Adding 0 turns -0 into 0. + (json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]), json!((-b).atan2(a).to_degrees() + 0.0)) +} + +/// Link Info of placed document `id`. +fn placed_info(st: &crate::DocState, id: NodeId) -> Result { + let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) }; + let size = n.geometric_bounds().unwrap_or_default(); + let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0)); + let mut out = document_row(pl); + let fields = [ + ("id", json!(id.0)), + ("name", json!(n.display_name())), + ("linked", json!(true)), + ("link", json!(pl.link.path)), + ("width", json!(size.width())), + ("height", json!(size.height())), + ("scale", scale), + ("rotation", rotation), + ("placement", placement_json(pl.placement)), + ]; + if let Some(o) = out.as_object_mut() { + o.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v))); + } + file_fields(st, id, &pl.link, &mut out); + Ok(out) +} + fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "links.info"; - let id = image_param(s.doc()?, p, C)?; + let id = linked_param(s.doc()?, p, C)?; + if image_of(&s.doc()?.doc, id).is_none() { + return placed_info(s.doc()?, id); + } 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["scale"], out["rotation"]) = scale_rotation(im.xf, (px, py)); 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); - } - } + file_fields(st, id, l, &mut out); } Ok(out) } @@ -904,7 +1146,13 @@ fn info(s: &mut Session, p: &Value) -> Result { 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 ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); + if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) { + return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); + } + if ids.is_empty() { + return Err(bad(C, "select the images, or pass ids")); + } let choice = |k: &str| -> Result> { match p.get(k) { None | Some(Value::Null) => Ok(None), @@ -918,7 +1166,7 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { 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(); + let first = ids.first().and_then(|id| placement_of(&st.doc, *id)).unwrap_or_default(); if preserve.is_none() && align.is_none() && clip.is_none() { return Ok(json!({ "placement": placement_json(first) })); } @@ -929,10 +1177,8 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { }; 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); + if let Some(o) = d.node_mut(*id).and_then(placement_mut) { + set(o); } } Ok(()) diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 624259d0..a5184c1a 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -3,8 +3,9 @@ //! //! The object keeps the file's bytes (in the document's images, like image data) and the link; the //! art it shows is read from them the way placing the file without Link reads it -//! ([`document_art`]: the artboard's art, with the file's own linked images found). The Links -//! commands ([`super::super::links`]) tell when the file changed and read it again. +//! ([`document_art`]: the artboard's art, with the file's own linked images found), so Break Link +//! and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands +//! ([`super::super::links`]) tell when the file changed and read it again. //! //! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept //! as the blob's [`ImageBlob::proxy`]: a save writes only the preview (unless Include Linked @@ -244,3 +245,29 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { } (Cow::Owned(out), warnings) } + +/// Placed document `id` of `d` replaced by an editable copy of its art, as placing its file +/// without Link gives (its resources joining `d`, renamed where `d` uses a name for something +/// else), keeping the object's id, place, name and transparency: Break Link and Object › Expand. +pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let NodeKind::PlacedDocument(p) = &n.kind else { return Err(bad(cmd, format!("object {} is not a placed document", id.0))) }; + let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?; + let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob) + .ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?; + let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, cmd)?; + let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?; + let mut nodes = a.nodes; + super::adopt::adopt(d, &a.doc, &mut nodes); + let mut children: Vec> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect(); + if let Some(r) = a.clip { + children.insert(0, Arc::new(super::clip_path(d.alloc_id(), r))); + } + let art = super::transformed(Node::new(d.alloc_id(), NodeKind::Group { children, clip: a.clip.is_some() }), m); + let mut out = n.clone(); + out.kind = art.kind; + let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + d.remove(id)?; + d.insert(par, index, out)?; + Ok(()) +} diff --git a/crates/engine/src/tests_placed_document.rs b/crates/engine/src/tests_placed_document.rs index da0f6b90..e1498c09 100644 --- a/crates/engine/src/tests_placed_document.rs +++ b/crates/engine/src/tests_placed_document.rs @@ -1,5 +1,6 @@ //! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read -//! from a file), drawn and output as vectors, and saved with its preview. +//! from a file), drawn and output as vectors, saved with its preview, checked, updated and +//! relinked when the file changes, and turned into an editable copy (Break Link, Expand). use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; @@ -158,6 +159,45 @@ fn a_placed_document_is_vectors_in_every_output() { assert!(info.to_string().contains("\"placedDocuments\""), "{info}"); } +#[test] +fn the_preview_is_saved_so_the_document_shows_it_without_its_file() { + let dir = Folder::new("placed-saved"); + let src = dir.file("logo.vectorcraft"); + // Content of its own: files read are cached by content, and this one is removed. + source(&src, 100.0, 50.0, RED, 2); + let mut s = session(); + let id = place(&mut s, &src); + let doc_path = dir.file("poster/poster.vectorcraft"); + std::fs::create_dir_all(dir.file("poster")).unwrap(); + save(&mut s, &doc_path); + let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + let kind = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"]; + assert_eq!(kind["type"], "placeddocument"); + assert_eq!(kind["link"]["relative"], "../logo.vectorcraft"); + let key = kind["key"].as_str().unwrap(); + let entry = &saved["images"][key]; + assert_eq!((entry["proxy"].as_bool(), entry["mime"].as_str()), (Some(true), Some("image/jpeg")), "the preview, as JPEG (opaque)"); + // Include Linked Files keeps the file itself. + let full = file_json(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "includeLinked": true})).unwrap())); + assert_eq!((full["images"][key]["proxy"].clone(), full["images"][key]["mime"].as_str()), (Value::Null, Some("application/json"))); + std::fs::remove_file(&src).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!(r["missingLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert_eq!(placed(&s, id).link.path, src); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "shown from the saved preview"); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 1); + // Output with the file gone: the preview, with a warning. + let png = s.execute("document.serialize", &json!({"format": "png"})).unwrap(); + assert!(png["warnings"].to_string().contains("output as their previews"), "{png}"); + let [r, g, b, _] = image::load_from_memory(&decode(&png)).unwrap().to_rgba8().get_pixel(200, 150).0; + assert!(near([r, g, b], RED), "{:?}", [r, g, b]); + // Saved for older apps: the art it shows (here its preview), as plain objects. + let old: Value = + serde_json::from_slice(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": 2})).unwrap())).unwrap(); + let old = old["document"]["layers"][0]["kind"]["children"][0].to_string(); + assert!(old.contains("\"image\"") && !old.contains("placeddocument"), "{old}"); +} + #[test] fn a_document_saved_with_previews_outputs_its_files_art() { let dir = Folder::new("placed-preview-output"); @@ -182,6 +222,73 @@ fn a_document_saved_with_previews_outputs_its_files_art() { assert!(s.doc().unwrap().doc.images[&key].is_proxy()); } +#[test] +fn a_changed_file_is_modified_and_update_links_reads_it_again() { + let dir = Folder::new("placed-update"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let before = bounds(&s, id); + let c = s.execute("links.check", &json!({})).unwrap(); + assert_eq!((c["modified"].as_u64(), c["links"][0]["status"].as_str()), (Some(0), Some("ok")), "{c}"); + let list = s.execute("links.list", &json!({})).unwrap(); + let row = &list["links"][0]; + assert_eq!((row["format"].as_str(), row["document"].as_bool(), row["status"].as_str()), (Some("VectorCraft"), Some(true), Some("ok")), "{row}"); + // A blue document, wider, and a different file size. + source(&src, 200.0, 50.0, BLUE, 1); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["links"][0]["status"], "modified"); + // Until updated, it shows (and outputs) the file as it read it. + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert_eq!(svg["warnings"], json!([])); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"], json!([id.0]), "{u}"); + assert_eq!(placed(&s, id).width, 200.0); + assert_eq!(bounds(&s, id), before, "keeps its bounds"); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); +} + +#[test] +fn update_links_on_open_reads_a_modified_file_again() { + let dir = Folder::new("placed-open"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + place(&mut s, &src); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + source(&src, 100.0, 50.0, BLUE, 1); + // Ask When Modified (the default): reported, left as it was. + let r = open(&mut s, &doc_path); + assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + // Automatically: read again as the document opens. + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!(r["updatedLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); +} + +#[test] +fn relink_points_a_placed_document_at_another_file() { + let dir = Folder::new("placed-relink"); + let (a, b) = (dir.file("a.vectorcraft"), dir.file("b.vectorcraft")); + source(&a, 100.0, 50.0, RED, 0); + source(&b, 100.0, 50.0, BLUE, 0); + let mut s = session(); + let id = place(&mut s, &a); + let r = s.execute("links.relink", &json!({"ids": [id.0], "path": b})).unwrap(); + assert_eq!(r["relinked"], json!([id.0]), "{r}"); + assert_eq!(placed(&s, id).link.path, b); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); +} + #[test] fn a_placed_document_inside_a_placed_document_outputs_from_its_own_file() { let dir = Folder::new("placed-nested"); @@ -201,6 +308,28 @@ fn a_placed_document_inside_a_placed_document_outputs_from_its_own_file() { assert_eq!(pdf_contents(&decode(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap())).0, 0); } +#[test] +fn documents_that_place_each_other_dont_loop() { + let dir = Folder::new("placed-loop"); + let (a, b) = (dir.file("a.vectorcraft"), dir.file("b.vectorcraft")); + source(&a, 100.0, 50.0, RED, 0); + // B places A; A then places B. + let mut s = session(); + place(&mut s, &a); + save(&mut s, &b); + s.execute("document.open", &json!({"path": a})).unwrap(); + place(&mut s, &b); + save(&mut s, &a); + s.execute("document.open", &json!({"path": b})).unwrap(); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"].as_array().map(Vec::len), Some(1), "{u}"); + // Drawn and output a few levels deep, then their previews. + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + for format in ["svg", "pdf", "png"] { + s.execute("document.serialize", &json!({"format": format})).unwrap(); + } +} + #[test] fn what_cant_be_placed_linked_is_an_error() { let dir = Folder::new("placed-errors"); @@ -247,3 +376,106 @@ fn a_placed_documents_resources_stay_its_own() { assert!(!old.has_placed()); assert!(near(centre_colour(&old), BLUE)); } + +#[test] +fn break_link_gives_the_editable_copy_placing_without_link_gives() { + let dir = Folder::new("placed-break"); + let src = dir.file("badge.vectorcraft"); + star_source(&src, "break"); + let mut s = session(); + parent_star(&mut s); + // The copy placing without Link gives. + let r = s.execute("file.place", &json!({"path": src, "link": false, "at": [200, 150]})).unwrap(); + let copy = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone(); + s.execute("edit.undo", &json!({})).unwrap(); + let id = place(&mut s, &src); + let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["embedded"], json!([id.0]), "{r}"); + let doc = &s.doc().unwrap().doc; + let n = doc.node(id).unwrap(); + assert!(!doc.has_placed() && n.kind_label() == "Clip Group", "{}", n.kind_label()); + assert_eq!((n.name.as_deref(), n.geometric_bounds()), (copy.name.as_deref(), copy.geometric_bounds())); + let kinds = |n: &Node| { + let mut k = vec![]; + n.walk(&mut |c| k.push(c.kind_label())); + k + }; + assert_eq!(kinds(n), kinds(©)); + assert!(near(centre_colour(doc), BLUE), "the art as it showed"); + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names.len(), 2, "the file's Star joined, renamed: {names:?}"); + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"], json!([])); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Embed"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.has_placed()); + // A file that can't be read stays linked. + std::fs::remove_file(&src).unwrap(); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + open(&mut s, &doc_path); + let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap(); + assert_eq!((r["embedded"].clone(), r["missing"].clone()), (json!([]), json!([id.0])), "{r}"); +} + +#[test] +fn expand_turns_a_placed_document_into_editable_art_with_its_resources() { + let dir = Folder::new("placed-expand"); + let src = dir.file("badge.vectorcraft"); + star_source(&src, "star"); + let mut s = session(); + parent_star(&mut s); + let id = place(&mut s, &src); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let before = bounds(&s, id); + s.execute("object.expand", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + let n = doc.node(id).unwrap(); + assert!(n.is_container() && !doc.has_placed(), "{:?}", n.kind_label()); + let after = n.geometric_bounds().unwrap(); + assert!((after.width() - before.width()).abs() < 0.5 && (after.height() - before.height()).abs() < 0.5, "{after:?} vs {before:?}"); + assert!(near(centre_colour(doc), BLUE)); + let saved = vectorcraft_format::save_file(doc); + assert!(near(centre_colour(&vectorcraft_format::load(&saved).unwrap()), BLUE), "saved with its resources"); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!(info["object"], false, "nothing left to expand: {info}"); +} + +#[test] +fn saving_a_document_refreshes_the_open_documents_that_place_it() { + let dir = Folder::new("placed-refresh"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let parent = s.active_index().unwrap(); + // Open the file, recolour it and save it. + s.execute("document.open", &json!({"path": src})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#1414e6"})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let child = s.active_index().unwrap(); + assert_ne!(parent, child); + let doc = &s.documents()[parent]; + assert_eq!(doc.history.undo.last().unwrap().label, "Update Links"); + assert!(near(centre_colour(&doc.doc), BLUE), "shows the saved version"); + assert!(matches!(doc.doc.node(id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))); + assert_eq!(s.active_index(), Some(child), "the saved document stays active"); +} + +#[test] +fn link_info_gives_the_artboards_size_not_pixels() { + let dir = Folder::new("placed-info"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let i = s.execute("links.info", &json!({"id": id.0})).unwrap(); + assert_eq!((i["pageWidth"].as_f64(), i["pageHeight"].as_f64(), i["document"].as_bool()), (Some(100.0), Some(50.0), Some(true)), "{i}"); + assert!(i.get("pixelWidth").is_none() && i.get("ppi").is_none(), "no pixels: {i}"); + assert_eq!( + (i["status"].as_str(), i["fileName"].as_str(), i["scale"].clone()), + (Some("ok"), Some("logo.vectorcraft"), json!([200.0, 200.0])), + "{i}" + ); +}