From dadb2993e9f3104b11d097b9f58e2fd1931cf2c0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 14 Nov 2026 06:45:33 +0000 Subject: [PATCH] DES-10 T6: Links status by hash, update, embed and unembed for linked graphics links.list gains storage; a linked graphic's status comes from the open check while its bytes are unchanged, else from its files: OK by size and time without reading, else by content hash; Modified or Missing. Update, Relink and Relink to Folder read the file into a new linked version with a new hash and proxy. Embed checks the file first and keeps the link record; links.unembed links to the original when it holds the same bytes, else writes them into dir. Not on the web. (cherry picked from commit 8d617b872f7d7b01b9551d07a3e82be03e1c1362) --- crates/engine/src/cmd/links.rs | 456 ++++++++++++++++++++++++--- crates/engine/src/cmd/links/tests.rs | 205 +++++++++++- 2 files changed, 608 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index e7d164f35..badc8238a 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1,9 +1,14 @@ //! Window › Links: placed graphics with their status, effective resolution and uses; relink, //! update, embed, go to. //! -//! Every placed graphic keeps its bytes in the document; a linked graphic also remembers its -//! file. Its status compares that file with the copy in the document: missing, modified (the -//! file's bytes differ) or OK. +//! A graphic placed from a file links to it ([`Storage::Linked`]): the document keeps the file's +//! path (absolute, and relative to the document), size, time and content hash, and a proxy +//! image; the bytes are read from the file when needed. Its status is OK while a file at one of +//! those paths holds the recorded bytes (same size and time, or the same hash), Modified when +//! files are there with other bytes, and Missing otherwise. Embed copies the bytes into the +//! document and keeps the link record, so Unembed can link to the file again. An embedded copy +//! from a document saved before linked storage (a link without a hash) is compared with its file +//! byte for byte. //! //! A linked text file (File › Place with Create Links) remembers its file's size and hash on the //! story instead; updating it imports the file again into the story. @@ -16,7 +21,9 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use designcraft_compose::Cache; -use designcraft_doc::{AssetId, Content, Document, Fingerprint, Item, ItemId, Selection, Story, StoryId, TextFileLink}; +use designcraft_doc::{ + Asset, AssetData, AssetId, Content, Document, Fingerprint, Item, ItemId, LinkedSource, Selection, Storage, Story, StoryId, TextFileLink, +}; use designcraft_geom::Affine; use serde_json::{Value, json}; @@ -25,10 +32,10 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { + cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, storage: linked|embedded (where a save keeps the bytes), status: ok|modified|missing|embedded|checking (a linked file still being hashed), refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { // Taken out for the call so the document needn't be cloned on every poll. let mut checks = std::mem::take(&mut s.link_checks); - let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), Some(&mut checks), None).unwrap_or_default()); + let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), Some(&mut checks), None, Some(&d.links)).unwrap_or_default()); s.link_checks = checks; Ok(Value::Array(links?)) }), @@ -64,22 +71,18 @@ pub fn specs() -> Vec { "Embed Link", ["Window", "Links"], None, - "{asset | story} — keep only the copy in the document (a text file: unlink the story)", + "{asset | story} — copy a linked graphic's file into the document (its link is kept for Unembed); fails when the file is missing or has changed (update or relink it first). A text file: unlink the story", has_doc, - |s, p| { - if let Some(sid) = story_param(p) { - return s.edit(|d, _| { - d.story_mut(sid).ok_or_else(|| bad("links.embed", "no such story"))?.file_link = None; - Ok(Value::Null) - }); - } - let aid = asset_param(p, "links.embed")?; - s.edit(|d, _| { - let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.embed", "no such graphic"))?; - Arc::make_mut(a).set_link(None, None); - Ok(Value::Null) - }) - } + embed + ), + cmd!( + "links.unembed", + "Unembed Link…", + ["Window", "Links"], + None, + "{asset, dir?} — link an embedded graphic to a file instead of keeping it in the document: its original file when that still holds the same bytes, else a file written into `dir` (an existing one with the same bytes is reused) → {path}. Not on the web", + can_unembed, + unembed ), cmd!(noundo "links.goTo", "Go To Link", ["Window", "Links"], None, "{asset | story} — selects the frames showing the graphic or the linked text", has_doc, |s, p| { let st = s.doc_mut()?; @@ -145,6 +148,12 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool let Some(link) = &a.link else { return false }; #[cfg(not(target_arch = "wasm32"))] { + if a.storage == Storage::Linked { + return match LinkedSource::of(a, document) { + Some(source) => !crate::asset_io::candidates(&source).iter().any(crate::io::is_file), + None => true, + }; + } !crate::io::document_path(link, document).is_ok_and(crate::io::exists) } #[cfg(target_arch = "wasm32")] @@ -171,6 +180,134 @@ pub(crate) fn loaded_fingerprint(before: Option, bytes: &[u8]) -> O before.filter(|f| f.size == bytes.len() as u64 && settled(f)) } +/// The modification time kept with a linked file whose time couldn't vouch for its bytes when +/// they were read ([`loaded_fingerprint`]): no file has it, so the file is told by its hash until +/// a check finds it holding the bytes and keeps its real time ([`remember_link_files`]). +pub(crate) const UNVERIFIED_MTIME: i64 = i64::MIN; + +/// The file bytes for a graphic were just read from. +pub(crate) struct FileLink { + /// The file's absolute path. + path: String, + /// Its size and time from before the read. + before: Option, +} + +impl FileLink { + pub(crate) fn of(path: &Path, before: Option) -> Self { + let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()).to_string_lossy().into_owned(); + FileLink { path, before } + } +} + +/// `target` relative to the folder of the document file `document`, as link records keep it. +fn relative_to(document: &Path, target: &Path) -> Option { + let document = std::path::absolute(document).ok()?; + let relative = super::datamerge::relative_between(document.parent()?, target); + #[cfg(windows)] + let relative = relative.replace('\\', "/"); + Some(relative) +} + +/// The proxy a linked graphic is drawn with while its file can't be read: none for video and +/// sound, or a file that doesn't decode. +fn proxy_of(a: &Asset, bytes: &[u8]) -> Option { + if a.mime.starts_with("video/") || a.mime.starts_with("audio/") { + return None; + } + let p = designcraft_render::proxy::make_proxy(bytes, a.page)?; + Some(designcraft_doc::Proxy { mime: p.mime.into(), pixels: p.pixels, data: AssetData::embedded(p.data) }) +} + +/// Give `a` the bytes of a graphic being placed, relinked or updated (its `mime` and `page` set). +/// Bytes read from a file (`file`) link to it, unless `embed` or on the web, where they are +/// embedded with the link record kept; the bytes just read stay in memory for the first draw. +/// Other bytes (pasted, dropped, base64) are embedded without a link. +pub(crate) fn give_bytes(a: &mut Asset, bytes: impl Into>>, file: Option, embed: bool, document: Option<&Path>) { + let bytes: Arc> = bytes.into(); + a.proxy = None; + let Some(file) = file else { + a.set_link(None, None); + a.link_relative = None; + a.link_hash = None; + a.storage = Storage::Embedded; + a.data = AssetData::embedded(bytes); + return; + }; + let loaded = loaded_fingerprint(file.before, &bytes); + a.link_relative = document.and_then(|d| relative_to(d, Path::new(&file.path))); + a.link = Some(file.path); + a.link_hash = Some(crate::asset_io::content_hash(&bytes)); + if embed || cfg!(target_arch = "wasm32") { + a.link_fingerprint = loaded; + a.storage = Storage::Embedded; + a.data = AssetData::embedded(bytes); + return; + } + a.link_fingerprint = Some(loaded.unwrap_or(Fingerprint { size: bytes.len() as u64, mtime: UNVERIFIED_MTIME })); + let Some(source) = LinkedSource::of(a, document) else { + a.storage = Storage::Embedded; + a.data = AssetData::embedded(bytes); + return; + }; + a.storage = Storage::Linked; + a.proxy = proxy_of(a, &bytes); + a.data = AssetData::linked(source); + a.data.keep_resident(bytes); +} + +/// Does `bytes` (a file's) hold `a`'s bytes: its recorded hash, or, without one, its bytes? +#[cfg(not(target_arch = "wasm32"))] +fn holds(a: &Asset, bytes: &[u8]) -> bool { + match &a.link_hash { + Some(hash) => *hash == crate::asset_io::content_hash(bytes), + None => a.data.resident().is_some_and(|d| *d == bytes), + } +} + +/// Status of a linked graphic: OK when a file at its absolute or relative path has the recorded +/// size and time (nothing is read), or the recorded size and the recorded bytes (hashed once per +/// new time, `checks`); Modified when files are there and none holds them; Missing when none is. +fn linked_status(a: &Asset, document: Option<&Path>, checks: &mut ImageChecks) -> (&'static str, Option) { + let Some(source) = LinkedSource::of(a, document) else { return ("missing", None) }; + #[cfg(not(target_arch = "wasm32"))] + { + let mut status = "missing"; + for path in crate::asset_io::candidates(&source) { + let Some(meta) = crate::io::metadata(&path).ok().filter(std::fs::Metadata::is_file) else { continue }; + let now = crate::asset_io::fingerprint_from(&meta); + if Some(now) == source.fingerprint && settled(&now) { + return ("ok", None); + } + status = "modified"; + if now.size != source.len { + continue; + } + let key = (a.id, a.data.key(), path.clone()); + let same = match checks.get(&key) { + Some((at, same)) if *at == now => *same, + _ => { + let Ok(bytes) = crate::io::read_capped(&path, source.len) else { continue }; + let same = bytes.len() as u64 == source.len && holds(a, &bytes); + if settled(&now) { + checks.insert(key, (now, same)); + } + same + } + }; + if same { + return ("ok", settled(&now).then_some(now)); + } + } + (status, None) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (source, checks); + ("missing", None) + } +} + /// Is the linked file still the one the asset's bytes were loaded from, going by the size and /// time kept then ([`designcraft_doc::Asset::link_fingerprint`], [`loaded_fingerprint`])? Nothing /// is read when it is, also on the first check after a document is opened, which matters on a @@ -186,14 +323,37 @@ fn loaded_unchanged(a: &designcraft_doc::Asset, now: Fingerprint) -> bool { /// ([`loaded_unchanged`]) nor the one remembered from the last read (`checks`): the Links panel /// polls, and a document of press-size images would otherwise be read whole on every poll. A file /// changed in the last two seconds is never remembered (see [`settled`]). +#[cfg(test)] pub(crate) fn status_cached(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> &'static str { - image_status(a, document, checks).0 + image_status(a, document, checks, None).0 +} + +/// Whether `a`'s bytes are the document's own copy, with no file to compare them with: embedded +/// with no link, or with the link record Embed keeps (it has a hash; a copy saved before linked +/// storage has none, and is compared with its file). +fn embedded_copy(a: &Asset) -> bool { + a.storage == Storage::Embedded && (a.link.is_none() || a.link_hash.is_some()) } /// [`status_cached`], and the file's size and time when a read found the asset's bytes in it /// although the fingerprint kept with the asset says nothing or something else: what -/// [`remember_link_files`] keeps, so the file isn't read again in another session. -fn image_status(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> (&'static str, Option) { +/// [`remember_link_files`] keeps, so the file isn't read again in another session. What the open +/// of the document found (`open`) answers first, while the asset's bytes are the ones it checked. +fn image_status( + a: &designcraft_doc::Asset, + document: Option<&Path>, + checks: &mut ImageChecks, + open: Option<&super::native_links::OpenLinks>, +) -> (&'static str, Option) { + if embedded_copy(a) { + return ("embedded", None); + } + if a.storage == Storage::Linked { + if let Some(status) = open.and_then(|o| o.status(a)) { + return (status.as_str(), None); + } + return linked_status(a, document, checks); + } let Some(link) = &a.link else { return ("embedded", None) }; // Nothing was read from the file (a package's link that wasn't followed): missing, as in // Preflight. @@ -414,7 +574,7 @@ impl LinkListChecks { /// what [`list_document_links`] finds on a worker thread, where a share that doesn't answer /// can't stall it. pub fn preview_document_links(d: &Document, cache: &Cache, document: Option<&Path>) -> Vec { - list(d, cache, document, None, None).unwrap_or_default() + list(d, cache, document, None, None, None).unwrap_or_default() } /// The rows of `links.list` for a document outside a [`Session`], so a native UI can check the @@ -428,7 +588,7 @@ pub fn list_document_links( checks: &mut LinkListChecks, stop: &AtomicBool, ) -> Option> { - list(d, cache, document, Some(checks), Some(stop)) + list(d, cache, document, Some(checks), Some(stop), None) } /// Keep, with each linked image in `files`, the size and time its file had when a check read it @@ -449,10 +609,18 @@ pub fn remember_link_files(s: &mut Session, doc: &Arc, files: &[(Asset return 0; } let saved = !st.is_dirty(); + let document = st.path.clone(); let d = Arc::make_mut(&mut st.doc); for (id, f) in &fresh { if let Some(a) = d.assets.get_mut(id) { - Arc::make_mut(a).link_fingerprint = Some(*f); + let a = Arc::make_mut(a); + a.link_fingerprint = Some(*f); + // Linked bytes are read by that time too (the same bytes: the key stays). + if a.data.is_linked() + && let Some(source) = LinkedSource::of(a, document.as_deref().map(Path::new)) + { + a.data = a.data.with_source(source); + } } } if saved { @@ -465,7 +633,14 @@ pub fn remember_link_files(s: &mut Session, doc: &Arc, files: &[(Asset /// `files` is what was read this session (a poll reads nothing unchanged); without it no file is /// looked at and a status that needs one is `checking` ([`preview_document_links`]). `None` when /// `stop` was set before the last link was reached. -fn list(d: &Document, cache: &Cache, document: Option<&Path>, files: Option<&mut LinkListChecks>, stop: Option<&AtomicBool>) -> Option> { +fn list( + d: &Document, + cache: &Cache, + document: Option<&Path>, + files: Option<&mut LinkListChecks>, + stop: Option<&AtomicBool>, + open: Option<&super::native_links::OpenLinks>, +) -> Option> { let stopped = || stop.is_some_and(|stop| stop.load(Ordering::Relaxed)); let (mut checks, mut images, mut verified) = match files { Some(files) => { @@ -519,17 +694,21 @@ fn list(d: &Document, cache: &Cache, document: Option<&Path>, files: Option<&mut let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string()); let status = match images.as_deref_mut() { Some(images) => { - let (status, file) = image_status(a, document, images); + let (status, file) = image_status(a, document, images, open); if let (Some(verified), Some(file)) = (verified.as_deref_mut(), file) { verified.push((a.id, file)); } status } - None if a.link.is_none() => "embedded", + None if embedded_copy(a) => "embedded", None if a.data.is_empty() || why.is_some() => "missing", - None => "checking", + None => open.and_then(|o| o.status(a)).map_or("checking", |s| s.as_str()), }; - Some(json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status, "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})) + let storage = match a.storage { + Storage::Linked => "linked", + Storage::Embedded => "embedded", + }; + Some(json!({"asset": a.id.0, "name": a.name, "path": a.link, "storage": storage, "status": status, "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})) }) .chain(texts) .collect() @@ -604,14 +783,12 @@ fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> { Err(EngineError::Other(format!("{}: files can't be read on the web", path.as_ref().display()))) } -/// The replacement for `a`: bytes read from `read`, with `link` recorded as the stored link. -/// Relink passes the file the user chose as both. Update passes the path the stored link resolved -/// to and the stored link itself, so refreshing a link never rewrites what the document holds. -fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result> { +/// The replacement for `a`: bytes read from the file at `read`, linked to it (its absolute path, +/// and its path relative to the `document` file) with a new hash and proxy. +fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, document: Option<&Path>) -> Result> { // The size and time from before the read: a change during it is seen by the next check. let before = super::datamerge::fingerprint_of(read); let (bytes, px) = reload(read)?; - let loaded = loaded_fingerprint(before, &bytes); let page = if designcraft_render::is_pdf(&bytes) { a.page } else { 0 }; let px = if page > 0 { designcraft_render::pdf_page_size(&bytes, page as usize) @@ -622,16 +799,26 @@ fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result }; let mut a = a.clone(); a.mime = designcraft_render::image_mime(&bytes).to_string(); - a.data = designcraft_doc::AssetData::embedded(bytes); a.pixels = Some(px); a.page = page; - a.name = Path::new(link).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| link.to_string()); - a.set_link(Some(link.to_string()), loaded); + a.name = file_name(&read.to_string_lossy()); + let file = FileLink::of(read, before); + give_bytes(&mut a, bytes, Some(file), false, document); // A placed PDF keeps its crop choice; its box is found on the new file. super::file::crop_pdf(&mut a); Ok(Arc::new(a)) } +/// The file Update reads `a`'s bytes from: the first of a linked graphic's paths that holds a +/// file, or where the link of an embedded copy resolves. +fn update_source(a: &Asset, document: Option<&Path>) -> Option { + if a.storage == Storage::Linked { + let source = LinkedSource::of(a, document)?; + return crate::asset_io::candidates(&source).into_iter().find(|p| crate::io::is_file(p)); + } + crate::io::document_path(a.link.as_deref()?, document).ok() +} + /// `links.relink`: a placed graphic or linked text file takes its bytes from `path` from now on. fn relink(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string(); @@ -644,15 +831,17 @@ fn relink(s: &mut Session, p: &Value) -> Result { }); } let aid = asset_param(p, "links.relink")?; - let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?; + let st = s.doc()?; + let a = st.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?; // Build the replacement before the edit: a file that cannot be read leaves the document as it was. - let fresh = relinked_asset(a, Path::new(&path), &path)?; + let fresh = relinked_asset(a, Path::new(&path), st.path.as_deref().map(Path::new))?; let r = s.edit(|d, _| { let name = fresh.name.clone(); d.assets.insert(aid, fresh); Ok(json!({"name": name})) }); s.link_checks.images.clear(); + designcraft_doc::asset_data::links_changed(); r } @@ -679,7 +868,9 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new(); let mut not_found = Vec::new(); for a in d.assets.values() { - if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status_cached(a, document, &mut images) != "missing") { + if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) + || (missing_only && image_status(a, document, &mut images, Some(&st.links)).0 != "missing") + { continue; } let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone())); @@ -716,7 +907,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { let mut fresh = Vec::new(); for (aid, path) in todo { let a = d.assets.get(&aid).ok_or_else(|| bad("links.relinkFolder", "no such graphic"))?; - fresh.push((aid, relinked_asset(a, &path, &path.to_string_lossy())?)); + fresh.push((aid, relinked_asset(a, &path, document)?)); } let n = fresh.len(); if n > 0 { @@ -727,16 +918,177 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { Ok(()) })?; images.clear(); + designcraft_doc::asset_data::links_changed(); } s.link_checks.images = images; Ok(json!({"relinked": n, "notFound": not_found})) } +/// Unembed reads and writes files: not on the web. +fn can_unembed(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if cfg!(target_arch = "wasm32") { + return Err("files can't be linked on the web".into()); + } + Ok(()) +} + +/// `links.embed`: the graphic's bytes go into the document, checked against the link record. +fn embed(s: &mut Session, p: &Value) -> Result { + const ID: &str = "links.embed"; + if let Some(sid) = story_param(p) { + return s.edit(|d, _| { + d.story_mut(sid).ok_or_else(|| bad(ID, "no such story"))?.file_link = None; + Ok(Value::Null) + }); + } + let aid = asset_param(p, ID)?; + let st = s.doc()?; + let document = st.path.as_deref().map(Path::new); + let a = st.doc.assets.get(&aid).ok_or_else(|| bad(ID, "no such graphic"))?; + if a.storage == Storage::Linked { + if cfg!(target_arch = "wasm32") { + return Err(bad(ID, format!("{}: linked files can't be read on the web", a.name))); + } + // Checked even when the bytes are in memory: what is embedded is what the file holds. + match linked_status(a, document, &mut ImageChecks::default()).0 { + "missing" => return Err(bad(ID, format!("{}: the linked file is missing; relink it, then embed it", a.name))), + "modified" => return Err(bad(ID, format!("{}: the linked file has changed; update or relink it, then embed it", a.name))), + _ => {} + } + } + let bytes = a.data.load().map_err(|e| bad(ID, format!("{}: {e}", a.name)))?; + // A link without a hash would be taken for a copy saved before linked storage. + let hash = a.link_hash.clone().or_else(|| a.link.is_some().then(|| crate::asset_io::content_hash(&bytes))); + let key = a.data.key(); + s.edit(|d, _| { + let a = Arc::make_mut(d.assets.get_mut(&aid).ok_or_else(|| bad(ID, "no such graphic"))?); + // The same bytes: what was decoded from them stays usable. + a.data = AssetData::embedded_under(key, bytes); + a.storage = Storage::Embedded; + a.proxy = None; + a.link_hash = hash; + Ok(Value::Null) + }) +} + +/// The file in `folder` named like the original (or `name` with an extension for `mime`) that +/// holds `bytes`: an existing one with exactly these bytes, else a new one written there. +#[cfg(not(target_arch = "wasm32"))] +fn write_into(folder: &Path, name: &str, mime: &str, bytes: &[u8]) -> Result { + const ID: &str = "links.unembed"; + if !crate::io::is_dir(folder) { + return Err(bad(ID, format!("{}: not a folder", folder.display()))); + } + let file = Path::new(name).file_name().map(|n| n.to_string_lossy().into_owned()).filter(|n| !n.is_empty()).unwrap_or_else(|| "graphic".into()); + let mut file = std::path::PathBuf::from(file); + if file.extension().is_none() { + let ext = match mime { + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/tiff" => "tif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + "application/pdf" => "pdf", + "application/postscript" => "eps", + "image/png" => "png", + _ => "bin", + }; + file.set_extension(ext); + } + let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let ext = file.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); + for n in 0..1000u32 { + let candidate = if n == 0 { folder.join(&file) } else { folder.join(format!("{stem}-{n}.{ext}")) }; + if !crate::io::exists(&candidate) { + crate::io::write_file(&candidate, bytes).map_err(EngineError::Other)?; + return Ok(candidate); + } + if file_holds(&candidate, bytes) { + return Ok(candidate); + } + } + Err(bad(ID, format!("{}: no free file name for {}", folder.display(), file.display()))) +} + +/// Does the regular file at `path` hold exactly `bytes`? +#[cfg(not(target_arch = "wasm32"))] +fn file_holds(path: &Path, bytes: &[u8]) -> bool { + crate::io::metadata(path).is_ok_and(|m| m.is_file() && m.len() == bytes.len() as u64) + && crate::io::read_capped(path, bytes.len() as u64).is_ok_and(|b| b == bytes) +} + +/// `links.unembed`: an embedded graphic links to its original file, or to a copy written into `dir`. +fn unembed(s: &mut Session, p: &Value) -> Result { + const ID: &str = "links.unembed"; + can_unembed(s).map_err(|e| bad(ID, e))?; + let aid = asset_param(p, ID)?; + let st = s.doc()?; + let document = st.path.clone(); + let document = document.as_deref().map(Path::new); + let a = st.doc.assets.get(&aid).ok_or_else(|| bad(ID, "no such graphic"))?; + if a.storage == Storage::Linked { + return Err(bad(ID, format!("{} is already linked", a.name))); + } + let bytes = a + .data + .embedded_bytes() + .cloned() + .filter(|b| !b.is_empty()) + .ok_or_else(|| bad(ID, format!("{}: the document holds no bytes for it", a.name)))?; + #[cfg(not(target_arch = "wasm32"))] + { + let original = a.link.as_ref().and_then(|link| { + let source = LinkedSource { + path: link.clone(), + relative: a.link_relative.clone(), + document: document.map(Path::to_path_buf), + len: bytes.len() as u64, + fingerprint: None, + hash: None, + }; + crate::asset_io::candidates(&source).into_iter().find(|p| file_holds(p, &bytes)) + }); + let target = match (original, str_param(p, "dir")) { + (Some(original), _) => original, + (None, Some(dir)) => { + let dir = Path::new(dir); + crate::file_access::check_write(dir).map_err(EngineError::Other)?; + write_into(dir, &a.name, &a.mime, &bytes)? + } + (None, None) => return Err(bad(ID, format!("{}: its original file is missing or has changed; give `dir` to write it there", a.name))), + }; + let before = super::datamerge::fingerprint_of(&target); + let file = FileLink::of(&target, before); + let key = a.data.key(); + let mut linked = (**a).clone(); + give_bytes(&mut linked, Arc::clone(&bytes), Some(file), false, document); + // The same bytes: what was decoded from them stays usable. + if let Some(source) = linked.data.linked_source().cloned() { + linked.data = AssetData::linked_under(key, source); + linked.data.keep_resident(bytes); + } + let path = linked.link.clone(); + let r = s.edit(|d, _| { + d.assets.insert(aid, Arc::new(linked)); + Ok(json!({"path": path})) + }); + s.link_checks.images.clear(); + designcraft_doc::asset_data::links_changed(); + r + } + #[cfg(target_arch = "wasm32")] + { + let _ = (document, bytes); + Err(bad(ID, "files can't be linked on the web")) + } +} + /// `links.update`: re-read the modified links (one, or every one) from where they resolve. fn update(s: &mut Session, p: &Value) -> Result { - let (d, document) = { + let (d, document, open) = { let st = s.doc()?; - (st.doc.clone(), st.path.clone()) + (st.doc.clone(), st.path.clone(), st.links.clone()) }; let document = document.as_deref().map(Path::new); let LinkListChecks { text: checks, images, .. } = &mut s.link_checks; @@ -754,13 +1106,14 @@ fn update(s: &mut Session, p: &Value) -> Result { }) .collect(); let mut fresh = Vec::new(); - for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status_cached(a, document, images) == "modified") { - // The bytes come from where the stored link resolves; the link itself stays as the - // document stored it, so refreshing never rewrites a relative link to an absolute one. - if let Some((link, at)) = a.link.as_deref().and_then(|l| resolved(l).map(|at| (l, at))) { - fresh.push((a.id, relinked_asset(a, &at, link)?)); + for a in + d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| image_status(a, document, images, Some(&open)).0 == "modified") + { + if let Some(at) = update_source(a, document) { + fresh.push((a.id, relinked_asset(a, &at, document)?)); } } + drop(open); let mut fresh_texts = Vec::new(); for (sid, path, at) in texts { let (bytes, before) = read_text(&at)?; @@ -781,6 +1134,7 @@ fn update(s: &mut Session, p: &Value) -> Result { Ok(json!({"updated": n, "warnings": warnings})) }); s.link_checks.images.clear(); + designcraft_doc::asset_data::links_changed(); r } diff --git a/crates/engine/src/cmd/links/tests.rs b/crates/engine/src/cmd/links/tests.rs index 2d156b690..7898e3d68 100644 --- a/crates/engine/src/cmd/links/tests.rs +++ b/crates/engine/src/cmd/links/tests.rs @@ -188,7 +188,9 @@ fn preview_rows_look_at_no_file() { std::fs::remove_file(&gone).unwrap(); let st = s.doc().unwrap(); assert!(st.doc.assets[&ids[0]].link_fingerprint.is_some(), "a placed file is remembered by its size and time"); - assert!(st.doc.assets[&ids[1]].link_fingerprint.is_none(), "embed forgets the file"); + let copy = &st.doc.assets[&ids[1]]; + assert_eq!(copy.storage, designcraft_doc::Storage::Embedded); + assert!(copy.link.is_some() && copy.link_hash.is_some(), "embed keeps the link record for Unembed"); let statuses = |rows: &[Value]| -> Vec { ids.iter().map(|id| rows.iter().find(|r| r["asset"] == id.0).unwrap()["status"].as_str().unwrap().to_string()).collect() }; @@ -243,7 +245,8 @@ fn a_file_written_just_now_is_not_vouched_for_by_its_time() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); - assert_eq!(s.doc().unwrap().doc.assets[&aid].link_fingerprint, None); + let kept = s.doc().unwrap().doc.assets[&aid].link_fingerprint.unwrap(); + assert_eq!((kept.size, kept.mtime), (image.len() as u64, UNVERIFIED_MTIME), "the time can't vouch for the bytes"); let mut other = image.clone(); if let Some(last) = other.last_mut() { *last ^= 0xff; @@ -505,10 +508,14 @@ fn eps_legacy_preview_document_is_upgraded_by_update() { s.execute("file.new", &json!({})).unwrap(); let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); let proxy = designcraft_images::eps_proxy(source).unwrap().0; + // A document saved before linked storage: the preview embedded, a link without a hash. s.edit(|d, _| { let a = Arc::make_mut(d.assets.get_mut(&aid).unwrap()); a.data = designcraft_doc::AssetData::embedded(proxy); a.mime = "image/png".into(); + a.storage = designcraft_doc::Storage::Embedded; + a.link_hash = None; + a.proxy = None; Ok(()) }) .unwrap(); @@ -707,3 +714,197 @@ fn link_status_relink_update_embed() { assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded"); let _ = std::fs::remove_dir_all(&dir); } + +/// A temporary folder, removed when dropped. +#[cfg(not(target_arch = "wasm32"))] +struct TempDir(std::path::PathBuf); + +#[cfg(not(target_arch = "wasm32"))] +impl TempDir { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("dc-links-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Two valid PNGs of one byte length with other pixels (a uniform fill compresses to the same size). +fn same_size_pngs() -> (Vec, Vec) { + let a = designcraft_render::Rendered { width: 30, height: 20, pixels: vec![200u8; 30 * 20 * 4] }.to_png(); + let b = designcraft_render::Rendered { width: 30, height: 20, pixels: vec![100u8; 30 * 20 * 4] }.to_png(); + assert_eq!(a.len(), b.len()); + (a, b) +} + +fn placed(s: &mut Session, params: Value) -> AssetId { + AssetId(s.execute("file.place", ¶ms).unwrap()["asset"].as_u64().unwrap()) +} + +fn asset_of(s: &Session, id: AssetId) -> Arc { + Arc::clone(&s.doc().unwrap().doc.assets[&id]) +} + +fn row(s: &mut Session, id: AssetId) -> Value { + s.execute("links.list", &json!({})).unwrap().as_array().unwrap().iter().find(|r| r["asset"] == id.0).unwrap().clone() +} + +/// The format version a saved bundle declares. +#[cfg(not(target_arch = "wasm32"))] +fn bundle_version(bytes: &[u8]) -> (u64, Vec) { + use std::io::Read; + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let names: Vec = zip.file_names().map(str::to_string).collect(); + let mut version = 0; + for name in names.iter().filter(|n| n.ends_with(".json")) { + let mut text = String::new(); + zip.by_name(name).unwrap().read_to_string(&mut text).unwrap(); + if let Ok(v) = serde_json::from_str::(&text) + && v.get("format").is_some() + { + version = v["version"].as_u64().unwrap(); + } + } + (version, names.into_iter().filter(|n| n.starts_with("assets/")).collect()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_file_placed_from_a_path_links_to_it_and_embed_true_embeds_it() { + use designcraft_doc::Storage; + let dir = TempDir::new("place"); + let path = dir.0.join("art.png"); + let (bytes, _) = same_size_pngs(); + std::fs::write(&path, &bytes).unwrap(); + set_age(&path, 60); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + + let id = placed(&mut s, json!({"path": path.to_string_lossy()})); + let a = asset_of(&s, id); + assert_eq!(a.storage, Storage::Linked); + assert!(a.data.is_linked() && a.data.embedded_bytes().is_none(), "no embedded copy"); + assert_eq!(a.link.as_deref(), path.to_str()); + assert_eq!(a.link_hash.as_deref(), Some(crate::asset_io::content_hash(&bytes).as_str())); + assert!(a.proxy.as_ref().is_some_and(|p| p.pixels == (30, 20))); + assert_eq!(a.data.resident().as_deref(), Some(&bytes), "the bytes read for placing are kept for the first draw"); + let r = row(&mut s, id); + assert_eq!((r["storage"].as_str(), r["status"].as_str()), (Some("linked"), Some("ok"))); + let (version, stored) = bundle_version(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()); + assert_eq!((version, stored), (5, vec![]), "linked: format 5 and no copy of the file"); + + let e = placed(&mut s, json!({"path": path.to_string_lossy(), "embed": true, "x": 200})); + let a = asset_of(&s, e); + assert_eq!(a.storage, Storage::Embedded); + assert_eq!(a.data.embedded_bytes().map(|b| b.as_slice()), Some(bytes.as_slice())); + assert!(a.link.is_some() && a.link_hash.is_some(), "the link record stays for Unembed"); + let r = row(&mut s, e); + assert_eq!((r["storage"].as_str(), r["status"].as_str()), (Some("embedded"), Some("embedded"))); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_modified_link_is_updated_undoably() { + let dir = TempDir::new("update"); + let path = dir.0.join("art.png"); + let (first, second) = same_size_pngs(); + std::fs::write(&path, &first).unwrap(); + set_age(&path, 60); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = placed(&mut s, json!({"path": path.to_string_lossy()})); + let key = asset_of(&s, id).data.key(); + + std::fs::write(&path, &second).unwrap(); + set_age(&path, 30); + assert_eq!(row(&mut s, id)["status"], "modified"); + assert_eq!(asset_of(&s, id).data.load().unwrap().as_slice(), first, "changed bytes are never adopted"); + assert_eq!(s.execute("links.update", &json!({"asset": id.0})).unwrap()["updated"], 1); + let a = asset_of(&s, id); + assert_eq!(row(&mut s, id)["status"], "ok"); + assert_ne!(a.data.key(), key); + assert_eq!(a.link_hash.as_deref(), Some(crate::asset_io::content_hash(&second).as_str())); + assert_eq!(a.data.load().unwrap().as_slice(), second); + + s.execute("edit.undo", &json!({})).unwrap(); + let a = asset_of(&s, id); + assert_eq!((a.data.key(), a.link_hash.clone()), (key, Some(crate::asset_io::content_hash(&first)))); + assert_eq!(row(&mut s, id)["status"], "modified"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn embed_refuses_a_missing_link_and_unembed_links_to_the_original_or_into_a_folder() { + use designcraft_doc::Storage; + let dir = TempDir::new("embed"); + let path = dir.0.join("art.png"); + let (first, second) = same_size_pngs(); + std::fs::write(&path, &first).unwrap(); + set_age(&path, 60); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = placed(&mut s, json!({"path": path.to_string_lossy()})); + + std::fs::remove_file(&path).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("links.embed", &json!({"asset": id.0})).unwrap_err().to_string(); + assert!(err.contains("missing"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before)); + + std::fs::write(&path, &first).unwrap(); + s.execute("links.embed", &json!({"asset": id.0})).unwrap(); + let a = asset_of(&s, id); + assert_eq!((a.storage, a.data.embedded_bytes().map(|b| b.to_vec())), (Storage::Embedded, Some(first.clone()))); + assert_eq!(row(&mut s, id)["status"], "embedded"); + + // The original still holds the bytes: linked to it again. + let r = s.execute("links.unembed", &json!({"asset": id.0})).unwrap(); + assert_eq!(r["path"].as_str(), path.to_str()); + assert_eq!(asset_of(&s, id).storage, Storage::Linked); + assert_eq!(row(&mut s, id)["status"], "ok"); + + // Changed since: no original, so the bytes are written into `dir` (and found there next time). + s.execute("links.embed", &json!({"asset": id.0})).unwrap(); + std::fs::write(&path, &second).unwrap(); + assert!(s.execute("links.unembed", &json!({"asset": id.0})).is_err(), "no original and no folder"); + let out = dir.0.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let r = s.execute("links.unembed", &json!({"asset": id.0, "dir": out.to_string_lossy()})).unwrap(); + let written = out.join("art.png"); + assert_eq!(r["path"].as_str(), written.to_str()); + assert_eq!(std::fs::read(&written).unwrap(), first); + let r = row(&mut s, id); + assert_eq!((r["storage"].as_str(), r["status"].as_str()), (Some("linked"), Some("ok"))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(asset_of(&s, id).storage, Storage::Embedded); + s.execute("links.unembed", &json!({"asset": id.0, "dir": out.to_string_lossy()})).unwrap(); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "the file with the same bytes is reused"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn every_image_the_place_cursor_loads_is_linked() { + let dir = TempDir::new("cursor"); + let files: Vec<_> = (0..3u32) + .map(|i| { + let path = dir.0.join(format!("{i}.png")); + std::fs::write(&path, png(10 + i, 10)).unwrap(); + json!({"path": path.to_string_lossy()}) + }) + .collect(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("place.load", &json!({"files": files})).unwrap(); + s.execute("place.contactSheet", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let used = d.used_assets(); + assert_eq!(used.len(), 3); + assert!(used.iter().all(|id| d.assets[id].storage == designcraft_doc::Storage::Linked && d.assets[id].data.is_linked())); +}