Files
designcraft/crates/engine/src/cmd/links.rs
T

435 lines
19 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.
use std::sync::Arc;
use designcraft_doc::{AssetId, Content, Document, ItemId, Selection};
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, pixels, uses: [{id, page, ppi}]}]", has_doc, |s, _| {
Ok(Value::Array(list(&s.doc()?.doc)))
}),
cmd!(
"links.relink",
"Relink…",
["Window", "Links"],
None,
"{asset, path} — use another file for every placement of the graphic",
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?} — reload modified links from their files (all when no asset)",
has_doc,
update
),
cmd!("links.embed", "Embed Link", ["Window", "Links"], None, "{asset} — keep only the copy in the document", has_doc, |s, p| {
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} — selects the frames showing the graphic", has_doc, |s, p| {
let aid = asset_param(p, "links.goTo")?;
let st = s.doc_mut()?;
let ids = uses(&st.doc, aid);
if ids.is_empty() {
return Err(bad("links.goTo", "the graphic isn't placed"));
}
st.selection = Selection::items(ids.clone());
st.revision += 1;
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"))
}
/// 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
}
/// Link status of an asset.
pub fn status(a: &designcraft_doc::Asset) -> &'static str {
let Some(path) = &a.link else { return "embedded" };
#[cfg(not(target_arch = "wasm32"))]
{
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_file(path) {
Ok(b) if b == *a.data => "ok",
Ok(_) => "modified",
Err(_) => "missing",
},
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
"ok"
}
}
fn list(d: &Document) -> Vec<Value> {
let used = d.used_assets();
d.assets
.values()
.filter(|a| used.contains(&a.id))
.map(|a| {
let uses: Vec<Value> = uses(d, a.id)
.into_iter()
.map(|id| {
// Effective resolution: image pixels over the size it is shown at.
let ppi = (|| {
let loc = d.find(id)?;
let it = d.item_at(&loc)?;
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 = d.parent_xf(&loc) * 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 = d.page_of_item(id).map(|p| d.page_name(p));
json!({"id": id.0, "page": page, "ppi": ppi})
})
.collect();
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "uses": uses})
})
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn reload(path: &str) -> Result<(Vec<u8>, (u32, u32))> {
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?;
Ok((b, px))
}
#[cfg(target_arch = "wasm32")]
fn reload(path: &str) -> Result<(Vec<u8>, (u32, u32))> {
Err(EngineError::Other(format!("{path}: files can't be read on the web")))
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let aid = asset_param(p, "links.relink")?;
let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string();
let (bytes, px) = reload(&path)?;
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
s.edit(|d, _| {
let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?;
let a = Arc::make_mut(a);
a.mime = designcraft_render::image_mime(&bytes).to_string();
a.data = Arc::new(bytes.clone());
a.pixels = Some(px);
a.name = name.clone();
a.link = Some(path.clone());
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 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 d = &s.doc()?.doc;
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) != "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();
}
let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default();
let cand = folder.join(&name);
if crate::io::exists(&cand) {
todo.push((a.id, cand));
} else {
not_found.push(name.to_string_lossy().to_string());
}
}
let mut n = 0;
for (aid, path) in todo {
relink(s, &json!({"asset": aid.0, "path": path.to_string_lossy()}))?;
n += 1;
}
Ok(json!({"relinked": n, "notFound": not_found}))
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let which: Vec<(AssetId, String)> = d
.assets
.values()
.filter(|a| p.get("asset").and_then(Value::as_u64).is_none_or(|x| x == a.id.0))
.filter(|a| status(a) == "modified")
.filter_map(|a| a.link.clone().map(|l| (a.id, l)))
.collect();
let mut fresh = Vec::new();
for (aid, path) in which {
fresh.push((aid, reload(&path)?));
}
let n = fresh.len();
if n == 0 {
return Ok(json!({"updated": 0}));
}
s.edit(|d, _| {
for (aid, (bytes, px)) in &fresh {
if let Some(a) = d.assets.get_mut(aid) {
let a = Arc::make_mut(a);
a.data = Arc::new(bytes.clone());
a.pixels = Some(*px);
}
}
Ok(json!({"updated": n}))
})
}
#[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()
}
#[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 serde_json::json;
use crate::Session;
#[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(),
link: None,
data: Arc::new(data),
pixels: None,
page: 0,
};
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,
auto_fit: Default::default(),
fit_align: 4,
crop: [1.0; 4],
});
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);
}
}