DES-10: linked graphics from IDML, packages and file checks agree with T6 linking

IDML imports link the files they read through links::link_to_file, the
path File > Place takes, so a file read just after it was written keeps
an unverified fingerprint and its status comes from the hash instead of
reading as missing. File > Package and Links > Copy Links To point a
linked graphic's record (paths, unverified fingerprint) at its copy.
Regular-file metadata goes through io::file_metadata, which the file
guard accepts.
This commit is contained in:
aiongg committed 2026-11-14 11:56:59 +00:00
1 parent e8701e3f4c
commit 9c66115fce
8 files changed
+69 -67

No files matched your search

-7
View File
@@ -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<Vec<u8>>) -> 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.
+1 -4
View File
@@ -100,10 +100,7 @@ pub fn read_linked(source: &LinkedSource, trusted: Option<Fingerprint>) -> Resul
}
fn read_candidate(path: &Path, source: &LinkedSource, trusted: Option<Fingerprint>) -> Result<Loaded, Miss> {
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);
+7 -24
View File
@@ -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,
+27 -21
View File
@@ -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<String> {
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<designcraft_doc::Proxy> {
@@ -220,9 +211,9 @@ fn proxy_of(a: &Asset, bytes: &[u8]) -> Option<designcraft_doc::Proxy> {
}
/// 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<Arc<Vec<u8>>>, file: Option<FileLink>, embed: bool, document: Option<&Path>) {
let bytes: Arc<Vec<u8>> = bytes.into();
a.proxy = None;
@@ -234,28 +225,43 @@ pub(crate) fn give_bytes(a: &mut Asset, bytes: impl Into<Arc<Vec<u8>>>, 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<Vec<u8>>, file: FileLink, document: Option<&Path>, preview: Option<designcraft_doc::Proxy>) {
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<std
/// 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::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)
}
+3 -4
View File
@@ -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<Work> {
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<Outcome> {
#[cfg(not(target_arch = "wasm32"))]
fn fingerprint(path: &Path) -> Option<Fingerprint> {
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
+16 -2
View File
@@ -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);
}
+9 -4
View File
@@ -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);
}
+6 -1
View File
@@ -214,9 +214,14 @@ pub fn metadata(path: impl AsRef<Path>) -> std::io::Result<Metadata> {
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<Path>) -> Option<Metadata> {
metadata(path).ok().filter(Metadata::is_file)
}
/// Is there a file at `path` that automation may read?
pub fn is_file(path: impl AsRef<Path>) -> 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?