Merge pull request #5807 from dotCooCoo/fix/link-classic-mac-path

Links: a classic Mac OS path is named by its file name
This commit is contained in:
BFlat
2026-11-05 22:00:48 +07:00
committed by GitHub
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