Files
designcraft/crates/engine/src/cmd/links.rs
T
aiongg 3b71900097 Links: resolve stored paths with craft-docpath's resolve_absolute (#1399)
* Links: use resolve_absolute for stored paths

* Links: check for a missing linked file with is_ok_and

* CI: release only with git dependencies from storytold

The winit patch from skylartaylor/winit at its pinned rev is the one other source allowed.

* Links: Relink to Folder only uses a name that stays a file name in the folder

* CI: the git-sources check reads Cargo.lock as TOML, needs it current and refuses config patches

* CI: the git-sources check parses .cargo/config and .cargo/config.toml for source replacement, patches and paths

* Links: rustfmt

* Links: Relink to Folder only takes a file directly in the chosen folder

* Links: Relink to Folder compares folders as the file system resolves them
2026-10-23 21:19:50 -04:00

1521 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 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.
//!
//! A link is followed only where [`crate::io::document_path`] allows it, resolved against the
//! document's own file; a refused link is missing, with the reason in `links.list`.
use std::path::Path;
use std::sync::Arc;
use designcraft_compose::Cache;
use designcraft_doc::{AssetId, Content, Document, Fingerprint, Item, ItemId, Selection, Story, StoryId, TextFileLink};
use designcraft_geom::Affine;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
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, _| {
// Taken out for the call so the document needn't be cloned on every poll.
let mut checks = std::mem::take(&mut s.text_link_checks);
let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks));
s.text_link_checks = checks;
Ok(Value::Array(links?))
}),
cmd!(
"links.relink",
"Relink…",
["Window", "Links"],
None,
"{asset | story, path} — use another file for every placement of the graphic (keeps the selected PDF page; error if missing), or import another text file into the linked story",
has_doc,
relink
),
cmd!(
"links.relinkFolder",
"Relink to Folder…",
["Window", "Links"],
None,
"{dir?: folder to look in (default: each link's own folder), extension?: e.g. \"tif\" (Relink File Extension), assets?: [ids], missingOnly?: bool (default true with `dir`)} → {relinked, notFound: [names]}",
has_doc,
relink_folder
),
cmd!(
"links.update",
"Update Link",
["Window", "Links"],
None,
"{asset? | story?} — reload modified links from their files (all when neither); a linked text file is imported again into its story with the import options it was placed with, replacing edits made in the layout → {updated, warnings}",
has_doc,
update
),
cmd!(
"links.embed",
"Embed Link",
["Window", "Links"],
None,
"{asset | story} — keep only the copy in the document (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).link = None;
Ok(Value::Null)
})
}
),
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()?;
let ids = match story_param(p) {
Some(sid) => st.doc.story(sid).map(|s| s.frames.clone()).unwrap_or_default(),
None => uses(&st.doc, asset_param(p, "links.goTo")?),
};
if ids.is_empty() {
return Err(bad("links.goTo", "the graphic isn't placed"));
}
st.selection = Selection::items(ids.clone());
st.bump_revision();
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}),
]
}
fn asset_param(p: &Value, cmd: &str) -> Result<AssetId> {
p.get("asset").and_then(Value::as_u64).map(AssetId).ok_or_else(|| bad(cmd, "missing asset"))
}
fn story_param(p: &Value) -> Option<StoryId> {
p.get("story").and_then(Value::as_u64).map(StoryId)
}
/// FNV-1a (64-bit) of `bytes`, in hex: a linked text file's fingerprint.
pub(crate) fn fnv_hex(bytes: &[u8]) -> String {
let h = bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0000_0100_0000_01b3));
format!("{h:016x}")
}
/// Fingerprint of a story's text and formatting (edits made in the layout change it).
pub(crate) fn story_hash(st: &Story) -> String {
fnv_hex(serde_json::to_string(&(&st.text, &st.paras, &st.chars)).unwrap_or_default().as_bytes())
}
/// Frames (graphic content) showing an asset, top-level or in groups.
fn uses(d: &Document, aid: AssetId) -> Vec<ItemId> {
let mut out = Vec::new();
for sp in d.spreads.iter().chain(&d.parents) {
for it in &sp.items {
it.walk(&mut |i| {
if let Content::Graphic(g) = &i.content
&& g.asset == aid
{
out.push(i.id);
}
});
}
}
out
}
/// Why a link stored in the document at `document` isn't followed, if it isn't.
pub fn refused(link: &str, document: Option<&Path>) -> Option<crate::io::Refused> {
crate::io::document_path(link, document).err()
}
/// Whether an asset's linked file is gone ([`status`] "missing" without reading the file). An
/// asset can hold data while its file is missing: the document's preview of it. `document` is
/// the document's file, which relative links are resolved against.
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"))]
{
!crate::io::document_path(link, document).is_ok_and(crate::io::exists)
}
#[cfg(target_arch = "wasm32")]
{
let _ = (link, document);
false
}
}
/// Link status of an asset in the document at `document`. Only a regular file is compared, so a
/// link to a device (`/dev/zero`) or a FIFO is missing rather than read.
pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str {
let Some(link) = &a.link else { return "embedded" };
// Nothing was read from the file (a package's link that wasn't followed): missing, as in
// Preflight.
if a.data.is_empty() {
return "missing";
}
#[cfg(not(target_arch = "wasm32"))]
{
let Ok(path) = crate::io::document_path(link, document) else { return "missing" };
if !crate::io::is_file(&path) {
return "missing";
}
match crate::io::metadata(&path) {
Err(_) => "missing",
Ok(m) if m.len() as usize != a.data.len() => "modified",
// Same size: compare the bytes (cheap for the sizes involved, and exact).
Ok(_) => match crate::io::read_capped(&path, a.data.len() as u64) {
Ok(b) if b == *a.data => "ok",
Ok(_) => "modified",
Err(_) => "missing",
},
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = (link, document);
"ok"
}
}
/// The largest linked text file read (the Data Merge source limit). Only regular files are read,
/// so a link to a FIFO or a device (e.g. `/dev/zero`) can't block or fill memory.
const MAX_TEXT_LINK_BYTES: u64 = super::datamerge::MAX_DATA_FILE_BYTES;
/// Can a linked text file this size be checked and updated?
pub(crate) fn checkable_size(len: usize) -> bool {
len as u64 <= MAX_TEXT_LINK_BYTES
}
/// Can a size and time be trusted to change when the file does? Times are whole seconds, so not
/// while the file was changed in the last two: another change in that second keeps the time. A
/// time in the future (clock skew, a file copied from another machine) counts as settled, or such
/// a file would be read on every poll; a later save gives it a time unlike the remembered one.
pub(crate) fn settled(f: &Fingerprint) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
f.mtime > now || now.saturating_sub(f.mtime) >= 2
}
#[cfg(target_arch = "wasm32")]
{
let _ = f;
false
}
}
/// Checked linked text files: path → (size and time, hash of the contents then).
pub(crate) type TextChecks = std::collections::HashMap<String, (Fingerprint, String)>;
/// Link status of a linked text file: `tooLarge` when it is the recorded size but over
/// [`MAX_TEXT_LINK_BYTES`], so its contents aren't checked. The file is read only when its size
/// is the recorded one but its time isn't, and then once per new time (`checks`): a file saved
/// or synced again without changes stays OK. `document` is the document's file.
pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks, document: Option<&Path>) -> &'static str {
#[cfg(not(target_arch = "wasm32"))]
{
let Ok(path) = crate::io::document_path(&l.path, document) else { return "missing" };
if !crate::io::is_file(&path) {
return "missing";
}
let now = match crate::io::metadata(&path) {
Err(_) => return "missing",
Ok(m) => super::datamerge::fingerprint_from(&m),
};
if now.size != l.size {
return "modified";
}
if l.mtime != 0 && now.mtime == l.mtime {
return "ok";
}
let hash = match checks.get(&l.path) {
Some((at, hash)) if *at == now => hash.clone(),
_ if now.size > MAX_TEXT_LINK_BYTES => return "tooLarge",
_ => match read_text(&path) {
Ok((b, at)) => {
let hash = fnv_hex(&b);
// The time from before the read: a change during it is seen next time.
if settled(&at) {
checks.insert(l.path.clone(), (at, hash.clone()));
}
hash
}
Err(_) => return "missing",
},
};
if hash == l.hash { "ok" } else { "modified" }
}
#[cfg(target_arch = "wasm32")]
{
let _ = (l, checks, document);
"ok"
}
}
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
/// A graphic showing an asset: the item, its page, and the transform from its parent's space to
/// the spread.
struct Shown<'a> {
item: &'a Item,
page: Option<usize>,
parent_xf: Affine,
}
/// Where an asset shows: frames on spreads (see [`uses`]) and anchored or inline objects, whose
/// page and position come from the frame their story lays them out in.
fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec<Shown<'a>> {
fn walk<'a>(it: &'a Item, xf: Affine, aid: AssetId, page: Option<usize>, out: &mut Vec<Shown<'a>>) {
if let Content::Graphic(g) = &it.content
&& g.asset == aid
{
out.push(Shown { item: it, page, parent_xf: xf });
}
for c in it.children() {
walk(c, xf * it.xf, aid, page, out);
}
}
let mut out: Vec<Shown> = uses(d, aid)
.into_iter()
.filter_map(|id| {
let loc = d.find(id)?;
Some(Shown { item: d.item_at(&loc)?, page: d.page_of_item(id), parent_xf: d.parent_xf(&loc) })
})
.collect();
for st in d.stories.values() {
let mut composed = None;
for (k, o) in st.objects.iter().enumerate() {
let mut found = false;
o.item.walk(&mut |i| found |= matches!(&i.content, Content::Graphic(g) if g.asset == aid));
if !found {
continue;
}
let cs = composed.get_or_insert_with(|| cache.get(d, st.id, None));
let placed = cs.frames.iter().find_map(|ft| ft.objects.iter().find(|p| p.index == k).map(|p| (ft.frame, p.origin)));
// Overset: no page; the resolution of the object as it would show.
let (page, xf) = match placed {
Some((fid, origin)) => {
let frame_xf =
d.find(fid).map_or(Affine::IDENTITY, |loc| d.parent_xf(&loc)) * d.item(fid).map_or(Affine::IDENTITY, |i| d.text_xf(i));
(d.page_of_item(fid), frame_xf * Affine::translate(origin.to_vec2()))
}
None => (None, Affine::IDENTITY),
};
walk(&o.item, xf, aid, page, &mut out);
}
}
out
}
fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec<Value> {
let texts = d.stories.values().filter_map(|st| {
let l = st.file_link.as_ref()?;
let uses: Vec<Value> = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect();
Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks, document), "refused": refused(&l.path, document).map(|r| r.to_string()), "edited": story_hash(st) != l.story_hash, "uses": uses}))
});
let used = d.used_assets();
d.assets
.values()
.filter(|a| used.contains(&a.id))
.map(|a| {
let uses: Vec<Value> = shown(d, cache, a.id)
.into_iter()
.map(|u| {
let it = u.item;
// Effective resolution: image pixels over the size it is shown at.
let ppi = (|| {
let Content::Graphic(g) = &it.content else { return None };
// Vector (placed PDF or SVG): no effective resolution.
if a.mime == "application/pdf" || a.mime == "image/svg+xml" {
return None;
}
let (pw, _) = a.pixels?;
let xf = u.parent_xf * it.xf * g.xf;
let shown_w = xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)).width();
(shown_w > 0.0).then(|| (pw as f64 / (shown_w / 72.0)).round())
})();
let page = u.page.map(|p| d.page_name(p));
json!({"id": it.id.0, "page": page, "ppi": ppi})
})
.collect();
let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name());
let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string());
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
})
.chain(texts)
.collect()
}
/// A linked text file's bytes, with its size and time from before they were read.
#[cfg(not(target_arch = "wasm32"))]
fn read_text(path: impl AsRef<Path>) -> Result<(Vec<u8>, Fingerprint)> {
let path = path.as_ref();
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
// The roots' own message when they refuse the path.
crate::file_access::check_read(path).map_err(EngineError::Other)?;
let m = crate::io::metadata(path).map_err(err)?;
if !crate::io::is_file(path) {
return Err(EngineError::Other(format!("{}: not a file", path.display())));
}
let too_large = || EngineError::Other(format!("{}: larger than {} MiB", path.display(), MAX_TEXT_LINK_BYTES / (1024 * 1024)));
if m.len() > MAX_TEXT_LINK_BYTES {
return Err(too_large());
}
// At most one byte over the limit, in case the file grew since.
let b = crate::io::read_capped(path, MAX_TEXT_LINK_BYTES).map_err(err)?;
if b.len() as u64 > MAX_TEXT_LINK_BYTES {
return Err(too_large());
}
Ok((b, super::datamerge::fingerprint_from(&m)))
}
#[cfg(target_arch = "wasm32")]
fn read_text(path: impl AsRef<Path>) -> Result<(Vec<u8>, Fingerprint)> {
Err(EngineError::Other(format!("{}: files can't be read on the web", path.as_ref().display())))
}
/// Import a linked text file again; a warning says when edits made in the layout were replaced.
fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fingerprint) -> Result<Vec<String>> {
let edited = d.story(sid).is_some_and(|st| st.file_link.as_ref().is_some_and(|l| story_hash(st) != l.story_hash));
let mut warnings = super::place_text::reimport(d, sid, path, bytes, Some(before))?;
if edited {
warnings.insert(0, format!("{}: edits made to story {} in the layout were replaced by the file's text", file_name(path), sid.0));
}
Ok(warnings)
}
#[cfg(not(target_arch = "wasm32"))]
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
let path = path.as_ref();
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
if designcraft_render::is_pdf(&b) {
designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?;
}
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{}: unsupported or corrupt image", path.display())))?;
Ok((b, px))
}
#[cfg(target_arch = "wasm32")]
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (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<Arc<designcraft_doc::Asset>> {
let (bytes, px) = reload(read)?;
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)
.map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32))
.ok_or_else(|| bad("links", format!("{}: can't read PDF page {}", read.display(), u64::from(page) + 1)))?
} else {
px
};
let mut a = a.clone();
a.mime = designcraft_render::image_mime(&bytes).to_string();
a.data = Arc::new(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.link = Some(link.to_string());
// 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))
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string();
if let Some(sid) = story_param(p) {
let (bytes, before) = read_text(&path)?;
let name = file_name(&path);
return s.edit(|d, _| {
let warnings = reimport(d, sid, &path, &bytes, before)?;
Ok(json!({"name": name, "warnings": warnings}))
});
}
let aid = asset_param(p, "links.relink")?;
let a = s.doc()?.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)?;
s.edit(|d, _| {
let name = fresh.name.clone();
d.assets.insert(aid, fresh);
Ok(json!({"name": name}))
})
}
fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
let dir = str_param(p, "dir").map(std::path::PathBuf::from);
if let Some(dir) = &dir {
// Refused outright: candidates outside the automation read root would only look missing.
crate::file_access::check_read(dir).map_err(EngineError::Other)?;
}
let ext = str_param(p, "extension").map(|e| e.trim_start_matches('.').to_string());
if ext.as_ref().is_some_and(|e| e.contains(['/', '\\', '\0'])) {
return Err(bad("links.relinkFolder", "extension must not contain path separators or NUL"));
}
if dir.is_none() && ext.is_none() {
return Err(bad("links.relinkFolder", "give `dir` and/or `extension`"));
}
let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none());
let only: Option<Vec<u64>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
let st = s.doc()?;
let (d, document) = (&st.doc, st.path.as_deref().map(Path::new));
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(a, document) != "missing") {
continue;
}
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
let mut name = old.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| a.name.clone().into());
if let Some(e) = &ext {
name = std::path::Path::new(&name).with_extension(e).into_os_string();
}
// The link's own folder only where the link may be followed; the name only as a plain
// file name (not a device).
let folder = match &dir {
Some(dir) => Some(dir.clone()),
None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)),
};
let plain_name = name.to_str().filter(|n| !n.contains(['/', '\\']));
// A name that resolves to another file name (a `file:` URL such as `file:%2Ftmp%2Fx.png`,
// a drive-relative `C:x.png`) or a folder the stored-path rules read differently (`a\b`
// on Unix, `alias/..` through a symlink) could lead outside the folder, so only the name
// itself, directly in the folder as the file system resolves it, counts. The link keeps
// the folder as given.
let cand = folder.zip(plain_name).and_then(|(f, n)| {
let c = crate::io::folder_path(n, &f).ok()?;
let real = |p: &Path| crate::io::canonicalize(p).ok();
let same_folder = c.parent().and_then(real).is_some_and(|p| real(&f) == Some(p));
(c.file_name().is_some_and(|c| c == n) && same_folder).then_some(c)
});
if let Some(cand) = cand.filter(|c| crate::io::exists(c)) {
todo.push((a.id, cand));
} else {
not_found.push(name.to_string_lossy().to_string());
}
}
// Load every replacement before committing any of them: a bad file must not leave an
// unrecorded partial edit. Successful batches commit once and have one undo step.
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())?));
}
let n = fresh.len();
if n > 0 {
s.edit(|d, _| {
for (aid, a) in fresh {
d.assets.insert(aid, a);
}
Ok(())
})?;
}
Ok(json!({"relinked": n, "notFound": not_found}))
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let (d, document) = {
let st = s.doc()?;
(st.doc.clone(), st.path.clone())
};
let document = document.as_deref().map(Path::new);
let checks = &mut s.text_link_checks;
let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p));
let all = only_asset.is_none() && only_story.is_none();
// A link craft-docpath refuses is missing, so it is never read or refreshed.
let resolved = |link: &str| crate::io::document_path(link, document).ok();
let texts: Vec<(StoryId, String, std::path::PathBuf)> = d
.stories
.values()
.filter(|st| all || only_story == Some(st.id))
.filter_map(|st| {
let l = st.file_link.as_ref().filter(|l| text_status(l, checks, document) == "modified")?;
Some((st.id, l.path.clone(), resolved(&l.path)?))
})
.collect();
let mut fresh = Vec::new();
for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a, document) == "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)?));
}
}
let mut fresh_texts = Vec::new();
for (sid, path, at) in texts {
let (bytes, before) = read_text(&at)?;
fresh_texts.push((sid, path, bytes, before));
}
let n = fresh.len() + fresh_texts.len();
if n == 0 {
return Ok(json!({"updated": 0}));
}
s.edit(|d, _| {
for (aid, a) in fresh {
d.assets.insert(aid, a);
}
let mut warnings = Vec::new();
for (sid, path, bytes, before) in &fresh_texts {
warnings.extend(reimport(d, *sid, path, bytes, *before)?);
}
Ok(json!({"updated": n, "warnings": warnings}))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
}
fn pdf(sizes: &[(u32, u32)]) -> Vec<u8> {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap();
for (i, (width, height)) in sizes.iter().enumerate() {
s.execute("layout.pageSize", &json!({"pages": [i + 1], "width": width, "height": height})).unwrap();
}
let result = s.execute("file.exportPdf", &json!({})).unwrap();
super::super::file::base64_decode(result["base64"].as_str().unwrap())
}
#[test]
fn anchored_image_lists_its_page_and_resolution() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let r = s
.execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."}))
.unwrap();
let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
let placed = s
.execute(
"file.place",
&json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}),
)
.unwrap();
let id = placed["id"].as_u64().unwrap();
let before = s.execute("links.list", &json!({})).unwrap();
assert_eq!(before[0]["uses"][0]["ppi"], 20.0);
s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap();
let list = s.execute("links.list", &json!({})).unwrap();
let uses = list[0]["uses"].as_array().unwrap();
assert_eq!(uses.len(), 1, "anchored image must have one use: {list}");
assert_eq!(uses[0]["page"], "2");
assert_eq!(uses[0]["ppi"], 20.0);
}
#[test]
fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() {
use std::io::Read;
let bytes = super::super::file::eps_place_tests::tiff_eps();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let placed = s.execute("file.place", &json!({"base64":super::super::file::base64_encode(&bytes),"name":"logo.eps"})).unwrap();
let aid = AssetId(placed["asset"].as_u64().unwrap());
let before = s.doc().unwrap().doc.clone();
let html = s.execute("file.exportHtml", &json!({})).unwrap();
let encoded = html["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap();
let png = super::super::file::base64_decode(encoded);
assert!(png.starts_with(b"\x89PNG"));
assert_eq!(designcraft_render::image_size(&png), Some((2, 2)));
assert_eq!(designcraft_render::decode_pixmap(&png).unwrap().data()[0], designcraft_render::decode_pixmap(&bytes).unwrap().data()[0]);
let result = s.execute("file.exportEpub", &json!({})).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(result["base64"].as_str().unwrap()))).unwrap();
let mut image = Vec::new();
z.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap();
assert_eq!(image, png);
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(opf.contains("media-type=\"image/png\""));
assert!(!opf.contains("image/tiff"));
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
s.edit(|d, _| {
Arc::make_mut(d.assets.get_mut(&aid).unwrap()).data = Arc::new(b"%!PS-Adobe-3.0 EPSF-3.0\n%%EOF\n".to_vec());
Ok(())
})
.unwrap();
let before = s.doc().unwrap().doc.clone();
for command in ["file.exportHtml", "file.exportEpub"] {
let result = s.execute(command, &json!({})).unwrap();
assert_eq!(result["warnings"].as_array().unwrap().len(), 1, "{command}");
if command == "file.exportHtml" {
assert!(!result["text"].as_str().unwrap().contains("<img "));
}
}
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
s.execute("edit.clear", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.assets.contains_key(&aid));
for command in ["file.exportHtml", "file.exportEpub"] {
let result = s.execute(command, &json!({})).unwrap();
assert!(result["warnings"].as_array().unwrap().is_empty(), "unused EPS: {command}");
}
}
#[test]
fn eps_status_tracks_source_bytes_across_save_update_relink_and_embed() {
let dir = std::env::temp_dir().join(format!("dc-eps-status-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("logo.eps");
let first = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n0 0 0 setrgbcolor\n%%EOF\n";
let changed = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n1 0 0 setrgbcolor\n%%EOF\n";
assert_eq!(first.len(), changed.len());
assert_eq!(designcraft_images::eps_proxy(first), designcraft_images::eps_proxy(changed), "same preview, different source");
std::fs::write(&path, first).unwrap();
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.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
let before = s.doc().unwrap().doc.clone();
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 0);
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before), "unchanged EPS must not create an edit");
let saved = designcraft_format::save(&before).unwrap();
let reopened = designcraft_format::load(&saved).unwrap();
assert_eq!(reopened.assets[&aid].data.as_slice(), first);
let mut s = Session::new();
s.add_document(crate::DocState::new(reopened, None));
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
std::fs::write(&path, changed).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified");
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
assert_eq!(s.doc().unwrap().doc.assets[&aid].data.as_slice(), changed);
std::fs::remove_file(&path).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing");
let replacement = dir.join("replacement.eps");
std::fs::write(&replacement, changed).unwrap();
s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
s.execute("links.embed", &json!({"asset": aid.0})).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded");
std::fs::remove_file(&replacement).unwrap();
let embedded = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
// The asset is embedded, so no link is followed and the document is irrelevant.
assert_eq!(status(&embedded.assets[&aid], None), "embedded");
assert_eq!(embedded.assets[&aid].data.as_slice(), changed);
assert!(designcraft_render::decode_pixmap(&embedded.assets[&aid].data).is_some());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn eps_legacy_preview_document_is_upgraded_by_update() {
let dir = std::env::temp_dir().join(format!("dc-eps-legacy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("logo.eps");
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n";
std::fs::write(&path, source).unwrap();
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());
let proxy = designcraft_images::eps_proxy(source).unwrap().0;
s.edit(|d, _| {
let a = Arc::make_mut(d.assets.get_mut(&aid).unwrap());
a.data = Arc::new(proxy);
a.mime = "image/png".into();
Ok(())
})
.unwrap();
let old = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
let mut s = Session::new();
s.add_document(crate::DocState::new(old, None));
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
assert_eq!(s.doc().unwrap().doc.assets[&aid].data.as_slice(), source);
s.execute("edit.undo", &json!({})).unwrap();
assert!(designcraft_render::decode_pixmap(&s.doc().unwrap().doc.assets[&aid].data).is_some(), "old preview remains renderable");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn eps_source_exports_preview_to_pdf_html_and_epub_without_editing_document() {
use hayro_syntax::object::Name;
use std::io::Read;
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n";
let proxy = designcraft_images::eps_proxy(source).unwrap().0;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let aid = AssetId(
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(source), "name": "logo.eps"})).unwrap()["asset"]
.as_u64()
.unwrap(),
);
// A document retaining the original EPS rather than the legacy preview-only asset.
s.edit(|d, _| {
let a = Arc::make_mut(d.assets.get_mut(&aid).unwrap());
a.data = Arc::new(source.to_vec());
a.mime = "application/postscript".into();
Ok(())
})
.unwrap();
let before = s.doc().unwrap().doc.clone();
let result = s.execute("file.exportPdf", &json!({})).unwrap();
assert!(result["warnings"].as_array().unwrap().is_empty(), "{:?}", result["warnings"]);
let pdf = hayro_syntax::Pdf::new(super::super::file::base64_decode(result["base64"].as_str().unwrap())).unwrap();
assert_eq!(
pdf.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter(|st| st.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.count(),
1
);
let result = s.execute("file.exportHtml", &json!({})).unwrap();
let encoded = result["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap();
assert_eq!(super::super::file::base64_decode(encoded), proxy);
let result = s.execute("file.exportEpub", &json!({})).unwrap();
let bytes = super::super::file::base64_decode(result["base64"].as_str().unwrap());
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut image = Vec::new();
archive.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap();
assert_eq!(image, proxy);
let mut opf = String::new();
archive.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(opf.contains("media-type=\"image/png\""));
assert!(!opf.contains("application/postscript"));
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before), "exports must leave the source document intact");
assert_eq!(before.assets[&aid].data.as_slice(), source);
}
#[test]
fn eps_relink_update_and_folder_relink_retain_source() {
let dir = std::env::temp_dir().join(format!("dc-links-eps-{}", std::process::id()));
std::fs::create_dir_all(dir.join("new")).unwrap();
let eps = |w: u32| format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 {w} 10\n%%EOF\n");
let original = dir.join("original.eps");
let replacement = dir.join("replacement.eps");
std::fs::write(&original, eps(10)).unwrap();
std::fs::write(&replacement, eps(30)).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": original.to_string_lossy()})).unwrap();
let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap();
std::fs::write(&original, eps(20)).unwrap();
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((40, 20)));
s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap();
let a = &s.doc().unwrap().doc.assets[&aid];
assert_eq!(a.pixels, Some((60, 20)));
assert_eq!(a.mime, "application/postscript");
assert_eq!(a.data.as_slice(), eps(30).as_bytes());
let moved = dir.join("new/replacement.eps");
std::fs::rename(&replacement, &moved).unwrap();
assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1);
assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str());
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn pdf_relink_and_update_preserve_selected_page_size() {
let dir = std::env::temp_dir().join(format!("dc-links-pdf-size-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("original.pdf");
let replacement = dir.join("replacement.pdf");
std::fs::write(&original, pdf(&[(10, 10), (20, 30)])).unwrap();
std::fs::write(&replacement, pdf(&[(40, 40), (50, 60)])).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap();
let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap();
s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap();
let a = &s.doc().unwrap().doc.assets[&aid];
assert_eq!(a.page, 1);
assert_eq!(a.pixels, Some((50, 60)));
assert!(designcraft_render::render_pdf_page(&a.data, a.page as usize, 64).is_some());
std::fs::write(&replacement, pdf(&[(70, 70), (80, 90)])).unwrap();
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((80, 90)));
let raster = dir.join("replacement.png");
std::fs::write(&raster, png(4, 5)).unwrap();
s.execute("links.relink", &json!({"asset": aid.0, "path": raster.to_string_lossy()})).unwrap();
assert_eq!(s.doc().unwrap().doc.assets[&aid].page, 0);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn pdf_reload_rejects_missing_page_without_edit() {
let full = pdf(&[(10, 10), (20, 30)]);
let short = pdf(&[(10, 10)]);
let dir = std::env::temp_dir().join(format!("dc-links-pdf-page-{}", std::process::id()));
std::fs::create_dir_all(dir.join("new")).unwrap();
let original = dir.join("book.pdf");
let replacement = dir.join("new/book.pdf");
std::fs::write(&replacement, short.clone()).unwrap();
for command in ["links.relink", "links.relinkFolder", "links.update"] {
std::fs::write(&original, &full).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap();
let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap();
if command == "links.update" {
std::fs::write(&original, &short).unwrap();
}
let st = s.doc().unwrap();
let before = st.doc.clone();
let revision = st.revision;
let undo = st.history.undo.len();
let journal = s.journal.len();
let params = match command {
"links.relinkFolder" => json!({"dir": dir.join("new").to_string_lossy(), "missingOnly": false}),
"links.update" => json!({"asset": aid.0}),
_ => json!({"asset": aid.0, "path": replacement.to_string_lossy()}),
};
let err = s.execute(command, &params).unwrap_err();
assert!(err.to_string().contains("PDF page 2"), "{command}: {err}");
let st = s.doc().unwrap();
assert!(Arc::ptr_eq(&st.doc, &before), "{command}");
assert_eq!(st.revision, revision);
assert_eq!(st.history.undo.len(), undo);
assert_eq!(s.journal.len(), journal);
assert!(designcraft_render::render_pdf_page(&st.doc.assets[&aid].data, 1, 64).is_some());
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn link_status_relink_update_embed() {
let dir = std::env::temp_dir().join(format!("dc-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.png");
let b = dir.join("b.png");
std::fs::write(&a, png(10, 10)).unwrap();
std::fs::write(&b, png(20, 10)).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": a.to_string_lossy(), "x": 72, "y": 72, "width": 72})).unwrap();
let l = s.execute("links.list", &json!({})).unwrap();
assert_eq!(l[0]["status"], "ok");
assert_eq!(l[0]["uses"][0]["ppi"], 10.0, "10 px over 1 inch");
let aid = l[0]["asset"].as_u64().unwrap();
// The file changes on disk.
std::fs::write(&a, png(30, 10)).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified");
s.execute("links.update", &json!({})).unwrap();
let l = s.execute("links.list", &json!({})).unwrap();
assert_eq!(l[0]["status"], "ok");
assert_eq!(l[0]["pixels"], json!([30, 10]));
// Missing, then relinked.
std::fs::remove_file(&a).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing");
s.execute("links.relink", &json!({"asset": aid, "path": b.to_string_lossy()})).unwrap();
let l = s.execute("links.list", &json!({})).unwrap();
assert_eq!((l[0]["status"].as_str(), l[0]["name"].as_str()), (Some("ok"), Some("b.png")));
// Go to selects the frame; embed forgets the file.
let r = s.execute("links.goTo", &json!({"asset": aid})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
s.execute("links.embed", &json!({"asset": aid})).unwrap();
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod relink_folder_tests {
use std::sync::Arc;
use serde_json::json;
use crate::Session;
fn two_missing_links(case: &str) -> (Session, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("dc-relink-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("new")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for name in ["a.svg", "b.svg"] {
let path = dir.join(name);
std::fs::write(&path, r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#).unwrap();
s.doc_mut().unwrap().selection = Default::default();
s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap();
std::fs::rename(&path, dir.join("new").join(name)).unwrap();
}
(s, dir)
}
#[test]
fn folder_relink_failure_keeps_document_and_history() {
let (mut s, dir) = two_missing_links("atomic");
std::fs::write(dir.join("new/b.svg"), b"not an image").unwrap();
let st = s.doc().unwrap();
let before = st.doc.clone();
let revision = st.revision;
let undo = st.history.undo.len();
let journal = s.journal.len();
let err = s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap_err();
assert!(err.to_string().contains("unsupported or corrupt image"), "{err}");
let st = s.doc().unwrap();
assert!(Arc::ptr_eq(&st.doc, &before), "a failed batch must not leave its first link changed");
assert_eq!(st.revision, revision);
assert_eq!(st.history.undo.len(), undo);
assert_eq!(s.journal.len(), journal);
s.execute("frame.create", &json!({"rect": [0, 0, 20, 20]})).unwrap();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn folder_relink_is_one_undo_step() {
let (mut s, dir) = two_missing_links("undo");
let st = s.doc().unwrap();
let before = st.doc.clone();
let revision = st.revision;
let undo = st.history.undo.len();
let r = s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap();
assert_eq!(r["relinked"], 2);
let st = s.doc().unwrap();
// `revision` is drawn from a process-wide counter, so a test running beside this one
// can take the next value: that the batch commits *once* is what the single undo step is.
assert_ne!(st.revision, revision, "the batch commits");
assert_eq!(st.history.undo.len(), undo + 1, "exactly one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn relink_extension_rejects_path_separators_without_panicking() {
let (mut s, dir) = two_missing_links("extension");
for extension in ["png/other", "png\\other", "png\0other"] {
// Call directly so the command guard cannot hide a panic in Path::with_extension.
let err = super::relink_folder(&mut s, &json!({"extension": extension})).unwrap_err();
assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}");
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn relink_missing_links_to_a_folder_and_by_extension() {
let dir = std::env::temp_dir().join(format!("dc-relink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("new")).unwrap();
let svg =
|c: &str| format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="{c}"/></svg>"##);
let orig = dir.join("logo.svg");
std::fs::write(&orig, svg("red")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": orig.to_string_lossy()})).unwrap();
// The file moves away: relink from the new folder.
std::fs::rename(&orig, dir.join("new").join("logo.svg")).unwrap();
let r = s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap();
assert_eq!(r["relinked"], 1);
let link = s.doc().unwrap().doc.assets.values().next().unwrap().link.clone().unwrap();
assert!(link.contains("new"), "{link}");
// Relink File Extension: the same name as .png doesn't exist → reported.
let r = s.execute("links.relinkFolder", &json!({"extension": "png"})).unwrap();
assert_eq!(r["relinked"], 0);
assert_eq!(r["notFound"], json!(["logo.png"]));
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod shared_asset_tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
}
#[test]
fn placements_of_one_file_share_an_asset_and_saves_drop_replaced_ones() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = super::super::file::base64_encode(&png(10, 10));
s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 72, "y": 72})).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 200, "y": 72})).unwrap();
let l = s.execute("links.list", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().len(), 1);
assert_eq!(l[0]["uses"].as_array().unwrap().len(), 2);
assert_eq!(s.doc().unwrap().doc.assets.len(), 1);
// A new image into a frame: the old asset stays in memory (undo) but not in the file.
let f = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
let b = super::super::file::base64_encode(&png(20, 10));
let c = super::super::file::base64_encode(&png(30, 10));
s.execute("file.place", &json!({"base64": b, "name": "b.png", "frame": f})).unwrap();
s.execute("file.place", &json!({"base64": c, "name": "c.png", "frame": f})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.assets.len(), 3);
assert_eq!(s.execute("links.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
let mut names: Vec<&str> = back.assets.values().map(|a| a.name.as_str()).collect();
names.sort();
assert_eq!(names, ["a.png", "c.png"]);
}
#[test]
fn files_with_identical_bytes_at_different_paths_keep_their_own_links() {
let dir = std::env::temp_dir().join(format!("dc-same-bytes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.png"), dir.join("b.png"));
std::fs::write(&a, png(10, 10)).unwrap();
std::fs::write(&b, png(10, 10)).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for (path, x) in [(&a, 72), (&b, 200)] {
s.execute("edit.deselectAll", &json!({})).unwrap();
s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap();
}
assert_eq!(s.doc().unwrap().doc.assets.len(), 2);
let rows = |s: &mut Session| -> Vec<(String, Value)> {
let l = s.execute("links.list", &json!({})).unwrap();
l.as_array().unwrap().iter().map(|r| (r["path"].as_str().unwrap().to_string(), r["pixels"].clone())).collect()
};
let paths = |r: &[(String, Value)]| r.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>();
let before = rows(&mut s);
assert_eq!(paths(&before), [a.to_string_lossy(), b.to_string_lossy()]);
// b.png changes on disk: updating it leaves a.png's placement alone.
std::fs::write(&b, png(20, 10)).unwrap();
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
let after = rows(&mut s);
assert_eq!(paths(&after), paths(&before));
assert_eq!((after[0].1.clone(), after[1].1.clone()), (json!([10, 10]), json!([20, 10])));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pasted_graphics_reuse_the_identical_asset_of_the_target() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef};
let asset = |id: u64, data: Vec<u8>| Asset {
id: AssetId(id),
name: "p.png".into(),
mime: "image/png".into(),
data: Arc::new(data),
..Default::default()
};
let mut src = Document::new(&NewDocument::default());
let mut dst = Document::new(&NewDocument::default());
let (pa, pb) = (png(4, 4), png(8, 4));
// The source's asset id is taken in the target by a different file.
let sid = AssetId(src.alloc());
src.assets.insert(sid, Arc::new(asset(sid.0, pa.clone())));
dst.assets.insert(sid, Arc::new(asset(sid.0, pb)));
dst.alloc();
let same = AssetId(dst.alloc());
assert_ne!(same, sid);
dst.assets.insert(same, Arc::new(asset(same.0, pa)));
let iid = ItemId(src.alloc());
let mut it = Item::new(
iid,
src.default_layer(),
Shape::Rectangle,
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
asset: sid,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [1.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
src.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();
let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap();
assert_eq!((g.asset, g.crop), (same, [1.0; 4]));
assert_eq!(dst.assets.len(), 2);
}
#[test]
fn pasted_missing_graphic_does_not_show_an_unrelated_target_asset() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef};
let mut src = Document::new(&NewDocument::default());
let mut dst = Document::new(&NewDocument::default());
// The source graphic points at an asset the source does not have; the target uses that id for another image.
let missing = AssetId(src.alloc());
dst.assets.insert(
missing,
Arc::new(Asset {
id: missing,
name: "p.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(png(8, 4)),
pixels: None,
..Default::default()
}),
);
let iid = ItemId(src.alloc());
let mut it = Item::new(
iid,
src.default_layer(),
Shape::Rectangle,
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
asset: missing,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [1.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
src.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();
let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap();
assert_ne!(g.asset, missing);
assert!(!dst.assets.contains_key(&g.asset));
}
#[test]
fn pasted_missing_graphic_never_takes_an_asset_placed_later() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Graphic, Item, ItemId, Shape, SpreadRef};
let mut src = Document::new(&NewDocument::default());
let mut dst = Document::new(&NewDocument::default());
// The source's missing id is ahead of the target's counter: the target would allocate it to its next image.
for _ in 0..50 {
src.alloc();
}
let missing = AssetId(src.alloc());
let iid = ItemId(src.alloc());
let mut it = Item::new(
iid,
src.default_layer(),
Shape::Rectangle,
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
);
it.content = Content::Graphic(Graphic {
asset: missing,
size: (4.0, 4.0),
xf: designcraft_geom::Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [1.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
src.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();
let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap().asset;
assert!((0..100).all(|_| AssetId(dst.alloc()) != g), "a later image in the target takes the pasted graphic's id");
}
}
#[cfg(test)]
mod text_link_tests {
use serde_json::{Value, json};
use crate::Session;
use designcraft_doc::{ItemId, StoryId};
/// A small Word file: a Title paragraph, then Body paragraphs (`body` is WordprocessingML).
fn docx(body: &str) -> Vec<u8> {
use std::io::Write;
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
let styles = format!(
r#"<w:styles {W}><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:rPr><w:sz w:val="56"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Body"><w:name w:val="Body"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style></w:styles>"#
);
let doc = format!(
r#"<w:document {W}><w:body><w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:t>Head</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Body"/></w:pPr>{body}</w:p></w:body></w:document>"#
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for (n, c) in [("word/document.xml", doc), ("word/styles.xml", styles)] {
w.start_file(n, o).unwrap();
w.write_all(c.as_bytes()).unwrap();
}
w.finish().unwrap().into_inner()
}
fn text_rows(s: &mut Session) -> Vec<Value> {
let l = s.execute("links.list", &json!({})).unwrap();
l.as_array().unwrap().iter().filter(|r| r.get("story").is_some()).cloned().collect()
}
fn story(s: &Session, sid: StoryId) -> designcraft_doc::Story {
s.doc().unwrap().doc.story(sid).unwrap().clone()
}
fn styles(st: &designcraft_doc::Story) -> Vec<String> {
st.paras.iter().map(|p| p.style.clone()).collect()
}
#[test]
fn linked_word_file_status_update_relink_and_unlink() {
let dir = std::env::temp_dir().join(format!("dc-text-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("moved")).unwrap();
let path = dir.join("chapter.docx");
std::fs::write(&path, docx("<w:r><w:t>First draft.</w:t></w:r>")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 9}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Headline", "chars": {"size": 30}})).unwrap();
// Two threaded frames.
let a = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "spread": 1, "content": "text"})).unwrap()["id"].as_u64().unwrap();
s.edit(|d, _| Ok(d.thread(ItemId(a), ItemId(b))?)).unwrap();
s.execute("edit.deselectAll", &json!({})).unwrap();
// Place and link, with a style map and Auto Rename.
let r = s
.execute(
"file.place",
&json!({"path": path.to_string_lossy(), "frame": a, "link": true, "styleMap": {"Title": "Headline"}, "styleConflicts": "autoRename"}),
)
.unwrap();
let sid = StoryId(r["story"].as_u64().unwrap());
let placed = story(&s, sid);
assert_eq!(placed.text, "Head\nFirst draft.");
assert_eq!(styles(&placed), ["Headline", "Body_wrd_1"]);
let link = placed.file_link.clone().unwrap();
assert_eq!(link.path, path.to_string_lossy());
assert_eq!(link.style_conflicts, "autoRename");
assert_eq!(link.style_map.get("Title").map(String::as_str), Some("Headline"));
assert_eq!(link.style_map.get("Body").map(String::as_str), Some("Body_wrd_1"), "Auto Rename's choice is kept");
let rows = text_rows(&mut s);
assert_eq!(rows.len(), 1);
assert_eq!((rows[0]["story"].as_u64(), rows[0]["name"].as_str()), (Some(sid.0), Some("chapter.docx")));
assert_eq!((rows[0]["status"].as_str(), rows[0]["edited"].as_bool()), (Some("ok"), Some(false)));
assert_eq!(rows[0]["uses"].as_array().unwrap().len(), 2, "both threaded frames");
// The file changes (with tracked changes): Modified; Update imports it as if accepted, with
// the saved style map, into the same frames, without new renamed styles.
std::fs::write(
&path,
docx(r#"<w:r><w:t xml:space="preserve">Second </w:t></w:r><w:ins w:id="1" w:author="A"><w:r><w:t>revised </w:t></w:r></w:ins><w:del w:id="2" w:author="A"><w:r><w:delText>old </w:delText></w:r></w:del><w:r><w:t>draft.</w:t></w:r>"#),
)
.unwrap();
assert_eq!(text_rows(&mut s)[0]["status"], "modified");
let r = s.execute("links.update", &json!({"story": sid.0})).unwrap();
assert_eq!(r["updated"], 1);
assert_eq!(r["warnings"], json!([]), "no edits in the layout to replace");
let updated = story(&s, sid);
assert_eq!(updated.text, "Head\nSecond revised draft.");
assert_eq!(styles(&updated), ["Headline", "Body_wrd_1"]);
assert_eq!(updated.frames, [ItemId(a), ItemId(b)], "frames and thread kept");
assert!(s.doc().unwrap().doc.styles.para("Body_wrd_2").is_none());
assert!(s.doc().unwrap().doc.styles.para("Title").is_none(), "mapped styles aren't imported");
assert_eq!(text_rows(&mut s)[0]["status"], "ok");
// An edit in the layout, then the file changes again: Update replaces the edit and warns.
s.execute("story.setText", &json!({"story": sid.0, "text": "Fixed in layout"})).unwrap();
assert_eq!(text_rows(&mut s)[0]["edited"], true);
std::fs::write(&path, docx("<w:r><w:t>Third draft.</w:t></w:r>")).unwrap();
let r = s.execute("links.update", &json!({})).unwrap();
assert_eq!(r["updated"], 1);
let w = r["warnings"][0].as_str().unwrap();
assert!(w.contains("chapter.docx") && w.contains("replaced"), "{w}");
assert_eq!(story(&s, sid).text, "Head\nThird draft.");
assert_eq!(text_rows(&mut s)[0]["edited"], false);
// Undo brings the edit back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(story(&s, sid).text, "Fixed in layout");
s.execute("edit.redo", &json!({})).unwrap();
// Saved and opened again: the link and its options are kept.
let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
assert_eq!(back.story(sid).unwrap().file_link, story(&s, sid).file_link);
assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default(), None), "ok");
// The file moves: Missing; Relink to it imports it into the story.
let moved = dir.join("moved").join("chapter.docx");
std::fs::write(&moved, docx("<w:r><w:t>Moved draft.</w:t></w:r>")).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(text_rows(&mut s)[0]["status"], "missing");
s.execute("links.relink", &json!({"story": sid.0, "path": moved.to_string_lossy()})).unwrap();
let rows = text_rows(&mut s);
assert_eq!((rows[0]["status"].as_str(), rows[0]["path"].as_str()), (Some("ok"), Some(moved.to_string_lossy().as_ref())));
assert_eq!(story(&s, sid).text, "Head\nMoved draft.");
// Go To selects the story's frames; Unlink keeps the text and drops the file.
let r = s.execute("links.goTo", &json!({"story": sid.0})).unwrap();
assert_eq!(r["ids"], json!([a, b]));
s.execute("links.embed", &json!({"story": sid.0})).unwrap();
assert!(text_rows(&mut s).is_empty());
assert_eq!(story(&s, sid).text, "Head\nMoved draft.");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn unchanged_linked_text_files_are_not_read_again() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("dc-text-link-poll-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("note.txt");
std::fs::write(&path, "Hello").unwrap();
// Unreadable: a status that reads the file would say missing.
let readable = |on: bool| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(if on { 0o644 } else { 0o000 })).unwrap();
// A time `secs` ago (a file changed in the last two seconds is always read).
let touch = |secs: u64| {
let f = std::fs::File::options().write(true).open(&path).unwrap();
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)).unwrap();
};
touch(60);
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap();
let status = |s: &mut Session| text_rows(s)[0]["status"].as_str().unwrap().to_string();
// Size and time as placed: not read.
readable(false);
if std::fs::read(&path).is_ok() {
// Running as root: permissions don't stop reads, so this can't be observed.
return;
}
assert_eq!(status(&mut s), "ok");
// Saved again unchanged (a new time): read once, then not again.
readable(true);
touch(30);
assert_eq!(status(&mut s), "ok");
readable(false);
assert_eq!(status(&mut s), "ok", "the second poll doesn't read it");
// Changed, same size, new time: modified.
readable(true);
std::fs::write(&path, "Jello").unwrap();
touch(20);
assert_eq!(status(&mut s), "modified");
// Changed again in the same second, same size: still seen (just-changed files are read).
std::fs::write(&path, "Hello").unwrap();
assert_eq!(status(&mut s), "ok");
std::fs::write(&path, "Jello").unwrap();
assert_eq!(status(&mut s), "modified");
touch(10);
// Updated: the new size and time are kept, so the next poll doesn't read it.
s.execute("links.update", &json!({})).unwrap();
readable(false);
assert_eq!(status(&mut s), "ok");
// A time in the future (clock skew): read once, then not on every poll.
readable(true);
let f = std::fs::File::options().write(true).open(&path).unwrap();
f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(3600)).unwrap();
assert_eq!(status(&mut s), "ok");
readable(false);
assert_eq!(status(&mut s), "ok", "a future time is remembered");
readable(true);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_links_when_placing_text_files_preference() {
let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.txt");
std::fs::write(&path, "Plain text").unwrap();
let place = |s: &mut Session, extra: Value| {
let mut p = json!({"path": path.to_string_lossy(), "rect": [72, 72, 300, 200]});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("file.place", &p).unwrap();
(s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap().file_link.is_some(), r)
};
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert!(!place(&mut s, json!({})).0, "off by default");
assert!(place(&mut s, json!({"link": true})).0);
s.execute("prefs.set", &json!({"linkTextFiles": true})).unwrap();
assert!(place(&mut s, json!({})).0, "the preference links");
assert!(!place(&mut s, json!({"link": false})).0, "`link` overrides it");
// Placed from bytes: no file to link.
let b64 = crate::cmd::base64_encode(b"Plain text");
let r = s.execute("file.place", &json!({"base64": b64, "name": "notes.txt", "rect": [72, 300, 300, 400]})).unwrap();
assert!(s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap().file_link.is_none());
// Into an insertion point: not linked, with a warning.
let fr = s.execute("frame.create", &json!({"rect": [72, 500, 300, 600], "content": "text", "text": "Start end"})).unwrap();
let sid = fr["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 6})).unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(r["warnings"][0].as_str().unwrap().contains("not linked"), "{r}");
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().file_link.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(unix)]
#[cfg(test)]
mod text_link_read_tests {
use std::time::Duration;
use designcraft_doc::TextFileLink;
use super::{MAX_TEXT_LINK_BYTES, read_text, text_status};
/// `f` on another thread: a call that blocks fails the test instead of hanging it.
fn promptly<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
// A timeout means the call blocked.
rx.recv_timeout(Duration::from_secs(10)).unwrap()
}
fn status_of(path: &std::path::Path, size: u64) -> &'static str {
let l = TextFileLink { path: path.to_string_lossy().to_string(), size, hash: "0".into(), ..Default::default() };
promptly(move || text_status(&l, &mut Default::default(), None))
}
fn read(path: &std::path::Path) -> Result<usize, String> {
let p = path.to_string_lossy().to_string();
promptly(move || read_text(&p).map(|(b, _)| b.len()).map_err(|e| e.to_string()))
}
#[test]
fn links_to_fifos_devices_folders_and_huge_files_are_not_read() {
let dir = std::env::temp_dir().join(format!("dc-text-link-read-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// A FIFO with no writer: reading it would block for ever.
let fifo = dir.join("pipe.docx");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
assert_eq!(status_of(&fifo, 0), "missing");
assert!(read(&fifo).is_err());
// A device that never ends, recorded with size 0 (as its metadata reports).
let zero = std::path::Path::new("/dev/zero");
assert_eq!(status_of(zero, 0), "missing");
assert!(read(zero).is_err());
// A folder.
assert_eq!(status_of(&dir, 0), "missing");
assert!(read(&dir).is_err());
// Over the limit (sparse, so cheap): not read; the same size is reported as too large
// to check rather than OK, another size as modified.
let big = dir.join("big.txt");
std::fs::File::create(&big).unwrap().set_len(MAX_TEXT_LINK_BYTES + 1).unwrap();
assert_eq!(status_of(&big, MAX_TEXT_LINK_BYTES + 1), "tooLarge");
assert_eq!(status_of(&big, 10), "modified");
assert!(read(&big).unwrap_err().contains("larger than"));
assert!(!super::checkable_size(MAX_TEXT_LINK_BYTES as usize + 1), "placing it doesn't link it");
let _ = std::fs::remove_dir_all(&dir);
}
}