Links: a classic Mac OS path is named by its file name

A linked image read from a version 7 `.ai` file can keep its path in
the classic Mac OS form, with colons between folders
(`Macintosh HD:Images:photo.tif`). The link's name was the whole path,
so the Links panel showed it as one long name, and Relink to Folder,
the search of the document's folder and Find Linked Files looked for a
file with that whole string as its name.

A path with a colon and no `/` or `\` is now read as a classic Mac OS
path. Its name is the part after the last colon, and `links.info` gives
the rest as `location`. Relink to Folder, the document-folder search and
`links.findFiles` look for that name, and Find Linked Files compares the
path's folders with the folders of the files it finds. The link keeps
the path as stored. Paths with a `/` or `\` keep those as separators,
so a file name may still hold a colon (`/Users/a/x:y.png`).

Docs: docs/mcp.md (Links), docs/file-format-parity.md (version 7
linked images).
This commit is contained in:
dotCooCoo
2026-11-05 21:46:43 +07:00
committed by bflatastic
parent 8caa0e0e59
commit 48cf28fe48
7 changed files with 126 additions and 9 deletions
+47 -2
View File
@@ -46,10 +46,26 @@ impl LinkInfo {
Self { path: path.into(), ..Self::default() }
}
/// The file name: the path's last component, whichever system's separators it uses.
/// The file name: the path's last component, whichever system's separators it uses
/// ([`path_separators`]).
pub fn name(&self) -> &str {
self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path)
self.path.rsplit(path_separators(&self.path)).next().unwrap_or(&self.path)
}
/// The folder the file is in: the path without its name and the separators before it.
pub fn folder(&self) -> &str {
let folder = self.path.get(..self.path.len().saturating_sub(self.name().len())).unwrap_or_default();
folder.trim_end_matches(path_separators(&self.path))
}
}
/// The characters that separate the folders of `path`. A path with a colon and no `/` or `\` is a
/// classic Mac OS path (`Disk:Folder:file.tif`, as older documents store links), whose folders are
/// separated by colons. Any other path is separated by `/` and `\`: its file name may hold a colon
/// (`/Users/a/x:y.png`) and never holds a `/` or `\`.
pub fn path_separators(path: &str) -> &'static [char] {
const SLASHES: &[char] = &['/', '\\'];
if path.contains(':') && !path.contains(SLASHES) { &[':'] } else { SLASHES }
}
impl From<&str> for LinkInfo {
@@ -484,6 +500,35 @@ mod tests {
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
}
/// A classic Mac OS path is named by its last colon-separated component, and its folder is
/// what comes before; other paths keep their `/` and `\` separators, and a name never holds
/// one.
#[test]
fn a_classic_mac_path_is_named_by_its_last_component() {
let parts = |path: &str| {
let l = LinkInfo::new(path);
(l.name().to_string(), l.folder().to_string())
};
assert_eq!(parts("Macintosh HD:Images:photo.tif"), ("photo.tif".into(), "Macintosh HD:Images".into()));
assert_eq!(parts("Disk:photo.tif"), ("photo.tif".into(), "Disk".into()));
assert_eq!(parts("Disk:Art/2024:a\\b.tif"), ("b.tif".into(), "Disk:Art/2024:a".into()));
assert_eq!(parts("Disk:Images:../other.png"), ("other.png".into(), "Disk:Images:..".into()), "a name reaches no other folder");
assert_eq!(parts("C:\\art\\photo.png"), ("photo.png".into(), "C:\\art".into()));
assert_eq!(parts("c:/art/photo.png"), ("photo.png".into(), "c:/art".into()));
assert_eq!(parts("/Users/a/x:y.png"), ("x:y.png".into(), "/Users/a".into()), "a POSIX name may hold a colon");
assert_eq!(parts("./art/x:y.png"), ("x:y.png".into(), "./art".into()));
assert_eq!(parts("..\\art\\x:y.png"), ("x:y.png".into(), "..\\art".into()));
assert_eq!(parts("\\\\?\\C:\\art\\photo.png"), ("photo.png".into(), "\\\\?\\C:\\art".into()));
assert_eq!(parts("file:///art/photo.png"), ("photo.png".into(), "file:///art".into()));
assert_eq!(parts("file:/art/photo.png"), ("photo.png".into(), "file:/art".into()));
assert_eq!(parts("assets/icon:dark.png"), ("icon:dark.png".into(), "assets".into()), "a relative path with slashes");
assert_eq!(parts(":Images:photo.tif"), ("photo.tif".into(), ":Images".into()), "a classic path relative to its folder");
assert_eq!(parts("/Users/a//photo.png").1, "/Users/a", "repeated separators");
assert_eq!(parts("photo.png"), ("photo.png".into(), String::new()));
assert_eq!(parts("C:photo.png"), ("photo.png".into(), "C".into()), "a drive-relative path has the same shape");
assert_eq!(LinkInfo::new("Macintosh HD:Images:photo.tif").path, "Macintosh HD:Images:photo.tif", "the stored path is kept");
}
#[test]
fn a_reader_hashes_as_its_bytes_do() {
// Several 64 KB reads, and none.
+3 -3
View File
@@ -176,10 +176,10 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
}
/// How many folder names the file at `found` has in common with the saved path `saved` (whichever
/// system's separators it uses), counted from the innermost out up to the first that differs,
/// compared in lowercase.
/// system's separators it uses, [`vectorcraft_doc::links::path_separators`]), counted from the
/// innermost out up to the first that differs, compared in lowercase.
fn shared_folders(saved: &str, found: &Path) -> usize {
let saved = saved.rsplit(['/', '\\']).skip(1).filter(|c| !c.is_empty());
let saved = saved.rsplit(vectorcraft_doc::links::path_separators(saved)).skip(1).filter(|c| !c.is_empty());
let found = found.parent().into_iter().flat_map(|p| p.components().rev()).map_while(|c| match c {
Component::Normal(n) => n.to_str(),
_ => None,
+1 -2
View File
@@ -1349,12 +1349,11 @@ fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Va
let doc_path = st.path.as_deref();
let path = fileio::document_path(&l.path, doc_path).ok();
let stamp = path.as_deref().and_then(file_stamp);
let folder_len = l.path.len().saturating_sub(l.name().len());
out["status"] = json!(status.as_str());
note_refused(out, l, doc_path);
out["path"] = json!(l.path);
out["fileName"] = json!(l.name());
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
out["location"] = json!(l.folder());
for (k, v) in [
("page", l.page.map(u64::from)),
("fileSize", stamp.map(|s| s.0).or(l.size)),
+30
View File
@@ -123,6 +123,36 @@ fn files_found_are_ranked_by_what_the_link_saved() {
assert_eq!(photo_row["path"], json!(root.join("orig/photo.png")));
}
/// A link saved as a classic Mac OS path (`Disk:Folder:file`) is looked for by its file name, and
/// the files found are ranked by the folder names they share with that path.
#[test]
fn a_classic_mac_path_is_found_by_its_name_and_folders() {
let root = folder("classic");
let mut s = session_for(&root);
let photo = png(50, 30, BLUE);
write(&root.join("orig/photo.png"), &photo);
let id = placed(&mut s, &root.join("orig/photo.png"));
s.edit("classic path", |d, _| {
if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind {
im.link.as_mut().unwrap().path = "Old Disk:Art:Images:photo.png".into();
}
Ok(())
})
.unwrap();
std::fs::remove_dir_all(root.join("orig")).unwrap();
// Other files of the name (a search ends once it finds the same file).
let search = root.join("search");
let mut changed = photo.clone();
*changed.last_mut().unwrap() ^= 1;
write(&search.join("a/photo.png"), &changed);
write(&search.join("z/Art/Images/photo.png"), &changed);
s.execute("links.findFiles", &json!({"folder": search})).unwrap();
let r = finished(&mut s);
assert_eq!(r["state"].as_str(), Some("done"), "{r}");
let files = [("search/z/Art/Images/photo.png", "name", false), ("search/a/photo.png", "name", false)];
assert_eq!(found(&r, "photo.png", &root), files.map(|(p, k, c)| (p.to_string(), k.to_string(), c)));
}
/// Without `ids`, a search looks for the files that can't be found; with them, for those objects'
/// files, whatever their status.
#[test]
+38
View File
@@ -283,6 +283,44 @@ fn a_missing_link_renders_its_preview_and_relinks() {
assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder");
}
/// A link stored as a classic Mac OS path (`Disk:Folder:file`, as older `.ai` files keep them) is
/// named by its last component: the Links panel shows that name and its folder, Relink to Folder
/// looks for that name, and the document's own folder is searched for it. The stored path is kept.
#[test]
fn a_classic_mac_path_is_found_by_its_file_name() {
const CLASSIC: &str = "Macintosh HD:Images:photo.png";
let dir = Folder::new("classic-mac");
let pic = dir.file("art/photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
place(&mut s, &pic);
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
saved_image(&mut v)["link"] = json!({ "path": CLASSIC });
let doc_path = dir.file("poster.vectorcraft");
write(&doc_path, &serde_json::to_vec(&v).unwrap());
let r = open(&mut s, &doc_path);
let id = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
assert_eq!(r["missingLinks"], json!([{"name": "photo.png", "path": CLASSIC, "ids": [id.0]}]));
let i = s.execute("links.info", &json!({"id": id.0})).unwrap();
assert_eq!(
(i["fileName"].as_str(), i["location"].as_str(), i["path"].as_str()),
(Some("photo.png"), Some("Macintosh HD:Images"), Some(CLASSIC)),
"{i}"
);
// Relink to Folder: the file of the link's name there.
let r = s.execute("links.relink", &json!({"ids": [id.0], "folder": dir.file("found")})).unwrap();
assert_eq!(r, json!({"relinked": [], "notFound": ["photo.png"]}));
write(&dir.file("found/photo.png"), &png(600, 300, BLUE));
let r = s.execute("links.relink", &json!({"ids": [id.0], "folder": dir.file("found")})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
// The document's folder: the file of the link's name beside the document.
write(&dir.file("photo.png"), &png(600, 300, RED));
let r = open(&mut s, &doc_path);
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
/// A `w`×`h` PNG of noise cut to half its length: its header reads, its pixels don't.
pub(crate) fn cut_png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_fn(w, h, |x, y| {
+3 -1
View File
@@ -137,7 +137,9 @@ private editing data. VectorCraft:
from 0 to 9 leaves the mode as it was.
with a fill's offset, scale, rotation, reflection and shear applied before its matrix. Its linked images in
the version 7 form (`(path) modified XG`, then `XF` with no samples) come in as linked images of their files,
showing a placeholder where the image goes until the file is relinked in the Links panel.
showing a placeholder where the image goes until the file is relinked in the Links panel. A path stored in
the classic Mac OS form (`Disk:Folder:file.tif`, with a colon and no `/` or `\`) is named by its last part, and
relinking looks for that name.
A 1-bit gray image whose `XI` or `XF` has ImageMask 1 comes in colorized by the fill color set before it,
or by black when that fill is not a solid color. VectorCraft's own rule is that the pixels that open black
with ImageMask 0 take the color and the others are transparent.
+4 -1
View File
@@ -4163,7 +4163,10 @@ comes back to the front, following the same preference. `links.check` reports ev
another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. 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.
showed. Without a file system (the web), linked images show their previews. A link whose path has a colon and no `/`
or `\` is read as a classic Mac OS path (`Disk:Folder:file.tif`), a form that older `.ai` files can hold. The link
keeps that path and is named by its last part. `links.info` gives that part as `fileName` and the rest as `location`,
and `links.relink {folder}`, the search of the document's folder and `links.findFiles` look for a file of that name.
`links.findFiles {folder}` looks in an absolute folder and its subfolders, on separate threads, for the files of the
active document's missing links, or of the images and placed documents `ids` whatever their status, and returns at once