From 48cf28fe48825fd630e6e60338113cb066010385 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 06:04:31 -0800 Subject: [PATCH] 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). --- crates/doc/src/links.rs | 49 ++++++++++++++++++++++++++-- crates/engine/src/cmd/linkfiles.rs | 6 ++-- crates/engine/src/cmd/links.rs | 3 +- crates/engine/src/tests/linkfiles.rs | 30 +++++++++++++++++ crates/engine/src/tests/links.rs | 38 +++++++++++++++++++++ docs/file-format-parity.md | 4 ++- docs/mcp.md | 5 ++- 7 files changed, 126 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 6e50a4f7f..08121b566 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -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. diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index c874a70ee..8341ea904 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -176,10 +176,10 @@ fn find(s: &mut Session, p: &Value) -> Result { } /// 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, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 842d78b56..bcda11c2b 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -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)), diff --git a/crates/engine/src/tests/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs index 5990b3523..0d38cdacb 100644 --- a/crates/engine/src/tests/linkfiles.rs +++ b/crates/engine/src/tests/linkfiles.rs @@ -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] diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index 41bc6b8fb..7cdf43ad7 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -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 { let img = image::RgbImage::from_fn(w, h, |x, y| { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 41b01ad61..0ac7b3554 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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. diff --git a/docs/mcp.md b/docs/mcp.md index ab64cfbff..8e978c374 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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