From 5750843d731de8366c74a7ff584451940cc6bff0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:45:08 -0800 Subject: [PATCH] Links: placed images in stored brush art get Links rows Linked images and placed documents in the art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) are listed in the Links panel after the other stored art, one row per linked or placed object in each brush tile, with the brush as owner (`{kind: brush, name, index, tile?}`). Check Links, the missing and modified files reported on open, the link watch, Find in Folder, Relink (including Relink All Instances), Update, Embed, Placement Options, Link Info and Go To act on them, and their tooltip names the brush. - vectorcraft-doc: `Document::mounted_art` holds art kept outside the document's trees while a command looks it up by id; the stored-art lookups, link visitors and `art_roots` include it, and `number_mounted_art` gives its objects ids of their own. - vectorcraft-brush: `stored_art::StoredArt` mounts the art of the brushes that hold images or placed documents and writes back only the entries whose art changed. Entries that do not read as a brush are left as stored. `brush::store` keeps ids of their own in that art, so a row keeps its id between commands. - vectorcraft-engine: the links commands, opening a document, saving a placed document's source, output of linked files and placed documents, color masks after Update or Relink, and Package relinking go through the mounted art. Brush art rows follow the general Links rule as VectorCraft's own application of it. --- crates/brush/src/lib.rs | 9 +- crates/brush/src/stored_art.rs | 176 ++++++++++++++++ crates/brush/src/tests.rs | 153 ++++++++++++++ crates/doc/src/document.rs | 2 + crates/doc/src/document_ext.rs | 6 +- crates/doc/src/lib.rs | 6 + crates/doc/src/links.rs | 14 +- crates/doc/src/placed_document.rs | 5 +- crates/doc/src/stored_art.rs | 62 +++++- crates/engine/src/cmd/addons/image/mask.rs | 3 +- crates/engine/src/cmd/linkfiles.rs | 5 +- crates/engine/src/cmd/links.rs | 222 ++++++++++++++------- crates/engine/src/cmd/package.rs | 26 ++- crates/engine/src/cmd/place.rs | 6 +- crates/engine/src/cmd/place/document.rs | 7 +- crates/engine/src/tests/links.rs | 217 ++++++++++++++++++++ crates/engine/src/tests/package.rs | 32 +++ crates/engine/src/tests/placed_document.rs | 51 +++++ crates/ui-egui/src/panels/links.rs | 5 +- crates/ui-egui/src/panels/tests_links.rs | 1 + docs/mcp.md | 18 +- docs/target-app-parity.md | 1 + 22 files changed, 916 insertions(+), 111 deletions(-) create mode 100644 crates/brush/src/stored_art.rs diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 080462d0b6..0d8236f183 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -13,6 +13,7 @@ mod calli; mod colorize; mod defaults; +pub mod stored_art; pub mod track; mod warp; @@ -798,9 +799,15 @@ fn number_repeated_names(lib: &mut [Brush]) { } } -/// Store `lib` as the document's brush library. +/// Store `lib` as the document's brush library. When the art of a brush holds images or placed +/// documents, each object in that art whose id is 0 or repeats another object's is given a new +/// id ([`stored_art::StoredArt::persist_ids`]), so the Links panel lists it by the same id until it +/// changes. pub fn store(doc: &mut Document, lib: &[Brush]) { doc.unknown.insert(DOC_KEY.into(), serde_json::to_value(lib).unwrap_or_default()); + if lib.iter().any(|b| b.art().into_iter().any(stored_art::holds_files)) { + stored_art::StoredArt::persist_ids(doc); + } } /// Find a brush by name. diff --git a/crates/brush/src/stored_art.rs b/crates/brush/src/stored_art.rs new file mode 100644 index 0000000000..a386e2e181 --- /dev/null +++ b/crates/brush/src/stored_art.rs @@ -0,0 +1,176 @@ +//! The art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) as stored +//! art of the document ([`Document::mounted_art`]): set on the document while a command looks it up +//! by id or changes it, then written back into the library entries whose art changed. The Links +//! panel finds the images and placed documents in that art this way. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeKind}; + +use crate::{Brush, BrushKind, DOC_KEY}; + +/// The brushes of a document's stored library whose art holds images or placed documents. +#[derive(Default)] +pub struct StoredArt { + entries: Vec, + /// How many entries the stored library had when it was read. + read_entries: usize, +} + +/// A library entry whose brush's art holds images or placed documents. +struct Entry { + /// Its index in the stored library. + slot: usize, + /// The entry as it was read. + raw: Value, + brush: Brush, + /// The brush's art ([`Brush::art`]), each root with the `owner` the Links panel names for it. + art: Vec<(Arc, Value)>, +} + +/// What names each root of [`Brush::art`] among the brush's art: a pattern brush's tile, in the +/// same order; nothing for the one root of a scatter or art brush. +fn tiles(b: &Brush) -> Vec> { + match &b.kind { + BrushKind::Pattern(p) => std::iter::once(Some("side")) + .chain( + [ + (p.outer_corner.is_some(), "outerCorner"), + (p.inner_corner.is_some(), "innerCorner"), + (p.start.is_some(), "start"), + (p.end.is_some(), "end"), + ] + .into_iter() + .filter(|(held, _)| *held) + .map(|(_, tile)| Some(tile)), + ) + .collect(), + _ => vec![None], + } +} + +/// Whether `n` or its subtree, opacity-mask art included, holds an image or a placed document. +pub(crate) fn holds_files(n: &Node) -> bool { + let mut found = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| found |= matches!(c.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_))); + found +} + +impl StoredArt { + /// The brushes of `doc`'s stored library whose art holds an image or a placed document (opacity + /// masks included). An entry that does not read as a brush is left out, and so is left as it + /// is. A root's owner is + /// `{kind: "brush", name, index, tile?}`, with the name and index the brush has in the library as + /// the Brushes panel lists it ([`crate::library`], a repeated name numbered). + pub fn read(doc: &Document) -> Self { + let Some(stored) = doc.unknown.get(DOC_KEY) else { return Self::default() }; + let Some(list) = stored.as_array() else { return Self::default() }; + let listed = crate::parse_library(stored); + let parsed = list.iter().enumerate().filter_map(|(slot, raw)| Brush::from_json(raw.clone()).ok().map(|b| (slot, raw, b))); + let mut entries = vec![]; + for (index, (slot, raw, brush)) in parsed.enumerate() { + if !brush.art().into_iter().any(holds_files) { + continue; + } + let name = listed.get(index).map_or(brush.name.as_str(), |b| b.name.as_str()); + let art: Vec<(Arc, Value)> = brush + .art() + .into_iter() + .zip(tiles(&brush)) + .map(|(n, tile)| { + let mut owner = json!({ "kind": "brush", "name": name, "index": index }); + if let Some(tile) = tile { + owner["tile"] = json!(tile); + } + (Arc::new(n.clone()), owner) + }) + .collect(); + if !art.is_empty() { + entries.push(Entry { slot, raw: raw.clone(), brush, art }); + } + } + Self { entries, read_entries: list.len() } + } + + /// Gives the objects of the stored brushes' art that holds images or placed documents ids of + /// their own in `doc`'s library, as [`Self::mount`] gives them, and writes the brushes whose + /// ids changed back. Does nothing while art is set on `doc` ([`Document::mounted_art`]). + pub fn persist_ids(doc: &mut Document) { + if !doc.mounted_art.is_empty() { + return; + } + let art = Self::read(doc); + if !art.is_empty() { + art.mount(doc); + art.write_entries(doc); + } + } + + /// No stored brush's art holds an image or a placed document. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Sets the art on `doc` as [`Document::mounted_art`], each object with an id of its own + /// ([`Document::number_mounted_art`]: an id another stored object has, or none, is replaced, and + /// the id counter stands above them all). + pub fn mount(&self, doc: &mut Document) { + doc.mounted_art = self.entries.iter().flat_map(|e| e.art.iter().cloned()).collect(); + doc.number_mounted_art(); + } + + /// Takes [`Document::mounted_art`] off `doc` and writes each brush whose art changed (new ids + /// included) back into its library entry: the entry at its index when that entry is as it was + /// read or reads as the brush as it was apart from its name, else the first entry that reads as + /// the brush as it was, else the first that does apart from its name. An entry found apart from + /// its name keeps that name (the library was written again meanwhile, as placing a document's + /// brushes into it does, numbering a repeated name). The other entries are left as + /// they are. Then the art of brushes added meanwhile gets ids of its own + /// ([`Self::persist_ids`]). + pub fn write_back(self, doc: &mut Document) { + let read_entries = self.read_entries; + self.write_entries(doc); + if doc.unknown.get(DOC_KEY).and_then(Value::as_array).map(Vec::len) != Some(read_entries) { + Self::persist_ids(doc); + } + } + + /// [`Self::write_back`] without giving ids to the art of brushes added meanwhile. + fn write_entries(self, doc: &mut Document) { + let mut mounted = std::mem::take(&mut doc.mounted_art).into_iter(); + let mut changed = vec![]; + for e in self.entries { + let now: Vec> = mounted.by_ref().take(e.art.len()).map(|(n, _)| n).collect(); + if now.len() != e.art.len() || now.iter().zip(&e.art).all(|(a, (b, _))| Arc::ptr_eq(a, b)) { + continue; + } + let mut brush = e.brush.clone(); + for (slot, art) in brush.art_mut().into_iter().zip(now) { + *slot = Arc::unwrap_or_clone(art); + } + changed.push((e.slot, e.raw, e.brush, brush)); + } + let Some(list) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_array_mut) else { return }; + for (slot, raw, was, mut brush) in changed { + // The entry's name when it reads as `was` apart from its name. + let name_of = |entry: &Value| { + Brush::from_json(entry.clone()).ok().and_then(|mut b| { + let name = std::mem::replace(&mut b.name, was.name.clone()); + (b == was).then_some(name) + }) + }; + let at = match list.get(slot) { + Some(entry) if *entry == raw || name_of(entry).is_some() => Some(slot), + _ => list.iter().position(|e| name_of(e).as_ref() == Some(&was.name)).or_else(|| list.iter().position(|e| name_of(e).is_some())), + }; + let Some(entry) = at.and_then(|i| list.get_mut(i)) else { continue }; + if let Some(name) = name_of(entry) { + brush.name = name; + } + if let Ok(v) = serde_json::to_value(&brush) { + *entry = v; + } + } + } +} diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 2d62a1a993..4484b97c19 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -973,6 +973,159 @@ fn repeated_brush_names_are_numbered_when_read() { assert_eq!(names.iter().collect::>().len(), names.len()); } +/// Art of `w`×`h` points holding a linked image: a group of [`rect_art`] and the image, both with id 0. +fn linked_art(w: f64, h: f64) -> Node { + let image = Node::new( + NodeId(0), + NodeKind::Image(Box::new(vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link: Some(vectorcraft_doc::LinkInfo::new("/art/a.png")), + placement: Default::default(), + pixels: None, + })), + ); + Node::group(NodeId(0), vec![std::sync::Arc::new(rect_art(w, h)), std::sync::Arc::new(image)]) +} + +/// The art of the stored brushes that holds images mounts on the document as stored art, each tile +/// with the brush, its place in the library and the tile as owner, its objects with ids of their +/// own; writing it back changes only the entries whose art changed, and leaves an entry that does +/// not read as a brush, a brush without art and a brush whose art holds no image as they were stored. +#[test] +fn stored_brush_art_mounts_and_writes_back_only_what_changed() { + use stored_art::StoredArt; + let mut d = Document::new(100.0, 100.0); + let layer_object = d.layers[0].id; + let mut side = linked_art(10.0, 4.0); + side.id = layer_object; + let pattern = + Brush { name: "Frame".into(), kind: BrushKind::Pattern(PatternBrush { side, start: Some(linked_art(4.0, 4.0)), ..Default::default() }) }; + let scatter = Brush { + name: "Dots".into(), + kind: BrushKind::Scatter(Scatter { art: linked_art(2.0, 2.0), colorization: Colorization::None, ..Default::default() }), + }; + // Written as a file holds it, without the ids `store` gives. + let lib = [calli(0.0, 100.0, 3.0), pattern, scatter, art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)]; + d.unknown.insert(DOC_KEY.into(), serde_json::to_value(lib).unwrap()); + let odd = serde_json::json!({ "name": "Odd", "type": "unknown" }); + if let Some(list) = d.unknown.get_mut(DOC_KEY).and_then(serde_json::Value::as_array_mut) { + list.insert(1, odd.clone()); + } + let stored = d.unknown[DOC_KEY].clone(); + let art = StoredArt::read(&d); + art.mount(&mut d); + let owners: Vec<&serde_json::Value> = d.mounted_art.iter().map(|(_, o)| o).collect(); + assert_eq!( + owners, + [ + &serde_json::json!({ "kind": "brush", "name": "Frame", "index": 1, "tile": "side" }), + &serde_json::json!({ "kind": "brush", "name": "Frame", "index": 1, "tile": "start" }), + &serde_json::json!({ "kind": "brush", "name": "Dots", "index": 2 }), + ] + ); + let ids: Vec = d.mounted_art.iter().map(|(n, _)| n.id).collect(); + let mut all = vec![]; + d.visit_stored(|n, _| all.push(n.id)); + assert!(all.iter().all(|id| id.0 != 0) && !all[1..].contains(&layer_object), "{all:?}"); + assert_eq!(all.iter().collect::>().len(), all.len(), "{all:?}"); + // The scatter brush's art changes; the pattern brush's tiles keep the ids mounting gave them. + d.stored_node_mut(ids[2]).unwrap().name = Some("changed".into()); + art.write_back(&mut d); + assert!(d.mounted_art.is_empty()); + let now = d.unknown[DOC_KEY].as_array().unwrap(); + assert_eq!(now.len(), 5); + assert_eq!((&now[0], &now[1], &now[4]), (&stored[0], &odd, &stored[4]), "the entries without images stay as stored"); + assert_ne!(now[2], stored[2], "the pattern brush keeps the ids its art was given"); + let lib = library(&d); + let Some(BrushKind::Pattern(p)) = lib.iter().find(|b| b.name == "Frame").map(|b| &b.kind) else { panic!("no pattern brush") }; + assert_eq!((p.side.id, p.start.as_ref().map(|n| n.id)), (ids[0], Some(ids[1]))); + assert_eq!(find(&d, "Dots").unwrap().art()[0].name.as_deref(), Some("changed")); + // Mounting again gives the same ids and changes nothing, so writing back leaves the library as it is. + let again = d.unknown[DOC_KEY].clone(); + let art = StoredArt::read(&d); + art.mount(&mut d); + assert_eq!(d.mounted_art.iter().map(|(n, _)| n.id).collect::>(), ids); + art.write_back(&mut d); + assert_eq!(d.unknown[DOC_KEY], again); +} + +/// A brush whose stored name repeats an earlier brush's is named in its art's owner as the Brushes +/// panel lists it, numbered. +#[test] +fn stored_brush_art_owner_names_a_repeated_name_as_listed() { + use stored_art::StoredArt; + let mut d = Document::new(100.0, 100.0); + let scatter = Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: linked_art(2.0, 2.0), ..Default::default() }) }; + store(&mut d, &[scatter.clone(), scatter]); + StoredArt::read(&d).mount(&mut d); + let names: Vec<&serde_json::Value> = d.mounted_art.iter().map(|(_, o)| &o["name"]).collect(); + assert_eq!(names, ["Dots", "Dots 2"]); +} + +/// Storing a library whose brush art holds an image gives that art ids of its own, so mounting it +/// later gives the same ids whatever the document allocated in between; art without images keeps +/// the ids it was stored with. +#[test] +fn storing_brush_art_with_images_keeps_ids_of_its_own() { + use stored_art::StoredArt; + let mut d = Document::new(100.0, 100.0); + let mut art = linked_art(2.0, 2.0); + art.id = d.layers[0].id; + let scatter = Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art, ..Default::default() }) }; + store(&mut d, &[scatter, art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)]); + let lib = library(&d); + let stored = lib[0].art()[0].id; + assert_ne!(stored, d.layers[0].id); + assert_eq!(lib[1].art()[0].id, NodeId(0), "art without images is stored as it was"); + d.alloc_id(); + StoredArt::read(&d).mount(&mut d); + assert_eq!(d.mounted_art[0].0.id, stored); +} + +/// When the library is written again while the art is mounted, numbering a repeated name and +/// adding a brush (as placing a document's brushes into it does), writing back changes the entry +/// at its own place that reads as the brush apart from its new name, keeping that name, even when +/// an earlier brush is the same as the one it was, and gives the added brush's art ids of its own. +#[test] +fn brush_art_written_back_finds_its_renamed_entry_and_numbers_added_art() { + use stored_art::StoredArt; + /// `n` with ids from `next` on, in paint order. + fn numbered(mut n: Node, next: &mut u64) -> Node { + n.id = NodeId(*next); + *next += 1; + for c in n.children_mut().into_iter().flatten() { + *c = std::sync::Arc::new(numbered((**c).clone(), next)); + } + n + } + let mut d = Document::new(100.0, 100.0); + let scatter = + |n: &str| Brush { name: n.into(), kind: BrushKind::Scatter(Scatter { art: numbered(linked_art(2.0, 2.0), &mut 50), ..Default::default() }) }; + // Two brushes the same apart from the ids mounting gives the second. + d.unknown.insert(DOC_KEY.into(), serde_json::to_value([scatter("Dots"), scatter("Dots")]).unwrap()); + let art = StoredArt::read(&d); + art.mount(&mut d); + assert_eq!(d.mounted_art[0].0.id, NodeId(50)); + let second = d.mounted_art[1].0.id; + // The library is written again: the repeated name numbered, a brush whose art repeats an id added. + let mut added = scatter("New"); + if let BrushKind::Scatter(s) = &mut added.kind { + s.art.id = d.layers[0].id; + } + let lib = [library(&d), vec![added]].concat(); + d.unknown.insert(DOC_KEY.into(), serde_json::to_value(&lib).unwrap()); + assert_eq!(lib[1].name, "Dots 2"); + d.stored_node_mut(second).unwrap().name = Some("changed".into()); + art.write_back(&mut d); + let lib = library(&d); + assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::>(), ["Dots", "Dots 2", "New"]); + assert_eq!((lib[0].art()[0].name.as_deref(), lib[1].art()[0].name.as_deref()), (None, Some("changed"))); + assert_ne!(lib[2].art()[0].id, d.layers[0].id, "the added brush's art has ids of its own"); +} + #[test] fn brushes_serde_round_trip() { let lib = defaults().to_vec(); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 418e454b92..54130788e1 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -79,6 +79,7 @@ impl Document { index: _, guide_cache: _, inline_cycles: _, + mounted_art: _, } = self; *version == other.version && *title == other.title @@ -264,6 +265,7 @@ impl Document { index: Default::default(), guide_cache: Default::default(), inline_cycles: Default::default(), + mounted_art: vec![], }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index 0f1ec463c4..c29e8464ea 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -128,8 +128,8 @@ impl Document { } /// The roots of every tree of the document's art: the layers, symbol definitions, graph designs, - /// pattern swatches and the original of the pattern being edited. Opacity masks hang off nodes - /// in these trees. + /// pattern swatches, the original of the pattern being edited and [`Self::mounted_art`]. + /// Opacity masks hang off nodes in these trees. pub fn art_roots(&self) -> impl Iterator> { self.layers .iter() @@ -137,6 +137,7 @@ impl Document { .chain(self.graph_designs.iter().map(|g| &g.art)) .chain(self.patterns.iter().flat_map(|p| p.art.iter())) .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())) + .chain(self.mounted_art.iter().map(|(n, _)| n)) } /// [`Self::art_roots`], to change. @@ -147,6 +148,7 @@ impl Document { .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())) + .chain(self.mounted_art.iter_mut().map(|(n, _)| n)) } /// Does `wants` hold for a node anywhere in the document's art ([`Self::art_roots`]) or its diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c51e099840..cb78d96ba9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -367,6 +367,12 @@ pub struct Document { /// saved. #[serde(skip)] inline_cycles: inline::InlineCycles, + /// Art kept elsewhere in the document's data (the art of the stored brushes, which + /// `vectorcraft-brush` reads from [`Self::unknown`]) while it is set here, each root with the + /// `owner` the Links panel names for it, so the stored-art lookups ([`StoredRoot::Mounted`]) + /// find its objects too. Empty otherwise; never saved. + #[serde(skip)] + pub mounted_art: Vec<(Arc, serde_json::Value)>, } fn ppi72() -> f64 { diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index ffa9eac0cd..b2f46700eb 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -248,14 +248,15 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { impl Document { /// Visit every image object: in the layers, then in symbol definitions and pattern swatches, - /// opacity-mask art included. + /// then in [`Document::mounted_art`], opacity-mask art included. pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) { let roots = self .layers .iter() .chain(self.symbols.iter().map(|s| &s.art)) .chain(self.graph_designs.iter().map(|g| &g.art)) - .chain(self.patterns.iter().flat_map(|p| &p.art)); + .chain(self.patterns.iter().flat_map(|p| &p.art)) + .chain(self.mounted_art.iter().map(|(n, _)| n)); for root in roots { walk_all(root, &mut |n| { if let NodeKind::Image(im) = &n.kind { @@ -384,7 +385,8 @@ impl Align { impl Document { /// Rewrite linked file locations in both raster images and placed VectorCraft documents, - /// including those inside symbols, patterns and opacity masks. Used to make packages portable. + /// including those inside symbols, patterns, opacity masks and [`Document::mounted_art`]. Used to + /// make packages portable. pub fn update_links(&mut self, mut f: impl FnMut(&mut LinkInfo)) { fn walk(n: &mut Arc, f: &mut dyn FnMut(&mut LinkInfo)) { let n = Arc::make_mut(n); @@ -409,7 +411,8 @@ impl Document { .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) - .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)) + .chain(self.mounted_art.iter_mut().map(|(n, _)| n)); for root in roots { walk(root, &mut f); } @@ -471,7 +474,8 @@ impl Document { .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) - .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)) + .chain(self.mounted_art.iter_mut().map(|(n, _)| n)); for root in roots { update(root, &pick, &mut f); } diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 86f7f6134c..cd423658d7 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -166,13 +166,15 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { } } -/// The roots of `d`'s art: layers, symbol definitions and pattern swatches. +/// The roots of `d`'s art: layers, symbol definitions, pattern swatches and +/// [`Document::mounted_art`]. fn roots(d: &Document) -> impl Iterator> { d.layers .iter() .chain(d.symbols.iter().map(|s| &s.art)) .chain(d.graph_designs.iter().map(|g| &g.art)) .chain(d.patterns.iter().flat_map(|p| &p.art)) + .chain(d.mounted_art.iter().map(|(n, _)| n)) } fn roots_mut(d: &mut Document) -> impl Iterator> { @@ -181,6 +183,7 @@ fn roots_mut(d: &mut Document) -> impl Iterator> { .chain(d.symbols.iter_mut().map(|s| &mut s.art)) .chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(d.patterns.iter_mut().flat_map(|p| &mut p.art)) + .chain(d.mounted_art.iter_mut().map(|(n, _)| n)) } impl Document { diff --git a/crates/doc/src/stored_art.rs b/crates/doc/src/stored_art.rs index e623e0ed0e..cc0832b733 100644 --- a/crates/doc/src/stored_art.rs +++ b/crates/doc/src/stored_art.rs @@ -20,6 +20,8 @@ pub enum StoredRoot { GraphDesign(usize), /// Object `art` of pattern swatch `pattern` of [`Document::patterns`]. Pattern { pattern: usize, art: usize }, + /// Root `index` of [`Document::mounted_art`]. + Mounted(usize), } /// Where a stored object is: its root and, inside opacity-mask art, the object that mask belongs @@ -170,10 +172,12 @@ impl Document { .iter() .enumerate() .flat_map(|(p, def)| def.art.iter().enumerate().map(move |(a, n)| (n, StoredRoot::Pattern { pattern: p, art: a }))); - symbols.chain(designs).chain(patterns) + let mounted = self.mounted_art.iter().enumerate().map(|(i, (n, _))| (n, StoredRoot::Mounted(i))); + symbols.chain(designs).chain(patterns).chain(mounted) } - /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns. + /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns, + /// then [`Document::mounted_art`]. fn stored_roots(&self) -> impl Iterator, StoredRoot)> { self.layers.iter().enumerate().map(|(i, l)| (l, StoredRoot::Layer(i))).chain(self.definition_roots()) } @@ -185,12 +189,13 @@ impl Document { StoredRoot::Symbol(i) => self.symbols.get_mut(i).map(|s| &mut s.art), StoredRoot::GraphDesign(i) => self.graph_designs.get_mut(i).map(|g| &mut g.art), StoredRoot::Pattern { pattern, art } => self.patterns.get_mut(pattern)?.art.get_mut(art), + StoredRoot::Mounted(i) => self.mounted_art.get_mut(i).map(|(n, _)| n), } } /// Visit every stored object with its place: the layer tree with the opacity-mask art of its /// objects, then the art of symbols, graph designs and pattern swatches (their definitions, - /// once each, never the copies instances and tiles draw). + /// once each, never the copies instances and tiles draw), then [`Document::mounted_art`]. pub fn visit_stored<'a>(&'a self, mut f: impl FnMut(&'a Node, StoredPlace)) { self.visit_stored_arcs(|n, place| f(n, place)); } @@ -238,6 +243,29 @@ impl Document { /// visited before them (the layer tree's own objects first) a new id, so every stored object /// can be named by its id. Returns how many changed; the id counter then stands above them. pub(crate) fn renumber_stored_duplicates(&mut self) -> usize { + self.renumber_duplicates_in(|_| true) + } + + /// Raises the id counter above the ids of [`Document::mounted_art`]'s objects, then gives those + /// objects whose id is 0, past the id range, or already taken by an object visited before them + /// (every other stored object first) a new id, as [`Self::renumber_stored_duplicates`] gives + /// them. Returns how many changed. + pub fn number_mounted_art(&mut self) -> usize { + let mut max = 0; + for (i, (root, _)) in self.mounted_art.iter().enumerate() { + visit(root, StoredPlace { root: StoredRoot::Mounted(i), mask_of: None }, &mut |n, _| { + if n.id.0 < MAX_NEXT_ID { + max = max.max(n.id.0); + } + }); + } + self.raise_next_id(max.saturating_add(1)); + self.renumber_duplicates_in(|r| matches!(r, StoredRoot::Mounted(_))) + } + + /// [`Self::renumber_stored_duplicates`] for the roots `pick` holds for only; the objects of the + /// others still count as taken. + fn renumber_duplicates_in(&mut self, pick: impl Fn(StoredRoot) -> bool) -> usize { let mut seen: HashSet = HashSet::new(); for l in &self.layers { l.walk(&mut |n| { @@ -250,9 +278,9 @@ impl Document { // Per root, the visiting indices of the nodes that take a new id. let mut fresh: Vec<(StoredRoot, HashSet)> = vec![]; for (root, r) in self.stored_roots() { - let (mut i, mut here) = (0, HashSet::new()); + let (mut i, mut here, take) = (0, HashSet::new(), pick(r)); visit(root, StoredPlace { root: r, mask_of: None }, &mut |n, place| { - if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) { + if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) && take { here.insert(i); } i += 1; @@ -355,6 +383,30 @@ mod tests { assert_eq!(d, again); } + /// Mounted art is found and changed by id after the other stored art, and numbering it gives + /// its objects that repeat another stored object's id, or have none, new ids above the counter, + /// leaving every other object's id and its own unique ones as they are. + #[test] + fn mounted_art_is_found_and_numbered_after_the_rest() { + let mut d = doc([12, 13, 14]); + let owner = serde_json::json!({ "kind": "brush" }); + d.mounted_art = vec![(Arc::new(masked(10, rect(0))), owner.clone()), (Arc::new(rect(15)), owner)]; + assert_eq!(d.stored_node(NodeId(15)).map(|(_, p)| p.root), Some(StoredRoot::Mounted(1))); + let before = d.clone(); + assert_eq!(d.number_mounted_art(), 2); + let mut ids = vec![]; + d.visit_stored(|n, _| ids.push(n.id.0)); + let unique: HashSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert_eq!(&ids[..6], &[d.layers[0].id.0, 10, 11, 12, 13, 14], "the other art keeps its ids"); + assert_eq!(ids[8], 15, "a unique id is kept"); + assert!(ids.iter().all(|id| *id < d.peek_next_id()), "{ids:?} {}", d.peek_next_id()); + assert!(Arc::ptr_eq(&d.symbols[0].art, &before.symbols[0].art) && Arc::ptr_eq(&d.mounted_art[1].0, &before.mounted_art[1].0)); + d.stored_node_mut(NodeId(15)).unwrap().name = Some("changed".into()); + assert_eq!(d.mounted_art[1].0.name.as_deref(), Some("changed")); + assert_eq!(d.number_mounted_art(), 0, "a second pass changes nothing"); + } + #[test] fn renumbered_objects_on_path_keep_their_path_and_members() { // A symbol's Objects on Path group whose path repeats the layer's object 10 and whose diff --git a/crates/engine/src/cmd/addons/image/mask.rs b/crates/engine/src/cmd/addons/image/mask.rs index 786effbd2e..0dd8fabe3c 100644 --- a/crates/engine/src/cmd/addons/image/mask.rs +++ b/crates/engine/src/cmd/addons/image/mask.rs @@ -320,7 +320,8 @@ fn fresh(doc: &Document, n: &Node, cmd: &str) -> Option, doc: &mut Document) { diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index 99295e5c79..fa8ef06856 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -144,9 +144,10 @@ fn find(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(_) => { // Images and placed documents in opacity-mask art and in the art of symbols, graph - // designs and patterns are named by their ids too (`links.list`). + // designs, patterns and stored brushes are named by their ids too (`links.list`). let ids = ids_param(p, "ids", C)?.unwrap_or_default(); - let kind = |id: &NodeId| st.doc.stored_node(*id).map(|(n, _)| &n.kind); + let doc = super::links::with_brush_art(&st.doc); + let kind = |id: &NodeId| doc.stored_node(*id).map(|(n, _)| &n.kind); if let Some(value) = ids.iter().find(|id| kind(id).is_none()).map(|id| id.0) { return Err(bad(C, format!("no such object id {value}"))); } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 3e1bc8b2ef..b5529abb2f 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -27,6 +27,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use serde_json::{Value, json}; +use vectorcraft_brush::stored_art::StoredArt; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{ Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, @@ -44,7 +45,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs and patterns)} 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), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs, patterns and the art of stored brushes)} 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), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -53,7 +54,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{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. Images and placed documents inside symbols, graph designs, patterns and opacity masks have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", + "{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. Images and placed documents inside symbols, graph designs, patterns, opacity masks and the art of stored brushes have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", has_doc, update ), @@ -71,7 +72,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs and patterns) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page 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], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs, patterns and the art of stored brushes) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page 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], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", has_doc, relink ), @@ -80,7 +81,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, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs and patterns (one row per stored object, never one per symbol instance or pattern tile) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern, name? (of the symbol, graph design or pattern), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs, patterns and stored brushes (one row per stored object, never one per symbol instance, pattern tile or brushed stroke) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern|brush, name? (of the symbol, graph design, pattern or brush), index? (a brush's place in the Brushes panel, from 0), tile?: side|outerCorner|innerCorner|start|end (a pattern brush's tile), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -89,7 +90,7 @@ pub fn specs() -> Vec { "Go To Link", [], None, - "{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], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design or pattern has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)", + "{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], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design, pattern or stored brush has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)", has_doc, go_to ), @@ -198,6 +199,8 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { /// visited before it has (art being edited on a layer and its definition) counts once, by its /// first place; without `ids` its content is still grouped. fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { + let view = with_brush_art(d); + let d = &*view; let mut out: Vec = vec![]; let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); // Looked up for every linked node: a set, not the list (which may repeat an id many times). @@ -234,13 +237,52 @@ fn stored(d: &Document, id: NodeId) -> Option<&Node> { d.stored_node(id).map(|(n, _)| &**n) } +/// `d` with the art of its stored brushes set on it ([`vectorcraft_brush::stored_art`]), so that +/// the images and placed documents in that art are visited and found by their ids as stored art; +/// `d` itself when no stored brush's art holds one or the art is set on it already. +pub(crate) fn with_brush_art(d: &Document) -> Cow<'_, Document> { + if !d.mounted_art.is_empty() { + return Cow::Borrowed(d); + } + let art = StoredArt::read(d); + if art.is_empty() { + return Cow::Borrowed(d); + } + let mut d = d.clone(); + art.mount(&mut d); + Cow::Owned(d) +} + +/// `f` run on `d` with the art of its stored brushes set on it as [`with_brush_art`] sets it; the +/// brushes whose art `f` changed are then written back into the library, the color masks of its +/// images made for their pixels first. The ids it gives that art are the ones [`with_brush_art`] +/// gives it in the same document. +pub(crate) fn editing_brush_art(d: &mut Document, f: impl FnOnce(&mut Document) -> T) -> T { + if !d.mounted_art.is_empty() { + return f(d); + } + let art = StoredArt::read(d); + if art.is_empty() { + return f(d); + } + art.mount(d); + let before: Vec> = d.mounted_art.iter().map(|(n, _)| n.clone()).collect(); + let out = f(d); + if d.mounted_art.len() != before.len() || d.mounted_art.iter().zip(&before).any(|((n, _), b)| !Arc::ptr_eq(n, b)) { + super::refresh_color_masks(None, d); + } + art.write_back(d); + out +} + /// Of `ids`, the objects of `d` outside the layer tree (where [`Document::node`] finds none). fn nested_ids(d: &Document, ids: &[NodeId]) -> std::collections::HashSet { ids.iter().copied().filter(|id| d.node(*id).is_none()).collect() } /// `links.list`'s `owner` of an object stored at `place` of `d`, when it is not in the layer tree -/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`. +/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, or for one in the art of a +/// stored brush, `{kind: brush, name, index, tile?, maskOf?}`. fn owner_json(d: &Document, place: StoredPlace) -> Option { if place.in_layers() { return None; @@ -250,6 +292,7 @@ fn owner_json(d: &Document, place: StoredPlace) -> Option { StoredRoot::Symbol(i) => json!({ "kind": "symbol", "name": d.symbols.get(i).map(|s| s.name.as_str()) }), StoredRoot::GraphDesign(i) => json!({ "kind": "graphDesign", "name": d.graph_designs.get(i).map(|g| g.name.as_str()) }), StoredRoot::Pattern { pattern, .. } => json!({ "kind": "pattern", "name": d.patterns.get(pattern).map(|p| p.name.as_str()) }), + StoredRoot::Mounted(i) => d.mounted_art.get(i).map_or_else(|| json!({}), |(_, owner)| owner.clone()), }; if let Some(m) = place.mask_of { owner["maskOf"] = json!(m.0); @@ -479,7 +522,8 @@ pub(crate) fn linked_files(d: &Document, base: LinkBase) -> Vec { /// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only). pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a, Document>, Vec) { let mut keys = BTreeSet::new(); - d.visit_images(|_, im| { + // The art of the stored brushes draws its images too. + with_brush_art(d).visit_images(|_, im| { if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) { keys.insert(im.key.clone()); } @@ -747,7 +791,7 @@ struct Nested<'a> { impl Nested<'_> { /// Every root of `d`: the layers (whose own objects are left alone), then the art of symbols, - /// graph designs and patterns. + /// graph designs and patterns, then [`Document::mounted_art`]. fn run(mut self, d: &mut Document) { let first = d.peek_next_id(); self.next = first; @@ -757,7 +801,7 @@ impl Nested<'_> { for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) { self.root(root); } - for root in d.patterns.iter_mut().flat_map(|p| &mut p.art) { + for root in d.patterns.iter_mut().flat_map(|p| &mut p.art).chain(d.mounted_art.iter_mut().map(|(n, _)| n)) { self.root(root); } for _ in first..self.next { @@ -1229,8 +1273,14 @@ impl Resolved { /// found away from their path take the new path. A link nothing is known of whose image, or placed /// document, shows a preview (a file another app wrote kept only that) was never read: its file is /// read now, into the object's bounds, which isn't a change to report. A placed document whose -/// file's bytes are gone isn't showing a preview. Nothing here is an undo step. +/// file's bytes are gone isn't showing a preview. Links in the art of the stored brushes count too, +/// and that art keeps the ids they are listed by. Nothing here is an undo step. pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved { + editing_brush_art(d, |d| resolve_links(d, base, update, low_res_proxy_eps)) +} + +/// [`resolve`] in the art `d` stores. +fn resolve_links(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); // The image (with its preview) and hash of each file read for a preview here, by path: images // of other keys linking to the same file share them, rather than each reading, decoding and @@ -1381,8 +1431,8 @@ fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { } /// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open -/// documents, in the layers, symbols, graph designs, patterns and opacity masks: read again from -/// the new file, each as one Update Links step of its document. +/// documents, in the layers, symbols, graph designs, patterns, opacity masks and the art of the +/// stored brushes: 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 = s @@ -1392,7 +1442,7 @@ pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) { .filter(|(_, st)| st.uid != saved) .filter(|(_, st)| { let mut placed = false; - st.doc.visit_placed(|_, p| placed |= p.link.path == path); + with_brush_art(&st.doc).visit_placed(|_, p| placed |= p.link.path == path); placed }) .map(|(i, _)| i) @@ -1449,7 +1499,7 @@ fn link_target(link: &LinkInfo, base: LinkBase) -> PathBuf { /// `d` (looking from `base`) as saved to `dest`: the links' paths relative to its folder written /// in; `None` when they are already. A link whose file is not at its path is written relative to /// where its file is found, else to the place its relative path names. Links in the layers, -/// symbols, graph designs, patterns and opacity masks are all set. +/// symbols, graph designs, patterns, opacity masks and the art of the stored brushes are all set. pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option { let dest = absolute_path(dest); let dir = Path::new(&dest).parent()?; @@ -1458,23 +1508,29 @@ pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option); @@ -1629,7 +1685,9 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - let updated: Vec = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect(); let warnings = spot_swatch_notes(&st.doc, reads.iter().map(|(_, read)| read)); if !reads.is_empty() { - s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?; + s.edit("Update Links", |d, _| { + editing_brush_art(d, |d| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g)))) + })?; } let mut out = json!({ "updated": updated, "missing": missing }); if !warnings.is_empty() { @@ -1756,9 +1814,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?) } }; + let doc = with_brush_art(&st.doc); // An image or a placed document, with its link. let image = |id: &NodeId| { - stored(&st.doc, *id).and_then(|n| match &n.kind { + stored(&doc, *id).and_then(|n| match &n.kind { NodeKind::Image(im) => Some(im.link.as_ref()), NodeKind::PlacedDocument(p) => Some(Some(&p.link)), _ => None, @@ -1788,7 +1847,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); let mut given: BTreeSet = ids.iter().copied().collect(); let mut more = vec![]; - st.doc.visit_stored(|n, _| { + doc.visit_stored(|n, _| { if let Some((l, _, _)) = linked(n) && paths.contains(l.path.as_str()) && n.id.0 != 0 @@ -1799,7 +1858,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { }); ids.extend(more); } - if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&st.doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { + if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. @@ -1835,7 +1894,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let mut images = vec![]; let mut documents: Vec<((u32, bool, PageBox), Vec)> = vec![]; for id in ids { - match stored(&st.doc, id).map(|n| &n.kind) { + match stored(&doc, id).map(|n| &n.kind) { Some(NodeKind::PlacedDocument(pl)) => { let k = (pl.page(), pl.bounding, pl.page_box); match documents.iter_mut().find(|(e, _)| *e == k) { @@ -1886,9 +1945,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { } } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); + drop(doc); let warnings = spot_swatch_notes(&s.doc()?.doc, reads.iter().map(|(_, read)| read)); if !reads.is_empty() { - s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?; + s.edit("Relink", |d, _| editing_brush_art(d, |d| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None))))?; } not_found.sort(); not_found.dedup(); @@ -1998,14 +2058,15 @@ 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() } -/// `id`, else the first selected image. -fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { +/// `id` (looked up in `doc`, the document of `st` as [`with_brush_art`] gives it), else the first +/// selected image. +fn image_param(st: &crate::DocState, doc: &Document, p: &Value, cmd: &str) -> Result { let id = object_param(p, "id", cmd)? .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) { + match image_of(doc, id) { Some(_) => Ok(id), - None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image", id.0))), } } @@ -2028,13 +2089,14 @@ fn placement_of(d: &Document, id: NodeId) -> Option { } } -/// `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()); +/// `id` (looked up in `doc`, the document of `st` as [`with_brush_art`] gives it), else the first +/// selected image or placed document. +fn linked_param(st: &crate::DocState, doc: &Document, p: &Value, cmd: &str) -> Result { + let first = || st.selection.objects.iter().copied().find(|id| placement_of(doc, *id).is_some()); let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; - match placement_of(&st.doc, id) { + match placement_of(doc, id) { Some(_) => Ok(id), - None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))), } } @@ -2141,10 +2203,12 @@ fn list(s: &mut Session, p: &Value) -> Result { let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() }; let statuses = statuses(st); // The layer tree's objects, top first as the Layers panel lists them, then the others in the - // order they are stored (opacity-mask art, symbols, graph designs, patterns), each once. + // order they are stored (opacity-mask art, symbols, graph designs, patterns, the art of the + // stored brushes), each once. + let doc = with_brush_art(&st.doc); let (mut objects, mut nested) = (vec![], vec![]); let mut seen = std::collections::HashSet::new(); - st.doc.visit_stored(|n, place| { + doc.visit_stored(|n, place| { if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) && n.id.0 != 0 && seen.insert(n.id) { if place.in_layers() { objects.push((n, place)) } else { nested.push((n, place)) } } @@ -2196,7 +2260,7 @@ fn list(s: &mut Session, p: &Value) -> Result { if preview { row["preview"] = json!(true); } - if let Some(owner) = owner_json(&st.doc, place) { + if let Some(owner) = owner_json(&doc, place) { row["owner"] = owner; } if interactions.contains(&n.id) { @@ -2218,10 +2282,13 @@ fn list(s: &mut Session, p: &Value) -> Result { fn go_to(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = linked_param(st, p, "links.goTo")?; - // An object in mask art or a definition has no place of its own on the canvas: the selection - // is cleared, and the reply names what holds it. - if let Some(owner) = st.doc.stored_node(id).and_then(|(_, place)| owner_json(&st.doc, place)) { + let doc = with_brush_art(&st.doc); + let id = linked_param(st, &doc, p, "links.goTo")?; + // An object in mask art, a definition or a stored brush's art has no place of its own on the + // canvas: the selection is cleared, and the reply names what holds it. + let owner = doc.stored_node(id).and_then(|(_, place)| owner_json(&doc, place)); + drop(doc); + if let Some(owner) = owner { s.select(|_, sel| sel.set([]))?; return Ok(json!({ "id": id.0, "onCanvas": false, "owner": owner })); } @@ -2233,7 +2300,8 @@ 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 kind = |id: &NodeId| stored(&st.doc, *id).map(|n| &n.kind); + let doc = with_brush_art(&st.doc); + let kind = |id: &NodeId| stored(&doc, *id).map(|n| &n.kind); let linked = |id: &NodeId| match kind(id) { Some(NodeKind::Image(im)) => im.link.is_some(), Some(NodeKind::PlacedDocument(_)) => true, @@ -2285,17 +2353,20 @@ fn embed(s: &mut Session, p: &Value) -> Result { _ => missing.extend(g.ids), } } + drop(doc); if !done.is_empty() || !documents.is_empty() { s.edit("Embed", |d, _| { - for (ids, read) in reads { - install(d, &ids, read, Some(Preserve::Bounds), None)?; - } - for id in &done { - if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) { - im.link = None; + editing_brush_art(d, |d| { + for (ids, read) in reads { + install(d, &ids, read, Some(Preserve::Bounds), None)?; } - } - documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C)) + for id in &done { + if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) { + im.link = None; + } + } + documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C)) + }) })?; } done.extend(documents); @@ -2318,9 +2389,10 @@ fn convert_image(bytes: &[u8], ext: &str) -> std::result::Result, String 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 = stored(&st.doc, 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)))?; + let doc = with_brush_art(&st.doc); + let id = image_param(st, &doc, p, C)?; + let n = stored(&doc, id).ok_or(crate::EngineError::NoNode(id))?; + let im = image_of(&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))); } @@ -2345,7 +2417,8 @@ fn unembed(s: &mut Session, p: &Value) -> Result { let img = linked_image(&bytes, Some(&path))?; write_file(&path, &bytes)?; let read = (link_info(&path, &bytes), img); - s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?; + drop(doc); + s.edit("Unembed", |d, _| editing_brush_art(d, |d| install(d, &[id], read, Some(Preserve::Bounds), None)))?; Ok(json!({ "id": id.0, "path": path })) } @@ -2403,9 +2476,10 @@ fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) { (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 = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; +/// Link Info of placed document `id` (looked up in `doc`, the document of `st` as +/// [`with_brush_art`] gives it). +fn placed_info(st: &crate::DocState, doc: &Document, id: NodeId) -> Result { + let n = stored(doc, 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)); @@ -2430,13 +2504,19 @@ fn placed_info(st: &crate::DocState, id: NodeId) -> Result { fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "links.info"; - let id = linked_param(s.doc()?, p, C)?; - if image_of(&s.doc()?.doc, id).is_none() { - return placed_info(s.doc()?, id); - } + let id = { + let st = s.doc()?; + let doc = with_brush_art(&st.doc); + let id = linked_param(st, &doc, p, C)?; + if image_of(&doc, id).is_none() { + return placed_info(st, &doc, id); + } + 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 doc = with_brush_art(&st.doc); + let im = image_of(&doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; let (px, py) = file_pt(&st.doc, &im.key); out["effectivePpi"] = out["ppi"].take(); out["ppi"] = json!([72.0 / px, 72.0 / py]); @@ -2453,9 +2533,10 @@ 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 doc = with_brush_art(&st.doc); let ids = - ids_param(p, "ids", C)?.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()) { + ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&doc, *id).is_some()).collect()); + if let Some(id) = ids.iter().find(|id| placement_of(&doc, **id).is_none()) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } if ids.is_empty() { @@ -2474,7 +2555,8 @@ 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| placement_of(&st.doc, *id)).unwrap_or_default(); + let first = ids.first().and_then(|id| placement_of(&doc, *id)).unwrap_or_default(); + drop(doc); if preserve.is_none() && align.is_none() && clip.is_none() { return Ok(json!({ "placement": placement_json(first) })); } @@ -2484,11 +2566,13 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { o.clip = clip.unwrap_or(o.clip); }; s.edit("Placement Options", |d, _| { - for id in &ids { - if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) { - set(o); + editing_brush_art(d, |d| { + for id in &ids { + if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) { + set(o); + } } - } + }); Ok(()) })?; let mut shown = first; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 8d6f53d55b..315a737c59 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -229,7 +229,7 @@ impl<'a> Collector<'a> { Some((d, base)) => super::links::linked_files(d, base), None => super::links::linked_files(doc, LinkBase::file(Some(source))), }; - doc.visit_placed(|_, p| { + super::links::with_brush_art(doc).visit_placed(|_, p| { let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box); let pages = placed.entry(p.link.path.clone()).or_default(); if !pages.contains(&page) { @@ -389,16 +389,20 @@ impl<'a> Collector<'a> { } if self.opts.relink { // The child files have already been rewritten; the hash and size of each - // target must reflect the *packaged* bytes rather than the original. - doc.update_links(|link: &mut LinkInfo| { - if let Some(copy) = local.get(&link.path) { - let relative = relative_in_package(destination, ©.name); - link.path = self.root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(©.name).to_string_lossy().into_owned()); - link.relative = Some(relative); - link.size = copy.size; - link.hash = copy.hash.clone(); - link.modified = None; - } + // target must reflect the *packaged* bytes rather than the original. The links in the + // art of the stored brushes are written back into the brush library. + let root = &self.root; + super::links::editing_brush_art(doc, |doc| { + doc.update_links(|link: &mut LinkInfo| { + if let Some(copy) = local.get(&link.path) { + let relative = relative_in_package(destination, ©.name); + link.path = root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(©.name).to_string_lossy().into_owned()); + link.relative = Some(relative); + link.size = copy.size; + link.hash = copy.hash.clone(); + link.modified = None; + } + }) }); } if let Some(s) = self.sections.get_mut(section) { diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 20f19ef803..a558545e4e 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -962,8 +962,10 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { (None, [id]) => *id, _ => return Err(bad(C, "select one image or pass id")), }; - // An image in opacity-mask art or a definition's art is found by its id too (`links.list`). - let n = st.doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?; + // An image in opacity-mask art, a definition's art or a stored brush's art is found by its id + // too (`links.list`). + let doc = super::links::with_brush_art(&st.doc); + let n = doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) }; // Effective resolution: 72 pt per inch over the points one pixel spans along each axis. let [a, b, c, d, ..] = im.xf.as_coeffs(); diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 8e4afaa295..8b00e95152 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -330,7 +330,8 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) } /// `d` for output: linked images and placed documents that keep only their previews get their -/// files back, else output as their previews, with a warning. +/// files back, else output as their previews, with a warning. The art of the stored brushes counts +/// too. pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { let (d, mut warnings) = super::super::links::full_linked_images(d, Default::default()); let doc = match &d { @@ -339,7 +340,7 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { }; let mut need: Vec = vec![]; let mut seen = std::collections::HashSet::new(); - doc.visit_placed(|_, p| { + super::super::links::with_brush_art(doc).visit_placed(|_, p| { if doc.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && seen.insert(p.key.clone()) { need.push(p.clone()); } @@ -370,7 +371,7 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { "{n} placed documents output as their previews: their files can't be found, or changed since they were read (relink or update them)" ), }); - out.placed_as_previews(&previews); + super::super::links::editing_brush_art(&mut out, |out| out.placed_as_previews(&previews)); } (Cow::Owned(out), warnings) } diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index e683b156e1..62d3e36bad 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -768,6 +768,223 @@ fn nested_objects_that_repeat_ids_open_with_ids_of_their_own() { assert!(image(&s, layer_image).link.is_some(), "the image in the layers stays linked"); } +/// The files of the linked images that the stored brushes of [`brush_links`] are made of: a scatter +/// brush's, an art brush's and a pattern brush's side tile. +const BRUSHED: [(&str, &str, &str); 3] = [("scatter.png", "scatter", "Specks"), ("art.png", "art", "Band"), ("side.png", "pattern", "Frame")]; + +/// A document whose stored brushes are made of [`BRUSHED`]'s linked images in `dir`. The art +/// brush's image is still in the layers too, so its brush art repeats that object's id; the other +/// two images were cleared from the layers. +fn brush_links(dir: &Folder) -> (Session, NodeId) { + for (i, (name, ..)) in (0u8..).zip(BRUSHED) { + write(&dir.file(name), &png(20, 10, [30 + 60 * i, 120, 90])); + } + let mut s = session(); + let mut kept = NodeId(0); + for (name, kind, brush) in BRUSHED { + let id = place(&mut s, &dir.file(name)); + s.execute("brush.new", &json!({"type": kind, "name": brush, "ids": [id.0]})).unwrap(); + if kind == "art" { + kept = id; + } else { + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + } + } + s.execute("select.set", &json!({"ids": []})).unwrap(); + (s, kept) +} + +/// The image in the art of stored brush `name`, as the document stores it. +fn brush_image(s: &Session, name: &str) -> ImageObject { + let b = vectorcraft_brush::find(&s.doc().unwrap().doc, name).unwrap_or_else(|| panic!("no brush {name}")); + let mut found = None; + b.art()[0].walk(&mut |n| { + if let (None, NodeKind::Image(im)) = (&found, &n.kind) { + found = Some((**im).clone()); + } + }); + found.unwrap_or_else(|| panic!("no image in brush {name}")) +} + +/// The id of the image in the art of stored brush `name`, as the document stores it. +fn brush_image_id(s: &Session, name: &str) -> u64 { + let b = vectorcraft_brush::find(&s.doc().unwrap().doc, name).unwrap(); + let mut found = None; + b.art()[0].walk(&mut |n| { + if found.is_none() && matches!(n.kind, NodeKind::Image(_)) { + found = Some(n.id.0); + } + }); + found.unwrap() +} + +/// Images in the art of stored brushes are listed after the others, one row per image in each brush tile, +/// each with an id no other object has (the layer tree keeps its own), the brush named as what holds it; Check +/// Links and the missing filter count them, and the ids stay the same from one list to the next. +#[test] +fn links_in_stored_brush_art_are_listed_and_checked_with_ids() { + let dir = Folder::new("brush-ids"); + let (mut s, kept) = brush_links(&dir); + let list = s.execute("links.list", &json!({})).unwrap(); + let rows = list["links"].as_array().unwrap(); + let owners: Vec<(&str, &Value)> = rows.iter().map(|r| (r["name"].as_str().unwrap(), &r["owner"])).collect(); + let library = vectorcraft_brush::library(&s.doc().unwrap().doc); + let index = |name: &str| library.iter().position(|b| b.name == name).unwrap(); + assert_eq!( + owners, + [ + ("art.png", &Value::Null), + ("scatter.png", &json!({"kind": "brush", "name": "Specks", "index": index("Specks")})), + ("art.png", &json!({"kind": "brush", "name": "Band", "index": index("Band")})), + ("side.png", &json!({"kind": "brush", "name": "Frame", "index": index("Frame"), "tile": "side"})), + ], + "{list}" + ); + assert_eq!(rows[0]["id"], json!(kept.0), "the layer tree keeps its ids: {list}"); + let ids: std::collections::BTreeSet = rows.iter().map(|r| r["id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 4, "{list}"); + assert!(!ids.contains(&0), "{list}"); + assert!(rows.iter().all(|r| r["status"] == "ok" && r["linked"] == true), "{list}"); + assert_eq!(s.execute("links.list", &json!({})).unwrap(), list, "the same ids each time"); + let other = super::rect(&mut s, 0.0, 0.0, 5.0, 5.0); + s.execute("edit.clear", &json!({"ids": [other.0]})).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap(), list, "the same ids after other objects are made"); + let check = s.execute("links.check", &json!({})).unwrap(); + assert_eq!(check["links"].as_array().unwrap().len(), 3, "{check}"); + std::fs::remove_file(dir.file("side.png")).unwrap(); + let check = s.execute("links.check", &json!({})).unwrap(); + assert_eq!(check["missing"].as_u64(), Some(1), "{check}"); + let missing = s.execute("links.list", &json!({"show": "missing"})).unwrap(); + assert_eq!(missing["links"][0]["id"], rows[3]["id"], "{missing}"); +} + +/// Every links command takes the id of an image in a stored brush's art and acts on that brush's art, +/// never on the image in the layers whose id the art repeated: Link Info, Go To (which clears the +/// selection and names the brush), Placement Options, Relink, Update and Embed. The ids each change +/// gives the art are kept in the library, so the next list names the same rows. +#[test] +fn links_commands_act_on_images_in_stored_brush_art_by_id() { + let dir = Folder::new("brush-act"); + let (mut s, kept) = brush_links(&dir); + let band = row_id_owned(&mut s, "art.png", "brush"); + assert_ne!(band, kept.0); + let info = s.execute("links.info", &json!({"id": band})).unwrap(); + assert_eq!((info["fileName"].as_str(), info["status"].as_str(), info["id"].as_u64()), (Some("art.png"), Some("ok"), Some(band)), "{info}"); + s.execute("select.set", &json!({"ids": [kept.0]})).unwrap(); + let go = s.execute("links.goTo", &json!({"id": band})).unwrap(); + assert_eq!( + (go["onCanvas"].clone(), go["owner"]["kind"].clone(), go["owner"]["name"].clone()), + (json!(false), json!("brush"), json!("Band")), + "{go}" + ); + assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing on the canvas is selected"); + s.execute("links.placementOptions", &json!({"ids": [band], "preserve": "fit"})).unwrap(); + assert_eq!(s.execute("links.placementOptions", &json!({"ids": [band]})).unwrap()["placement"]["preserve"], "fit"); + assert_eq!(brush_image(&s, "Band").placement.preserve, vectorcraft_doc::links::Preserve::Fit); + assert_eq!(brush_image_id(&s, "Band"), band, "the brush keeps the id it is listed by"); + assert_ne!(image(&s, kept).placement.preserve, vectorcraft_doc::links::Preserve::Fit, "the image in the layers is left as it was"); + // Relink: the brush's image shows the new file; the image in the layers keeps its file. + let other = dir.file("other.png"); + write(&other, &png(40, 20, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [band], "path": other})).unwrap(); + assert_eq!(r["relinked"], json!([band]), "{r}"); + assert_eq!(brush_image(&s, "Band").link.unwrap().path, other); + assert_eq!(image(&s, kept).link.unwrap().path, dir.file("art.png")); + assert_eq!(row_id_owned(&mut s, "other.png", "brush"), band, "{}", s.execute("links.list", &json!({})).unwrap()); + // Update: a changed file is read again for the scatter brush's image. + let dots = row_id_owned(&mut s, "scatter.png", "brush"); + let changed = png(30, 30, RED); + write(&dir.file("scatter.png"), &changed); + let r = s.execute("links.update", &json!({"ids": [dots]})).unwrap(); + assert_eq!(r["updated"], json!([dots]), "{r}"); + assert_eq!(brush_image(&s, "Specks").link.unwrap().size, Some(changed.len() as u64)); + // Embed: the pattern brush's side tile keeps its pixels and no longer links. + let frame = row_id_owned(&mut s, "side.png", "brush"); + let r = s.execute("links.embed", &json!({"ids": [frame]})).unwrap(); + assert_eq!(r["embedded"], json!([frame]), "{r}"); + assert!(brush_image(&s, "Frame").link.is_none()); + let embedded = s.execute("links.list", &json!({"show": "embedded"})).unwrap(); + assert_eq!(embedded["links"][0]["id"], json!(frame), "{embedded}"); + // Relink All Instances takes the image in the layers and the brush's image of the same file. + let r = s.execute("links.relink", &json!({"ids": [kept.0], "path": dir.file("scatter.png"), "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([kept.0]), "{r}"); + let r = s.execute("links.relink", &json!({"ids": [dots], "path": other, "scope": "all"})).unwrap(); + let mut relinked: Vec = r["relinked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + relinked.sort(); + let mut both = vec![kept.0, dots]; + both.sort(); + assert_eq!(relinked, both, "{r}"); + assert_eq!(image(&s, kept).link.unwrap().path, other); + // One undo step puts the brushes back as they were. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(brush_image(&s, "Specks").link.unwrap().path, dir.file("scatter.png")); +} + +/// The id of the `links.list` row of file `name` whose owner is of `kind`. +fn row_id_owned(s: &mut Session, name: &str, kind: &str) -> u64 { + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == name && r["owner"]["kind"] == kind); + row.unwrap_or_else(|| panic!("{name} in {kind}: {list}"))["id"].as_u64().unwrap() +} + +/// A document saved with links in its stored brushes opens with them reported by the ids the list +/// gives them, and a library entry that does not read as a brush is kept as it was. +#[test] +fn links_in_stored_brush_art_open_by_id_and_unreadable_entries_stay() { + let dir = Folder::new("brush-open"); + let (mut s, _) = brush_links(&dir); + let odd = json!({"name": "Odd", "type": "unknown", "art": {"id": 3}}); + s.edit("Odd brush", |d, _| { + if let Some(list) = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut) { + list.push(odd.clone()); + } + Ok(()) + }) + .unwrap(); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + std::fs::remove_file(dir.file("scatter.png")).unwrap(); + let r = open(&mut s, &path); + let dots = row_id_owned(&mut s, "scatter.png", "brush"); + assert_eq!(r["missingLinks"][0]["ids"], json!([dots]), "{r}"); + assert_eq!(brush_image_id(&s, "Specks"), dots, "the library keeps the ids opening gave"); + let band = row_id_owned(&mut s, "art.png", "brush"); + s.execute("links.embed", &json!({"ids": [band]})).unwrap(); + let doc = &s.doc().unwrap().doc; + let stored = doc.unknown[vectorcraft_brush::DOC_KEY].as_array().unwrap(); + assert_eq!(stored.last(), Some(&odd), "the unreadable entry is left as it was"); + assert!(doc.mounted_art.is_empty()); +} + +/// The Color Mask of an image in a stored brush's art is made again for the new pixels when Update +/// reads the image's changed file. +#[test] +fn updating_an_image_in_stored_brush_art_makes_its_color_mask_again() { + let dir = Folder::new("brush-mask"); + let path = dir.file("logo.png"); + write(&path, &png(12, 10, RED)); + let mut s = session(); + let id = place(&mut s, &path); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let hide_white = json!({"background": "colors", "colors": "#ffffff", "contiguous": false, "tolerance": 0, "softness": 0}); + s.execute("addon.image.colorMask", &hide_white).unwrap(); + s.execute("brush.new", &json!({"type": "scatter", "name": "Masked", "ids": [id.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + write(&path, &png(14, 10, BLUE)); + let row = row_id_owned(&mut s, "logo.png", "brush"); + let r = s.execute("links.update", &json!({"ids": [row]})).unwrap(); + assert_eq!(r["updated"], json!([row]), "{r}"); + let doc = &s.doc().unwrap().doc; + let brush = vectorcraft_brush::find(doc, "Masked").unwrap(); + let mut masks = vec![]; + brush.art()[0].walk(&mut |n| { + if let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) { + masks.push((m.made_for(im), doc.images.contains_key(&m.key))); + } + }); + assert_eq!(masks, [(true, true)], "the mask is made for the new pixels"); +} + /// An image that Placement Options clip, alone in a group the user made, goes into a new clip group /// inside that group when its file grows, in the layers and in a symbol's art; the user's group /// stays a plain group. diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index e61c4bb4a4..952693d7c1 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -90,6 +90,38 @@ fn two_links_and_a_font_are_collected_and_relinked() { assert!(s.doc().unwrap().doc.images.values().all(|b| !b.is_proxy()), "the copies' pixels"); } +/// A file linked only from a stored brush's art is copied, and the packaged document's brush +/// links to the copy. +#[test] +fn a_file_linked_from_a_stored_brush_is_collected_and_relinked() { + let dir = Folder::new("package-brush"); + let file = dir.file("art/leaf.png"); + write(&file, &png(40, 20, RED)); + let mut s = session(); + let id = place(&mut s, &file); + run(&mut s, "brush.new", json!({"type": "scatter", "name": "Leaves Here", "ids": [id.0]})); + run(&mut s, "edit.clear", json!({"ids": [id.0]})); + save(&mut s, &dir.file("poster.vectorcraft")); + let out = dir.file("out"); + let r = run(&mut s, "file.package", json!({"folder": out, "copyFonts": false})); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert!(files.contains(&"Links/leaf.png"), "{r}"); + let root = std::path::Path::new(&out).join("poster Folder"); + std::fs::remove_dir_all(dir.0.join("art")).unwrap(); + let r = open(&mut s, &root.join("poster.vectorcraft").to_string_lossy()); + assert_eq!(r["missingLinks"], json!([]), "{r}"); + let brush = vectorcraft_brush::find(&s.doc().unwrap().doc, "Leaves Here").unwrap(); + let mut paths = vec![]; + brush.art()[0].walk(&mut |n| { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + paths.push(im.link.as_ref().map(|l| (l.path.clone(), l.relative.clone()))); + } + }); + let [Some((path, relative))] = paths.as_slice() else { panic!("{paths:?}") }; + assert!(std::path::Path::new(path).starts_with(root.join("Links")), "{path}"); + assert_eq!(relative.as_deref(), Some("Links/leaf.png")); +} + #[test] fn an_unsaved_document_cant_be_packaged() { let mut s = session(); diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 2420ef265c..c639fc9232 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1042,6 +1042,57 @@ fn saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns() { assert_eq!(hashes, [hash.clone(), hash], "the symbol's, then the pattern's"); } +/// Saving a document reads it again for its placed copy in the art of a stored brush of another +/// open document, the only place that document shows it. +#[test] +fn saving_a_document_refreshes_its_placed_copy_in_a_stored_brush() { + let dir = Folder::new("placed-refresh-brush"); + 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("brush.new", &json!({"type": "scatter", "name": "Logos", "ids": [id.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let parent = s.active_index().unwrap(); + 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 hash = Some(vectorcraft_doc::links::hash_bytes(&std::fs::read(&src).unwrap())); + let doc = &s.documents()[parent]; + assert_eq!(doc.history.undo.last().unwrap().label, "Update Links"); + let brush = vectorcraft_brush::find(&doc.doc, "Logos").unwrap(); + let mut hashes = vec![]; + brush.art()[0].walk(&mut |n| { + if let NodeKind::PlacedDocument(p) = &n.kind { + hashes.push(p.link.hash.clone()); + } + }); + assert_eq!(hashes, [hash]); +} + +/// A placed document kept only in a stored brush's art, whose file is gone and of which the +/// document holds only the preview, is output as its preview with the warning that says so. +#[test] +fn a_placed_document_in_a_stored_brush_without_its_file_is_output_as_its_preview() { + let dir = Folder::new("placed-brush-preview"); + 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, 3); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("brush.new", &json!({"type": "scatter", "name": "Logos", "ids": [id.0]})).unwrap(); + // The copy in the layers has the save keep only the preview. + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + 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}"); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert!(svg["warnings"].to_string().contains("1 placed document output as its preview"), "{svg}"); +} + #[test] fn link_info_gives_the_artboards_size_not_pixels() { let dir = Folder::new("placed-info"); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 2fb130ae3e..f9d9b524c9 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -360,8 +360,8 @@ fn badge(ui: &Ui, r: egui::Rect, status: &str) { } /// The tooltip of row `r`. A row outside the layer tree also names where it is stored -/// (`links.list`'s `owner`): the symbol, graph design or pattern whose art holds it, and the object -/// whose opacity mask holds it, by `mask` (that object's name) or else by its id. +/// (`links.list`'s `owner`): the symbol, graph design, pattern or brush whose art holds it, and the +/// object whose opacity mask holds it, by `mask` (that object's name) or else by its id. pub(super) fn status_tip(r: &Value, mask: Option) -> String { let tip = file_tip(r); let owner = &r["owner"]; @@ -370,6 +370,7 @@ pub(super) fn status_tip(r: &Value, mask: Option) -> String { Some("symbol") => Some(tl!("Symbols")), Some("graphDesign") => Some(tl!("Graph Design")), Some("pattern") => Some(tl!("Pattern")), + Some("brush") => Some(tl!("Brushes")), _ => None, }; let tip = match art { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 8055c1288c..6b773e9d3b 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -189,6 +189,7 @@ fn a_nested_rows_tooltip_names_what_holds_it() { assert_eq!(tip(json!({"kind": "symbol", "name": "Mark"}), None), "/x/a.png\nSymbols: Mark"); assert_eq!(tip(json!({"kind": "pattern", "name": "Dots"}), None), "/x/a.png\nPattern: Dots"); assert_eq!(tip(json!({"kind": "graphDesign", "name": "Bar"}), None), "/x/a.png\nGraph Design: Bar"); + assert_eq!(tip(json!({"kind": "brush", "name": "Leaf", "index": 2}), None), "/x/a.png\nBrushes: Leaf"); assert_eq!(tip(json!({"kind": "layers", "maskOf": 7}), Some("")), "/x/a.png\nOpacity Mask: "); assert_eq!(tip(json!({"kind": "symbol", "name": "Mark", "maskOf": 7}), None), "/x/a.png\nSymbols: Mark\nOpacity Mask: 7"); // A row in the layers says only its file. diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..5d49589002 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6992,7 +6992,7 @@ comes back to the front, following the same preference. `links.check` reports ev `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other stored object linked to the same file as -one of them, in the layers, opacity-mask art, symbols, graph designs and patterns; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +one of them, in the layers, opacity-mask art, symbols, graph designs, patterns and the art of the stored brushes; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in `path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, `path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, `path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are @@ -7000,9 +7000,11 @@ errors, and nothing is relinked. Each call is one undo step. The Links panel's R (`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed -documents inside symbols, graph designs, patterns and opacity masks that show the same content. Those objects have ids +documents inside symbols, graph designs, patterns, opacity masks and the art of the stored brushes (a scatter or art +brush's art, a pattern brush's tiles) that show the same content. Those objects have ids of their own: `links.list`, `links.check`, `links.findFiles`, the `missingLinks`, `modifiedLinks` and `updatedLinks` -rows and the link watch list them, and every `links.*` command takes their ids. Opening a document gives an object +rows and the link watch list them, and every `links.*` command takes their ids. A `links.*` command that changes a +brush's art writes it back into the brush library with those ids. Opening a document gives an object there whose id is 0 or already used by another object a new id, and the next save writes it. Saving a document also reads it again for the placed documents of it inside that art in the other open documents. A PNG, JPEG, GIF or WebP file whose pixels can't be read is not shown: `links.relink` fails with "the image's pixels can't be @@ -7244,10 +7246,12 @@ listed in `warnings`. ASCII and binary DXF files are read; DWG files answer an e `links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top first, as the Links panel does, then every image and placed document in opacity-mask art and in the art of symbols, -graph designs and patterns, one row per stored object (never one per symbol instance or pattern tile): `{id, name, -linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?, page?, preview?, note?, -owner?}` (`owner`, for an object outside the layer tree: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, -`maskOf` naming the object whose opacity mask holds it; `note`: the image takes more than the decode allowance, 2 GiB +graph designs, patterns and stored brushes, one row per stored object (never one per symbol instance, pattern tile or +brushed stroke): `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, +found?, page?, preview?, note?, owner?}` (`owner`, for an object outside the layer tree: +`{kind: layers|symbol|graphDesign|pattern|brush, name?, maskOf?, index?, tile?}`, `maskOf` naming the object whose +opacity mask holds it, `index` a brush's place in the library as `brush.list` lists it and `tile` the pattern brush tile +that holds it: `side`, `outerCorner`, `innerCorner`, `start` or `end`; `note`: the image takes more than the decode allowance, 2 GiB or 512 MiB in the web app, as RGBA or as decoded, and it is drawn at the largest size within the allowance ("shown at reduced size"; the document keeps the whole file) or, when it can't be read that way or has more pixels than the allowance has bytes, drawn as a crossed gray box on the canvas and left out of exports ("too large to draw"); `document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..26f3e4035b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1772,6 +1772,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. - When a document opens with notes and with linked files that changed on disk (Update Links: Manually) or, on the web, can't be read, the status bar shows the line about the linked files, then counts the notes and names the first, and hovering it lists the notes. The same holds when the line about modified files follows the Linked File Not Found dialog, and File › New from Template… shows the notes of the file it opens as File › Open does (`an_open_with_modified_links_keeps_link_notice_and_import_notes_reachable`, `new_from_template_preserves_open_warnings`, `an_open_with_unreadable_links_keeps_import_notes_reachable`, `modified_links_after_the_missing_ones_keep_the_open_notes`, `combined_open_and_link_status_lists_notes_on_hover_and_for_accessibility`). - Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id. + - Linked images and placed documents in the art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) get Links panel rows of their own the same way, one per linked or placed object in each brush tile, naming the brush; Relink, Update, Embed, Placement Options, Link Info and Go To act on that art and write it back into the brush library, and the ids they are listed by are kept there. Evidence: `links_in_stored_brush_art_are_listed_and_checked_with_ids`, `links_commands_act_on_images_in_stored_brush_art_by_id`, `links_in_stored_brush_art_open_by_id_and_unreadable_entries_stay`. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499).