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