Merge pull request #6120 from dotCooCoo/fix/relative-paths-nested-links

Links: links inside symbols, patterns and masks follow a moved folder and are updated like links in the layers
This commit is contained in:
BFlat
2026-11-08 14:13:00 +08:00
committed by GitHub
6 changed files with 595 additions and 60 deletions
+96
View File
@@ -415,6 +415,68 @@ impl Document {
}
}
/// [`Self::update_links`] for the links `pick` holds for only, in one pass; only the nodes on the
/// way to one are copied.
pub fn update_links_where(&mut self, pick: impl Fn(&LinkInfo) -> bool, mut f: impl FnMut(&mut LinkInfo)) {
fn link_of(n: &Node) -> Option<&LinkInfo> {
match &n.kind {
NodeKind::Image(image) => image.link.as_ref(),
NodeKind::PlacedDocument(placed) => Some(&placed.link),
_ => None,
}
}
/// `n` updated in place, a copy of it when shared; left as it is when it holds no picked link.
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&LinkInfo) -> bool, f: &mut dyn FnMut(&mut LinkInfo)) {
// Each child is updated through a second handle, so an updated child is a new node and a new
// pointer marks a change.
let mask = n.extras().mask.as_ref().and_then(|m| {
let mut art = Arc::clone(&m.art);
update(&mut art, pick, f);
(!Arc::ptr_eq(&art, &m.art)).then_some(art)
});
let mut children = vec![];
for (i, c) in n.children().into_iter().flatten().enumerate() {
let mut new = Arc::clone(c);
update(&mut new, pick, f);
if !Arc::ptr_eq(&new, c) {
children.push((i, new));
}
}
let own = link_of(n).is_some_and(pick);
if !own && mask.is_none() && children.is_empty() {
return;
}
let n = Arc::make_mut(n);
let link = match &mut n.kind {
NodeKind::Image(image) => image.link.as_mut(),
NodeKind::PlacedDocument(placed) => Some(&mut placed.link),
_ => None,
};
if let Some(link) = link.filter(|_| own) {
f(link);
}
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
m.art = art;
}
if let Some(slots) = n.children_mut() {
for (i, c) in children {
if let Some(slot) = slots.get_mut(i) {
*slot = c;
}
}
}
}
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
update(root, &pick, &mut f);
}
}
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
/// art included) for which `pick` holds; only the subtrees holding one are copied.
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
@@ -486,6 +548,40 @@ mod tests {
.unwrap()
}
/// `update_links_where` changes only the picked links, in a copy of the nodes on the way to them;
/// the other nodes stay shared with the document it was cloned from.
#[test]
fn update_links_where_copies_only_the_nodes_it_changes() {
let mut d = Document::new(10.0, 10.0);
image(&mut d, "a", Some(LinkInfo::new("/art/a.png")));
image(&mut d, "b", Some(LinkInfo::new("/art/b.png")));
image(&mut d, "c", None);
let mut copy = d.clone();
let mut calls = 0;
copy.update_links_where(
|l| l.path == "/art/a.png",
|l| {
calls += 1;
l.relative = Some("art/a.png".into());
},
);
assert_eq!(calls, 1);
let node = |d: &Document, key: &str| {
d.layers[0].children().unwrap().iter().find(|n| matches!(&n.kind, NodeKind::Image(im) if im.key == key)).unwrap().clone()
};
let relative = |n: &Node| match &n.kind {
NodeKind::Image(im) => im.link.as_ref().and_then(|l| l.relative.clone()),
_ => None,
};
assert_eq!((relative(&node(&copy, "a")), relative(&node(&d, "a"))), (Some("art/a.png".into()), None));
assert!(Arc::ptr_eq(&node(&copy, "b"), &node(&d, "b")) && Arc::ptr_eq(&node(&copy, "c"), &node(&d, "c")));
assert!(!Arc::ptr_eq(&copy.layers[0], &d.layers[0]));
// Nothing picked: nothing is copied.
let mut same = d.clone();
same.update_links_where(|_| false, |_| {});
assert!(Arc::ptr_eq(&same.layers[0], &d.layers[0]));
}
#[test]
fn the_placeholder_is_a_png() {
let png = missing_placeholder().unwrap();
+262 -57
View File
@@ -24,11 +24,12 @@
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect, shapes};
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, id_param, ids_param, str_param};
@@ -49,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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. One undo step → {updated: [ids], missing: [ids]}",
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}",
has_doc,
update
),
@@ -483,23 +484,183 @@ fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
Ok(Read::Document(f.link(&bytes, &g.link), src))
}
/// Objects `ids` show what was read (see [`install`], [`install_document`]).
fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option<Preserve>) -> Result<()> {
/// Objects `ids` show what was read (see [`install`], [`install_document`]), and with `also`, the
/// objects outside the layer tree that show group `also`'s content.
fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option<Preserve>, also: Option<&Group>) -> Result<()> {
let also = also.map(|g| (g.link.path.as_str(), g.key.as_str()));
match read {
Read::Image(r) => install(d, ids, r, force),
Read::Document(link, src) => install_document(d, ids, &link, &src, force),
Read::Image(r) => install(d, ids, r, force, also),
Read::Document(link, src) => install_document(d, ids, &link, &src, force, also),
}
}
/// Is `n` a linked image (or, with `document`, a placed document) of the file at `path` showing
/// content `key`?
fn shows(n: &Node, (path, key): (&str, &str), document: bool) -> bool {
match &n.kind {
NodeKind::Image(im) => !document && im.key == key && im.link.as_ref().is_some_and(|l| l.path == path),
NodeKind::PlacedDocument(p) => document && p.key == key && p.link.path == path,
_ => false,
}
}
/// The clipping path of `clip`: its room as a rectangle in its frame.
fn clip_shape((frame, room): Clip) -> PathData {
shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame)
}
/// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and
/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for.
struct Nested<'a> {
pick: &'a dyn Fn(&Node) -> bool,
/// The object an object becomes, and the clip it takes ([`placed_xf`]).
make: &'a mut dyn FnMut(&Node) -> (Node, Option<Clip>),
/// The next node id for a clip group (taken from the document after the walk).
next: u64,
}
impl Nested<'_> {
/// Every root of `d`: the layers (whose own objects are left alone), then the art of symbols,
/// graph designs and patterns.
fn run(mut self, d: &mut Document) {
let first = d.peek_next_id();
self.next = first;
for l in d.layers.iter_mut() {
self.update(l, false);
}
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) {
self.root(root);
}
for _ in first..self.next {
d.alloc_id();
}
}
/// The art at a root outside the layer tree (a symbol's, graph design's or pattern's art, or
/// mask art).
fn root(&mut self, n: &mut Arc<Node>) {
if !(self.pick)(n) {
return self.update(n, true);
}
let (new, clip) = (self.make)(n);
let mut new = Arc::new(new);
self.update(&mut new, true);
*n = match clip {
Some(clip) => Arc::new(self.clip_group(new, clip)),
None => new,
};
}
/// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays
/// the same node when none is there, and only the nodes on the way to one are copied.
fn update(&mut self, n: &mut Arc<Node>, nested: bool) {
let mask = n.extras().mask.as_ref().and_then(|m| {
let mut art = Arc::clone(&m.art);
self.root(&mut art);
(!Arc::ptr_eq(&art, &m.art)).then_some(art)
});
let mut slots: Vec<(usize, Arc<Node>)> = vec![];
if let Some(children) = n.children() {
// A clip group holding just the object: its clipping path changes.
let clip_path = match (&n.kind, &children[..]) {
(NodeKind::Group { clip: true, .. }, [c, _]) if matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c),
_ => None,
};
for (i, c) in children.iter().enumerate() {
if !(nested && (self.pick)(c)) {
let mut new = Arc::clone(c);
self.update(&mut new, nested);
if !Arc::ptr_eq(&new, c) {
slots.push((i, new));
}
continue;
}
let (new, clip) = (self.make)(c);
let mut new = Arc::new(new);
self.update(&mut new, true);
match (clip, clip_path) {
(None, _) => slots.push((i, new)),
(Some(clip), Some(path)) if i == 1 => {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip_shape(clip);
}
slots.extend([(0, Arc::new(path)), (1, new)]);
}
(Some(clip), _) => {
let group = self.clip_group(new, clip);
slots.push((i, Arc::new(group)));
}
}
}
}
if mask.is_none() && slots.is_empty() {
return;
}
let n = Arc::make_mut(n);
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
m.art = art;
}
if let Some(children) = n.children_mut() {
for (i, c) in slots {
if let Some(slot) = children.get_mut(i) {
*slot = c;
}
}
}
}
/// A clip group of `object` clipped to `clip`, as [`clip_image`] makes one in the layers.
fn clip_group(&mut self, object: Arc<Node>, clip: Clip) -> Node {
let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1));
self.next += 2;
let none = vectorcraft_color::Paint::None;
let mut path = Node::path(path_id, clip_shape(clip), Appearance::basic(none.clone(), none, 0.0));
if let NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
Node::new(group_id, NodeKind::Group { children: vec![Arc::new(path), object], clip: true })
}
}
/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its
/// Placement Options say (`force`: as that Preserve choice says, whatever they say).
fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, force: Option<Preserve>) -> Result<()> {
/// Placement Options say (`force`: as that Preserve choice says, whatever they say); with `also`
/// (a file's path and the content shown), so do the placed documents outside the layer tree that
/// show that content.
fn install_document(
d: &mut Document,
ids: &[NodeId],
link: &LinkInfo,
src: &super::place::document::Source,
force: Option<Preserve>,
also: Option<(&str, &str)>,
) -> Result<()> {
let mut clips = vec![];
let (w, h) = (src.frame.width(), src.frame.height());
let placed = |p: &PlacedDocument| {
let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement };
placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o)
};
if let Some(also) = also {
let pick = |n: &Node| shows(n, also, true);
let mut make = |n: &Node| {
let mut n = n.clone();
let mut clip = None;
if let NodeKind::PlacedDocument(p) = &mut n.kind {
let (xf, c) = placed(p);
(p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone());
clip = c;
}
(n, clip)
};
Nested { pick: &pick, make: &mut make, next: 0 }.run(d);
}
for id in ids {
let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue };
let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement };
let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o);
let (xf, clip) = placed(p);
if let Some(n) = d.node_mut(*id)
&& let NodeKind::PlacedDocument(p) = &mut n.kind
{
@@ -565,8 +726,8 @@ fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), n
/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just
/// it changes, else the image goes into a new clip group.
fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> {
let path = shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame);
fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> {
let path = clip_shape(clip);
let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind {
NodeKind::Group { children, clip: true } => match &children[..] {
[c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id),
@@ -602,14 +763,33 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_
let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?;
let bytes = read_file(&path)?;
let read = (link_info(&path, &bytes), linked_image(&bytes)?);
install(d, &[id], read, Some(Preserve::Bounds))
install(d, &[id], read, Some(Preserve::Bounds), None)
}
/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement
/// Options say (`force`: as that Preserve choice says, whatever they say).
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option<Preserve>) -> Result<()> {
/// Options say (`force`: as that Preserve choice says, whatever they say); with `also` (a file's
/// path and the content shown), so do the images outside the layer tree that show that content.
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option<Preserve>, also: Option<(&str, &str)>) -> Result<()> {
let new_pt = super::place::pt_per_px(img.ppi);
let mut clips = vec![];
if let Some(also) = also {
// Every image picked shows content `also.1`, so one resolution serves them all.
let old_pt = file_pt(d, also.1);
let pick = |n: &Node| shows(n, also, false);
let mut make = |n: &Node| {
let mut n = n.clone();
let mut clip = None;
if let NodeKind::Image(im) = &mut n.kind {
let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement };
let size = |w: u32, h: u32| (w as f64, h as f64);
let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o);
(im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone()));
clip = c;
}
(n, clip)
};
Nested { pick: &pick, make: &mut make, next: 0 }.run(d);
}
for id in ids {
let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue };
let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement };
@@ -678,6 +858,9 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
// of other keys linking to the same file share them, rather than each reading, decoding and
// keeping a copy of its own.
let mut read_images: BTreeMap<String, (ImageBlob, String)> = BTreeMap::new();
// (the path as it was, the content shown, a placed document?, the link now) of each file found
// away from its path or read again.
let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![];
for g in groups(d, None) {
let row = rows(std::iter::once(&g), doc_path);
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
@@ -685,7 +868,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
match probe(&g, doc_path) {
(Status::Missing, _) => out.missing.extend(row),
(Status::Modified, Some(mut f)) if update || unread => {
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) {
let force = unread.then_some(Preserve::Bounds);
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) {
Ok(()) if unread => {}
Ok(()) => out.updated.extend(row),
Err(_) => out.missing.extend(row),
@@ -697,7 +881,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None };
match read {
Some(Ok(read)) => {
if install_read(d, &g.ids, read, Some(Preserve::Bounds)).is_err() {
if install_read(d, &g.ids, read, Some(Preserve::Bounds), Some(&g)).is_err() {
out.missing.extend(row);
}
}
@@ -709,6 +893,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
};
if link != g.link {
set_links(d, &g.ids, &link);
moved.push((g.link.path.clone(), g.key.clone(), true, link));
}
}
}
@@ -720,6 +905,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
let link = f.link_hashed(hash, &g.link);
if link != g.link {
set_links(d, &g.ids, &link);
moved.push((g.link.path.clone(), g.key.clone(), false, link));
}
continue;
}
@@ -745,11 +931,34 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
};
if link != g.link {
set_links(d, &g.ids, &link);
moved.push((g.link.path.clone(), g.key.clone(), false, link));
}
}
(Status::Ok, None) => {}
}
}
// The objects in symbols, graph designs, patterns and opacity masks that show the same file and
// content, which have no ids here, whatever else their links hold.
if !moved.is_empty() {
let mut by_path: BTreeMap<String, Vec<(String, bool, LinkInfo)>> = BTreeMap::new();
for (path, key, document, new) in moved {
by_path.entry(path).or_default().push((key, document, new));
}
let new = |n: &Node| {
let (l, key, document) = linked(n)?;
let m = by_path.get(&l.path)?;
m.iter().find(|(k, doc, _)| k == key && *doc == document.is_some()).map(|(_, _, new)| new)
};
let pick = |n: &Node| new(n).is_some();
let mut make = |n: &Node| {
let mut out = n.clone();
if let (Some(link), Some(l)) = (new(n), link_mut(&mut out)) {
*l = link.clone();
}
(out, None)
};
Nested { pick: &pick, make: &mut make, next: 0 }.run(d);
}
out
}
@@ -772,35 +981,31 @@ 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: read again from the new file, each as one Update Links step of its document.
/// 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.
pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) {
let path = absolute_path(path);
let targets: Vec<(usize, Vec<u64>)> = s
let targets: Vec<usize> = s
.documents()
.iter()
.enumerate()
.filter(|(_, st)| st.uid != saved)
.filter_map(|(i, st)| {
let mut ids = vec![];
st.doc.walk(|n| {
if let NodeKind::PlacedDocument(p) = &n.kind
&& p.link.path == path
{
ids.push(n.id.0);
}
});
(!ids.is_empty()).then_some((i, ids))
.filter(|(_, st)| {
let mut placed = false;
st.doc.visit_placed(|_, p| placed |= p.link.path == path);
placed
})
.map(|(i, _)| i)
.collect();
if targets.is_empty() {
return;
}
// Each document is updated as the active one, without switching tools.
let back = s.active;
for (i, ids) in targets {
for i in targets {
s.active = Some(i);
// A file that can't be read leaves what its objects show: links.check reports it.
let _ = update(s, &json!({ "ids": ids }));
let _ = update_where(s, None, Some(&path));
}
s.active = back;
}
@@ -826,28 +1031,20 @@ fn relative_path(target: &Path, base: &Path) -> Option<String> {
}
/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they
/// are already.
/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set.
pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
let dest = absolute_path(dest);
let dir = Path::new(&dest).parent()?;
let mut changes = vec![];
d.walk(|n| {
if let Some((l, _, _)) = linked(n) {
let r = relative_path(Path::new(&l.path), dir);
if r != l.relative {
changes.push((n.id, r));
}
}
});
if changes.is_empty() {
let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir);
let outdated = |l: &LinkInfo| relative(l) != l.relative;
let mut changed = false;
d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated));
d.visit_placed(|_, p| changed |= outdated(&p.link));
if !changed {
return None;
}
let mut d = d.clone();
for (id, r) in changes {
if let Some(l) = d.node_mut(id).and_then(link_mut) {
l.relative = r;
}
}
d.update_links_where(outdated, |l| l.relative = relative(l));
Some(d)
}
@@ -898,24 +1095,32 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
update_where(s, ids_param(p, "ids"), None)
}
/// Update Links in the active document of `s` for objects `ids` (default: the linked files that
/// changed or show their preview), or with `path`, for the placed documents of the file at `path`
/// whatever their status. The objects in symbols, graph designs, patterns and opacity masks that
/// show the same content are read again too, and have no ids to report.
fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -> Result<Value> {
let st = s.doc()?;
let ids = ids_param(p, "ids");
let (mut reads, mut missing) = (vec![], vec![]);
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) {
let asked = ids.is_some() || path.is_some();
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) {
match probe(&g, st.path.as_deref()) {
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
// Unchanged and showing the file: nothing to read unless asked for by id.
(Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {}
// Unchanged and showing the file: nothing to read unless asked for.
(Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {}
// Unreadable as an image (or a document) counts as missing.
(_, Some(mut f)) => match read_for(&mut f, &g, "links.update") {
Ok(read) => reads.push((g.ids, read)),
Ok(read) => reads.push((g, read)),
Err(_) => missing.extend(ids_json(&g.ids)),
},
}
}
let updated: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
let updated: Vec<u64> = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect();
if !reads.is_empty() {
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, None)))?;
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?;
}
Ok(json!({ "updated": updated, "missing": missing }))
}
@@ -1033,7 +1238,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
if !reads.is_empty() {
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve)))?;
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?;
}
not_found.sort();
not_found.dedup();
@@ -1292,7 +1497,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
if !done.is_empty() || !documents.is_empty() {
s.edit("Embed", |d, _| {
for (ids, read) in reads {
install(d, &ids, read, Some(Preserve::Bounds))?;
install(d, &ids, read, Some(Preserve::Bounds), None)?;
}
for id in &done {
if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) {
@@ -1349,7 +1554,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
let img = linked_image(&bytes)?;
write_file(&path, &bytes)?;
let read = (link_info(&path, &bytes), img);
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds)))?;
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?;
Ok(json!({ "id": id.0, "path": path }))
}
+197
View File
@@ -244,6 +244,203 @@ fn the_relative_path_finds_the_file_after_the_folder_moves() {
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
/// Links in symbols, graph designs, patterns and opacity masks are saved with their relative
/// paths, as links in the layers are: after the folder moves, each file is found and its link takes
/// the new path, which the next save writes relative to the document again.
#[test]
fn links_outside_the_layers_find_their_files_after_the_folder_moves() {
let a = Folder::new("nested-move-a");
let b = Folder::new("nested-move-b");
let c = Folder::new("nested-move-c");
let names = ["layer", "symbol", "design", "pattern", "mask"];
for (i, name) in (0u8..).zip(names) {
write(&a.file(&format!("art/{name}.png")), &png(20, 10, [20 + 40 * i, 90, 160]));
}
let mut s = session();
place(&mut s, &a.file("art/layer.png"));
let id = place(&mut s, &a.file("art/symbol.png"));
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap();
let id = place(&mut s, &a.file("art/design.png"));
s.execute("graph.design", &json!({"new": "Bar"})).unwrap();
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
let id = place(&mut s, &a.file("art/pattern.png"));
s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap();
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0);
let mask = place(&mut s, &a.file("art/mask.png"));
s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap();
let mut in_layers = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
in_layers.extend(im.link.as_ref().map(|l| l.name().to_string()));
}
});
assert_eq!(in_layers, ["layer.png"], "only one file is shown by an object in the layers");
// The links' paths, each image checked to show its file's pixels rather than the saved preview.
let paths = |s: &Session| {
let d = &s.doc().unwrap().doc;
let mut out = vec![];
d.visit_images(|_, im| {
let l = im.link.as_ref().unwrap();
assert!(!d.images[&im.key].is_proxy(), "{} shows its preview", l.name());
out.push(PathBuf::from(&l.path));
});
out.sort();
out
};
let files = |dir: &Folder| {
let mut out: Vec<PathBuf> = names.iter().map(|n| PathBuf::from(dir.file(&format!("art/{n}.png")))).collect();
out.sort();
out
};
assert_eq!(paths(&s), files(&a));
save(&mut s, &a.file("poster.vectorcraft"));
// Move the document with its links: the old folder is gone.
std::fs::remove_dir_all(&b.0).unwrap();
std::fs::rename(&a.0, &b.0).unwrap();
let r = open(&mut s, &b.file("poster.vectorcraft"));
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
assert_eq!(paths(&s), files(&b), "the links follow the files");
// Saved again and moved again.
save(&mut s, &b.file("poster.vectorcraft"));
std::fs::remove_dir_all(&c.0).unwrap();
std::fs::rename(&b.0, &c.0).unwrap();
let r = open(&mut s, &c.file("poster.vectorcraft"));
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
assert_eq!(paths(&s), files(&c));
}
/// With Update Links: Automatically, a file in a symbol and in the layers that changed and moved
/// with the document is read again for the layers' image and for the symbol's, and both links take
/// its new path, size and hash.
#[test]
fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() {
let a = Folder::new("nested-modified-a");
let b = Folder::new("nested-modified-b");
let pic = a.file("art/photo.png");
write(&pic, &png(20, 10, RED));
let mut s = session();
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
let id = place(&mut s, &pic);
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap();
let id = place(&mut s, &pic);
save(&mut s, &a.file("poster.vectorcraft"));
write(&pic, &png(30, 30, BLUE));
std::fs::remove_dir_all(&b.0).unwrap();
std::fs::rename(&a.0, &b.0).unwrap();
let r = open(&mut s, &b.file("poster.vectorcraft"));
assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}");
let moved = PathBuf::from(b.file("art/photo.png"));
let blue = png(30, 30, BLUE);
let mut images = vec![];
s.doc().unwrap().doc.visit_images(|_, im| {
let l = im.link.as_ref().unwrap();
images.push((PathBuf::from(&l.path), l.size, l.hash.clone(), im.width, im.height, im.key.clone()));
});
let key = image(&s, id).key;
let new = (moved, Some(blue.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&blue)), 30, 30, key);
assert_eq!(images, [new.clone(), new], "the layers' image, then the symbol's");
}
/// A link inside a symbol that the document records without a relative path, beside a link in the
/// layers to the same file that has one, takes the file's new path after the folder moves.
#[test]
fn a_symbols_link_without_a_relative_path_follows_the_moved_file() {
let a = Folder::new("nested-unrelative-a");
let b = Folder::new("nested-unrelative-b");
let pic = a.file("art/photo.png");
write(&pic, &png(20, 10, RED));
let mut s = session();
let id = place(&mut s, &pic);
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap();
place(&mut s, &pic);
let doc_path = a.file("poster.vectorcraft");
save(&mut s, &doc_path);
// Removes the relative path of every link under `v` → how many it removed.
fn unrelative(v: &mut Value) -> usize {
match v {
Value::Object(o) => {
let own = match o.get_mut("link") {
Some(Value::Object(l)) => usize::from(l.remove("relative").is_some()),
_ => 0,
};
own + o.values_mut().map(unrelative).sum::<usize>()
}
Value::Array(a) => a.iter_mut().map(unrelative).sum(),
_ => 0,
}
}
let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
assert_eq!(unrelative(&mut saved["document"]["symbols"]), 1, "the symbol's link");
assert_eq!(unrelative(&mut saved["document"]["layers"].clone()), 1, "the layers' link keeps its relative path");
std::fs::write(&doc_path, serde_json::to_vec(&saved).unwrap()).unwrap();
std::fs::remove_dir_all(&b.0).unwrap();
std::fs::rename(&a.0, &b.0).unwrap();
let r = open(&mut s, &b.file("poster.vectorcraft"));
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
let mut paths = vec![];
s.doc().unwrap().doc.visit_images(|_, im| paths.push(PathBuf::from(&im.link.as_ref().unwrap().path)));
let moved = PathBuf::from(b.file("art/photo.png"));
assert_eq!(paths, [moved.clone(), moved], "the layers' link, then the symbol's");
}
/// Update Links reads a changed file again for the images inside symbols, patterns and opacity
/// masks too: those showing the same content as an image in the layers, and those no object in the
/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step.
#[test]
fn update_links_reads_files_again_inside_symbols_patterns_and_masks() {
let dir = Folder::new("nested-update");
let (photo, logo) = (dir.file("photo.png"), dir.file("logo.png"));
write(&photo, &png(20, 10, RED));
write(&logo, &png(20, 10, [200, 200, 20]));
let mut s = session();
// photo.png in the layers, and in a symbol filling its bounds, clipped to them.
let id = place(&mut s, &photo);
s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap();
let id = place(&mut s, &photo);
// logo.png only in a pattern and in an opacity mask.
let p = place(&mut s, &logo);
s.execute("object.pattern.make", &json!({"ids": [p.0], "edit": false})).unwrap();
s.execute("edit.clear", &json!({"ids": [p.0]})).unwrap();
let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0);
let mask = place(&mut s, &logo);
s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap();
// photo.png wider than the symbol's image is: filling its bounds overflows them.
let (blue, green) = (png(60, 20, BLUE), png(40, 20, [20, 200, 20]));
write(&photo, &blue);
write(&logo, &green);
let u = s.execute("links.update", &json!({})).unwrap();
assert_eq!(u, json!({"updated": [id.0], "missing": []}));
// (name, size, hash, pixel size) of each image, which shows its file's pixels.
let images = |s: &Session| {
let d = &s.doc().unwrap().doc;
let mut out = vec![];
d.visit_images(|_, im| {
let l = im.link.as_ref().unwrap();
assert!(!d.images[&im.key].is_proxy(), "{} shows its preview", l.name());
out.push((l.name().to_string(), l.size, l.hash.clone(), (im.width, im.height)));
});
out.sort();
out
};
let shown = |name: &str, bytes: &[u8], size| (name.to_string(), Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(bytes)), size);
let logo_now = shown("logo.png", &green, (40, 20));
let photo_now = shown("photo.png", &blue, (60, 20));
assert_eq!(images(&s), [logo_now.clone(), logo_now, photo_now.clone(), photo_now]);
// The symbol's image is clipped to its old bounds.
let mut clipped = false;
s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| {
if let NodeKind::Group { children, clip: true } = &n.kind {
clipped |=
matches!(&children[..], [c, im] if matches!(c.kind, NodeKind::Path { clipping: true, .. }) && matches!(im.kind, NodeKind::Image(_)));
}
});
assert!(clipped, "{:?}", s.doc().unwrap().doc.symbols[0].art);
s.execute("edit.undo", &json!({})).unwrap();
assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s));
}
#[test]
fn a_missing_link_renders_its_preview_and_relinks() {
let dir = Folder::new("missing");
@@ -891,6 +891,36 @@ fn saving_a_document_refreshes_the_open_documents_that_place_it() {
assert_eq!(s.active_index(), Some(child), "the saved document stays active");
}
/// Placed documents inside a symbol and a pattern of another open document are read again when
/// their file is saved, as one in the layers is.
#[test]
fn saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns() {
let dir = Folder::new("placed-refresh-nested");
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("symbol.new", &json!({"name": "Logo", "ids": [id.0]})).unwrap();
let id = place(&mut s, &src);
s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap();
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
let parent = s.active_index().unwrap();
let mut in_layers = 0;
s.doc().unwrap().doc.walk(|n| in_layers += usize::from(matches!(n.kind, NodeKind::PlacedDocument(_))));
assert_eq!(in_layers, 0, "no placed document in the layers");
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");
assert!(near(centre_colour(&doc.doc), BLUE), "the symbol's instance shows the saved version");
let mut hashes = vec![];
doc.doc.visit_placed(|_, p| hashes.push(p.link.hash.clone()));
assert_eq!(hashes, [hash.clone(), hash], "the symbol's, then the pattern's");
}
#[test]
fn link_info_gives_the_artboards_size_not_pixels() {
let dir = Folder::new("placed-info");
+9 -3
View File
@@ -4323,9 +4323,11 @@ is mixed with middle gray by its alpha.
A placed image linked to its file (`file.place {path}`, Link on) records the file's absolute path, its size,
modification time and hash, and keeps a low-resolution preview (at most 256 px a side): a `.vectorcraft` save writes
the preview instead of the pixels, plus the path relative to the saved file. `document.open` reads
the preview instead of the pixels, plus the path relative to the saved file. Links inside symbols, graph designs,
patterns and opacity masks are saved with their relative paths too. `document.open` reads
the linked files again, looking for each at its path, then at its relative path and by name in the document's folder
(so a folder moved with its links still opens). Only files on this computer, or on the network share the document is
(so a folder moved with its links still opens), and each link, in the layers or inside that art, takes the path where
its file was found. Only files on this computer, or on the network share the document is
on, are looked at: a path to another computer, a device or a URL counts as missing. On Windows, a document opened from
a mapped drive (`Z:\…`) is on the share the drive is mapped to, and links on that share (`\\server\share\…`) are
looked at. A link on a drive mapped to another share (`Y:\…`) counts as missing, from a document on this computer too.
@@ -4335,7 +4337,11 @@ The result lists `missingLinks`
path isn't followed) when it isn't. The desktop app also checks the active document's links when its window
comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`,
`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. 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 (by default also for
those that only that art shows); they have no ids, so the results do not list them. 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
read", and `links.update`, or `document.open` when it reads the file, lists it as missing, its images keeping what they
showed. Without a file system (the web), linked images show their previews. A link whose path has a colon and no `/`
+1
View File
@@ -1334,6 +1334,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before.
- On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before.
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.
- Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`.
- On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing.
- On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed.
- In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one.