diff --git a/crates/doc/src/asset_data.rs b/crates/doc/src/asset_data.rs index 3171efbaa..7232204ce 100644 --- a/crates/doc/src/asset_data.rs +++ b/crates/doc/src/asset_data.rs @@ -139,13 +139,6 @@ impl AssetData { AssetData { key, repr: Repr::Linked(Arc::new(Linked { source, trusted: Mutex::new(None) })) } } - /// [`AssetData::linked_under`] with `bytes` (the file's, just read) already in the store, so - /// the first draw doesn't read the file again. - pub fn linked_resident(key: SourceKey, source: LinkedSource, bytes: Arc>) -> Self { - store::global().keep(key, bytes); - AssetData::linked_under(key, source) - } - /// This value pointing at `source` (the file moved, the document was saved elsewhere, the /// fingerprint was brought up to date). It keeps the key when both name the same bytes /// (same size and content hash), so nothing decoded is decoded again. diff --git a/crates/engine/src/asset_io.rs b/crates/engine/src/asset_io.rs index bc870135c..65d5ecce6 100644 --- a/crates/engine/src/asset_io.rs +++ b/crates/engine/src/asset_io.rs @@ -100,10 +100,7 @@ pub fn read_linked(source: &LinkedSource, trusted: Option) -> Resul } fn read_candidate(path: &Path, source: &LinkedSource, trusted: Option) -> Result { - let meta = crate::io::metadata(path).map_err(|_| Miss::Absent)?; - if !meta.is_file() { - return Err(Miss::Absent); - } + let meta = crate::io::file_metadata(path).ok_or(Miss::Absent)?; let found = fingerprint_from(&meta); if found.size > MAX_LINKED_BYTES { return Err(Miss::TooLarge); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 55bdc93f5..8c36bc159 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -214,34 +214,17 @@ fn import_report_with_previews( Ok((document, warnings, story_ids)) } -/// `asset`, whose embedded bytes were just read from the file its link names, as a linked asset: -/// the file's record (hash, relative path, fingerprint) and a proxy (the document's preview when -/// the importer kept one, else made from the bytes). The bytes stay resident for the first draw. +/// `asset`, whose embedded bytes were just read from the file its link names, as a linked asset +/// ([`super::links::link_to_file`]), drawn with the document's preview while the file can't be +/// read when the importer kept one. #[cfg(not(target_arch = "wasm32"))] fn link_read_file(asset: &mut designcraft_doc::Asset, package: Option<&std::path::Path>) { let Some(bytes) = asset.data.embedded_bytes().cloned() else { return }; let Some(path) = asset.link.clone() else { return }; - asset.link_hash = Some(crate::asset_io::content_hash(&bytes)); - asset.link_relative = package.and_then(|d| super::native_links::relative_to(d, std::path::Path::new(&path))); - if asset.proxy.is_none() { - asset.proxy = designcraft_render::proxy::make_proxy(&bytes, asset.page).map(|p| designcraft_doc::Proxy { - mime: p.mime.into(), - pixels: p.pixels, - data: designcraft_doc::AssetData::embedded(p.data), - }); - } - asset.storage = designcraft_doc::Storage::Linked; - // A file just written has no settled fingerprint ([`super::links::loaded_fingerprint`]): the - // hash tells its bytes. - let source = designcraft_doc::LinkedSource { - path, - relative: asset.link_relative.clone(), - document: package.map(std::path::Path::to_path_buf), - len: bytes.len() as u64, - fingerprint: asset.link_fingerprint, - hash: asset.link_hash.clone(), - }; - asset.data = designcraft_doc::AssetData::linked_resident(asset.data.key(), source, bytes); + let preview = asset.proxy.take(); + // The fingerprint kept by the read (none for a file just written: its hash tells its bytes). + let file = super::links::FileLink::of(std::path::Path::new(&path), asset.link_fingerprint); + super::links::link_to_file(asset, bytes, file, package, preview); } /// Placed PDFs read from IDML name their crop (`PDFCrop`): find where each box sits on its page, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index badc8238a..1031adb3a 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -200,15 +200,6 @@ impl FileLink { } } -/// `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 { @@ -220,9 +211,9 @@ fn proxy_of(a: &Asset, bytes: &[u8]) -> Option { } /// 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. +/// Bytes read from a file (`file`) link to it ([`link_to_file`]), unless `embed` or on the web, +/// where they are embedded with the link record kept. 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; @@ -234,28 +225,43 @@ pub(crate) fn give_bytes(a: &mut Asset, bytes: impl Into>>, file: Op 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; + record_file(a, &bytes, file, document); 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 })); + link_to_file(a, bytes, file, document, None); +} + +/// Link `a` to `file`, whose bytes (`bytes`) were just read: the file's record (paths, size and +/// time, content hash) and a proxy to draw while the file can't be read (`preview` when there is +/// one, else made from the bytes). The bytes stay in memory for the first draw. +pub(crate) fn link_to_file(a: &mut Asset, bytes: Arc>, file: FileLink, document: Option<&Path>, preview: Option) { + record_file(a, &bytes, file, document); + a.link_fingerprint = Some(a.link_fingerprint.unwrap_or(Fingerprint { size: bytes.len() as u64, mtime: UNVERIFIED_MTIME })); let Some(source) = LinkedSource::of(a, document) else { a.storage = Storage::Embedded; + a.proxy = None; a.data = AssetData::embedded(bytes); return; }; a.storage = Storage::Linked; - a.proxy = proxy_of(a, &bytes); + a.proxy = preview.or_else(|| proxy_of(a, &bytes)); a.data = AssetData::linked(source); a.data.keep_resident(bytes); } +/// Keep with `a` the record of `file`, which `bytes` were read from: its absolute path, its path +/// relative to the `document` file, the size and time that vouch for the bytes +/// ([`loaded_fingerprint`]) and their content hash. +fn record_file(a: &mut Asset, bytes: &[u8], file: FileLink, document: Option<&Path>) { + a.link_fingerprint = loaded_fingerprint(file.before, bytes); + a.link_relative = document.and_then(|d| super::native_links::relative_to(d, Path::new(&file.path))); + a.link = Some(file.path); + a.link_hash = Some(crate::asset_io::content_hash(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 { @@ -274,7 +280,7 @@ fn linked_status(a: &Asset, document: Option<&Path>, checks: &mut ImageChecks) - { 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 Some(meta) = crate::io::file_metadata(&path) else { continue }; let now = crate::asset_io::fingerprint_from(&meta); if Some(now) == source.fingerprint && settled(&now) { return ("ok", None); @@ -1014,7 +1020,7 @@ fn write_into(folder: &Path, name: &str, mime: &str, bytes: &[u8]) -> Result bool { - crate::io::metadata(path).is_ok_and(|m| m.is_file() && m.len() == bytes.len() as u64) + crate::io::file_metadata(path).is_some_and(|m| m.len() == bytes.len() as u64) && crate::io::read_capped(path, bytes.len() as u64).is_ok_and(|b| b == bytes) } diff --git a/crates/engine/src/cmd/native_links.rs b/crates/engine/src/cmd/native_links.rs index 50ceb6e83..3fb0e58d7 100644 --- a/crates/engine/src/cmd/native_links.rs +++ b/crates/engine/src/cmd/native_links.rs @@ -207,7 +207,7 @@ fn locate(a: &mut Asset, source: &LinkedSource, document: Option<&Path>, work: & let found: Vec<(PathBuf, Fingerprint)> = crate::asset_io::candidates(source) .into_iter() .filter_map(|p| { - let meta = crate::io::metadata(&p).ok().filter(std::fs::Metadata::is_file)?; + let meta = crate::io::file_metadata(&p)?; Some((p, crate::asset_io::fingerprint_from(&meta))) }) .collect(); @@ -287,8 +287,7 @@ fn migration(a: &Asset, document: &Path) -> Option { fingerprint: None, hash: None, }; - let path = - crate::asset_io::candidates(&source).into_iter().find(|p| crate::io::metadata(p).is_ok_and(|m| m.is_file() && m.len() == source.len))?; + let path = crate::asset_io::candidates(&source).into_iter().find(|p| crate::io::file_metadata(p).is_some_and(|m| m.len() == source.len))?; Some(Work::Migrate { asset: a.id, key: a.data.key(), bytes, path, page: a.page }) } #[cfg(target_arch = "wasm32")] @@ -387,7 +386,7 @@ fn check(work: Work, cancel: &AtomicBool) -> Option { #[cfg(not(target_arch = "wasm32"))] fn fingerprint(path: &Path) -> Option { - crate::io::metadata(path).ok().filter(std::fs::Metadata::is_file).map(|m| crate::asset_io::fingerprint_from(&m)) + crate::io::file_metadata(path).map(|m| crate::asset_io::fingerprint_from(&m)) } /// Read the file at `path`, which must be `len` bytes, handing each chunk to `keep` (with its diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 995dbe9e6..289f0450b 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -11,7 +11,7 @@ use std::io::Write; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{EngineError, Result, Session}; use designcraft_compose::ComposedStory; -use designcraft_doc::{AssetId, Document}; +use designcraft_doc::{AssetId, Document, Fingerprint, LinkedSource, Storage}; use designcraft_fonts::{FaceRef, FontFace, FontSource}; use serde_json::{Value, json}; @@ -354,7 +354,21 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset // The copy is the linked file now, and the original's size and time say nothing of it. // Its own are too recent to vouch for it yet: the first check compares the bytes and // keeps them (`remember_link_files`). - na.set_link(Some(link), None); + if na.storage == Storage::Linked { + // A linked graphic is told by its hash until then; its paths name the copy. + na.link_relative = relative_to.map(|_| link.clone()); + na.set_link(Some(link), Some(Fingerprint { size: bytes.len() as u64, mtime: super::links::UNVERIFIED_MTIME })); + // Without a folder the link is the copy's full path, which the open document reads + // from now on; a package's document reads its copies once opened beside them. + if relative_to.is_none() + && na.data.is_linked() + && let Some(source) = LinkedSource::of(&na, None) + { + na.data = na.data.with_source(source); + } + } else { + na.set_link(Some(link), None); + } out.assets.insert(id, Arc::new(na)); written.push(path); } diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs index 423e08a58..65578f834 100644 --- a/crates/engine/src/cmd/package/tests.rs +++ b/crates/engine/src/cmd/package/tests.rs @@ -112,7 +112,8 @@ fn relocated_packages_rebind_embedded_image_links_on_open() { let mut opened = Session::new(); opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); - assert_eq!(**asset.data.embedded_bytes().unwrap(), png, "{extension}: the packaged pixels"); + // The IDML copy links Links/owned.png, so its graphic opens as a link to that file. + assert_eq!(asset.data.load().ok().as_deref(), Some(&png), "{extension}: the packaged pixels"); assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); let links = opened.execute("links.list", &json!({})).unwrap(); assert_eq!(links[0]["status"], "ok", "{extension}"); @@ -267,6 +268,8 @@ fn package_leaves_missing_links_missing() { for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { let mut opened = Session::new(); opened.execute(command, &json!({"path": out.join(format!("TwoLinks.{extension}")).to_string_lossy()})).unwrap(); + // The open checks linked files in the background. + super::super::native_links::wait(&mut opened); assert_eq!(status(&mut opened, "gone.svg"), Some(json!("missing")), "{extension}"); assert_eq!(status(&mut opened, "kept.svg"), Some(json!("ok")), "{extension}"); } @@ -329,15 +332,17 @@ fn package_writes_document_links_and_report() { let link = packed.assets.values().next().unwrap().link.clone().unwrap(); assert!(link.contains("Links"), "{link}"); // The link is the copy now: the original file's size and time don't go with it (the copy's - // own are too recent to vouch for it; the first check compares the bytes and keeps them). + // own are too recent to vouch for it; the first check compares the bytes by their hash and + // keeps them). + let unverified = Some(super::super::links::UNVERIFIED_MTIME); assert!(fingerprint(&out.join("Links").join("logo.svg")).is_some()); - assert_eq!(packed.assets.values().next().unwrap().link_fingerprint, None); + assert_eq!(packed.assets.values().next().unwrap().link_fingerprint.map(|f| f.mtime), unverified); // Copy Links To relinks the open document. let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap(); assert_eq!(r["copied"], 1); let relinked = s.doc().unwrap().doc.assets.values().next().unwrap().clone(); assert!(relinked.link.as_deref().unwrap().contains("Copies")); - assert_eq!(relinked.link_fingerprint, None); + assert_eq!(relinked.link_fingerprint.map(|f| f.mtime), unverified); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 15e76a99e..bd14c87be 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -214,9 +214,14 @@ pub fn metadata(path: impl AsRef) -> std::io::Result { std::fs::metadata(path) } +/// [`metadata`] of the regular file at `path`: `None` when there is none automation may read. +pub fn file_metadata(path: impl AsRef) -> Option { + metadata(path).ok().filter(Metadata::is_file) +} + /// Is there a file at `path` that automation may read? pub fn is_file(path: impl AsRef) -> bool { - metadata(path).is_ok_and(|m| m.is_file()) + file_metadata(path).is_some() } /// Is there a folder at `path` that automation may read?