M4.53: Linked images remember their file and keep a preview; missing links ask Replace or Ignore, modified ones follow Update Links

A linked image (`ImageObject.link`, now a LinkInfo: absolute path, path relative to the document,
size, modification time, hash, page; old path strings still load) keeps a low-resolution preview
(`ImageBlob.proxy`, at most 256 px a side) that a native save writes in place of the pixels of
images only linked images show. Opening a native document reads the linked files again, looking
for each at its path, then relative to the document and by name in its folder: missing files
show their preview, modified ones are read again only with Update Links: Automatically.
`document.open` reports `missingLinks`, `modifiedLinks` and `updatedLinks`.

links.check reports each link's status, links.update reads modified files again and
links.relink points images at another file or at same-named files in a folder (each keeps its
bounds; one undo step). In the app, a document opened without its linked files asks about each
(Replace…, Ignore, Apply to All), then offers to update modified links when the preference asks.
The renderer caches previews apart from the file's pixels; the Eyedropper samples previews in
the object's pixel space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 10:07:10 +07:00
co-authored by Claude Opus 5.5
parent 0c65fe77f3
commit 2e5ec7b251
39 changed files with 1586 additions and 96 deletions
+4 -1
View File
@@ -68,7 +68,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
Some("textObjects")
}
NodeKind::Image(im) => {
images.insert(im.key.clone(), json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link }));
images.insert(
im.key.clone(),
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
);
Some("images")
}
NodeKind::SymbolInstance { symbol, .. } => {
+6 -4
View File
@@ -1,7 +1,6 @@
//! `document.open`: every readable format into a new document.
use std::io::Cursor;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
@@ -132,7 +131,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
/// Open a file's bytes as the new active document (what `document.open` does) →
/// `{index, title, format, warnings}`. `path` is kept for Save only for a native, non-template file.
/// `{index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks}`. `path` is kept
/// for Save only for a native, non-template file; the document's linked files are looked for from
/// it ([`crate::cmd::links::resolve`]).
pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
open_bytes_with(s, name, bytes, path, &Value::Null)
}
@@ -141,6 +142,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
let opts = LoadOptions::from_params("document.open", p)?;
let Loaded { mut doc, format, warnings } = load_with(name, bytes, &opts)?;
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
// A template (saved by Save as Template, or an .ait file) opens as a new untitled document.
let template = doc.template || format.id == "ait";
if template {
@@ -150,7 +152,7 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<S
let keep_path = format.id == "vectorcraft" && !template;
let index = s.add_document(doc, path.filter(|_| keep_path));
let title = s.documents()[index].title();
Ok(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }))
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }), links.to_json()))
}
/// A file named by a command's params: `{path}` (read from disk) or `{name, dataBase64}`.
@@ -201,7 +203,7 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
if width == 0 || height == 0 {
return Err(err("the image is empty"));
}
let blob = ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) };
let blob = ImageBlob::new(mime, bytes);
Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi })
}
+30 -1
View File
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)",
always,
load::open
),
@@ -364,6 +364,35 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
/// keeps one); `None` when there is no file at `path`.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
Some((m.len(), modified))
}
/// `path` made absolute against the working directory (as given when absolute already, or when
/// that fails).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn absolute_path(path: &str) -> String {
if std::path::Path::new(path).is_absolute() {
return path.to_string();
}
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option<u64>)> {
None
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn absolute_path(path: &str) -> String {
path.to_string()
}
#[cfg(target_arch = "wasm32")]
fn no_fs(path: &str) -> EngineError {
EngineError::Other(format!("{path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64)"))
+8 -6
View File
@@ -20,13 +20,15 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res
}
}
/// What Save writes for `st` in format `f` (see [`save_format`]) → the encoded file and the
/// options to remember for the next Save. An SVG save takes the SVG options in `p`, else the ones
/// the document was last saved with, and keeps hidden layers (hidden) unless they say otherwise.
pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, Value)> {
/// What Save writes for `st` in format `f` (see [`save_format`]) to `path` → the encoded file and
/// the options to remember for the next Save. An SVG save takes the SVG options in `p`, else the
/// ones the document was last saved with, and keeps hidden layers (hidden) unless they say
/// otherwise. A native file records its links' paths relative to `path`.
pub fn save_encoding(st: &DocState, f: &Format, p: &Value, path: Option<&str>) -> Result<(Encoded, Value)> {
const C: &str = "document.save";
if f.id == "vectorcraft" {
return Ok((encode_all(&st.doc, f.id, p)?, Value::Null));
let relative = path.and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path));
return Ok((encode_all(relative.as_ref().unwrap_or(&st.doc), f.id, p)?, Value::Null));
}
let given = super::svg_options(p).map_err(|e| bad(C, e))?;
let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) };
@@ -56,7 +58,7 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
stamp_save_dates(s.doc_mut()?);
}
let st = s.doc()?;
let (enc, opts) = save_encoding(st, f, p)?;
let (enc, opts) = save_encoding(st, f, p, path.as_deref())?;
let Some(path) = path else {
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, json!({}));
+1 -1
View File
@@ -1038,7 +1038,7 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
/// An image of `r` (in a clip group when it is clipped).
fn raster_node(d: &mut Document, r: Raster) -> Node {
let key = unique_key(d, "flattened");
d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(r.png) });
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png));
let image = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None }));
let Some(path) = r.clip else { return image };
super::rasterfx::clip_group(d, path, FillRule::NonZero, image)
+477
View File
@@ -0,0 +1,477 @@
//! Linked images (File → Place with Link): finding their files, telling when they changed, and
//! reading them again.
//!
//! A linked image keeps its file's absolute path, the path relative to the document (written on
//! save), the file's size, modification time and hash ([`LinkInfo`]), and a low-resolution
//! preview that the document saves in place of the pixels ([`ImageBlob::proxy`]). A file is looked
//! for at its path, then at its relative path and by name in the document's folder. When a
//! document opens ([`resolve`]), the files found unchanged are read again, modified ones follow
//! Preferences → File Handling → Update Links, and missing ones show their preview.
//!
//! `links.check` reports, `links.update` reads modified files again, `links.relink` points images
//! at other files.
use std::collections::BTreeMap;
use std::path::{Component, Path};
use serde_json::{Value, json};
use vectorcraft_doc::links::hash_bytes;
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, NodeId, NodeKind};
use vectorcraft_geom::Affine;
use super::fileio::{self, RasterImage, absolute_path, file_stamp, read_file};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "links.check",
"Check Links",
[],
None,
"{ids?: [image ids] (default: every linked image)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
has_doc,
check
),
cmd!(
"links.update",
"Update Links",
[],
None,
"{ids?: [image ids] (default: the linked images whose file was modified or is showing its preview)} read their linked files again; each image keeps its bounds. One undo step → {updated: [ids], missing: [ids]}",
has_doc,
update
),
cmd!(
"links.relink",
"Relink",
[],
None,
"{ids?: [image ids] (default: the selected images; with folder and nothing selected, every missing link), path | folder} link images to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}",
has_doc,
relink
),
]
}
// ---------- finding files ----------
/// What became of a linked file since it was read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Ok,
Modified,
Missing,
}
impl Status {
pub fn as_str(self) -> &'static str {
match self {
Status::Ok => "ok",
Status::Modified => "modified",
Status::Missing => "missing",
}
}
}
/// The image objects that show one linked file as one content (same path, same image).
struct Group {
link: LinkInfo,
key: String,
/// The objects in the layers (images in symbols and patterns share the content, not an id).
ids: Vec<NodeId>,
}
/// The linked images of `d` by file and content: those in the layers (only `ids` when given), then,
/// without `ids`, the ones only symbols and patterns show.
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
let mut out: Vec<Group> = vec![];
let mut index: BTreeMap<(String, String), usize> = BTreeMap::new();
let mut add = |link: &LinkInfo, key: &str, id: Option<NodeId>| {
let i = *index.entry((link.path.clone(), key.to_string())).or_insert_with(|| {
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![] });
out.len() - 1
});
if let (Some(id), Some(g)) = (id, out.get_mut(i)) {
g.ids.push(id);
}
};
d.walk(|n| {
if let NodeKind::Image(im) = &n.kind
&& let Some(l) = &im.link
&& ids.is_none_or(|ids| ids.contains(&n.id))
{
add(l, &im.key, Some(n.id));
}
});
if ids.is_none() {
d.visit_images(|_, im| {
if let Some(l) = &im.link {
add(l, &im.key, None);
}
});
}
out
}
/// A linked file where it was found.
struct Found {
path: String,
size: u64,
modified: Option<u64>,
/// Read when telling whether it changed needed them.
bytes: Option<Vec<u8>>,
}
impl Found {
/// The file's bytes (read now unless already read).
fn read(&mut self) -> Result<Vec<u8>> {
match self.bytes.take() {
Some(b) => Ok(b),
None => read_file(&self.path),
}
}
/// `base` pointing at this file as read now (`bytes`).
fn link(&self, bytes: &[u8], base: &LinkInfo) -> LinkInfo {
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash_bytes(bytes)), ..base.clone() }
}
}
/// The link to the file at `path` whose bytes were just read.
pub(crate) fn link_info(path: &str, bytes: &[u8]) -> LinkInfo {
let path = absolute_path(path);
LinkInfo {
size: Some(bytes.len() as u64),
modified: file_stamp(&path).and_then(|(_, m)| m),
hash: Some(hash_bytes(bytes)),
..LinkInfo::new(path)
}
}
/// The file at `path` when there is one.
fn found_at(path: String) -> Option<Found> {
let (size, modified) = file_stamp(&path)?;
Some(Found { path, size, modified, bytes: None })
}
/// Where the file of `link` is: at its path, else at its relative path or by name in the
/// document's folder `dir`.
fn locate(link: &LinkInfo, dir: Option<&Path>) -> Option<Found> {
let mut paths = vec![link.path.clone()];
if let Some(dir) = dir {
let near = link.relative.iter().map(|r| dir.join(r)).chain([dir.join(link.name())]);
paths.extend(near.map(|p| p.to_string_lossy().into_owned()));
}
paths.into_iter().find_map(found_at)
}
/// Where the file of group `g` is and whether it still has the content shown. Its bytes are read
/// only when its size or modification time changed.
fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
let Some(mut f) = locate(&g.link, dir) else { return (Status::Missing, None) };
let l = &g.link;
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
return (Status::Ok, Some(f));
}
let Ok(bytes) = f.read() else { return (Status::Missing, None) };
let same = match &l.hash {
Some(h) => *h == hash_bytes(&bytes),
// Linked before links kept a hash: the image the file decodes to.
None => fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key),
};
f.bytes = Some(bytes);
(if same { Status::Ok } else { Status::Modified }, Some(f))
}
/// The folder of the document saved at `doc_path`.
fn folder(doc_path: Option<&str>) -> Option<&Path> {
doc_path.and_then(|p| Path::new(p).parent())
}
/// Is image `key` of `d` showing its preview (its file not read)?
fn previewing(d: &Document, key: &str) -> bool {
d.images.get(key).is_none_or(ImageBlob::is_proxy)
}
// ---------- reading files into the document ----------
/// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a
/// preview there is kept, and a linked image gets one.
pub(crate) fn store_image(d: &mut Document, key: &str, full: ImageBlob, linked: bool) {
let blob = match d.images.get(key) {
Some(b) if !b.is_proxy() => b.clone(),
old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()), ..full },
};
d.images.insert(key.to_string(), if linked { blob.with_proxy() } else { blob });
}
/// The file read for a link: the link to it and its image.
type FileRead = (LinkInfo, RasterImage);
/// The file at `f` read for a link like `base` (its image decoded).
fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
let bytes = f.read()?;
Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?))
}
/// Image objects `ids` show the file read for `link` (`img`), each keeping its bounds.
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead) {
store_image(d, &img.key, img.blob, true);
for id in ids {
if let Some(n) = d.node_mut(*id)
&& let NodeKind::Image(im) = &mut n.kind
{
if (im.width, im.height) != (img.width, img.height) {
im.xf *= Affine::scale_non_uniform(im.width as f64 / img.width as f64, im.height as f64 / img.height as f64);
}
(im.key, im.width, im.height, im.link) = (img.key.clone(), img.width, img.height, Some(link.clone()));
}
}
}
/// Groups as `{name, path, ids}` rows (those with objects in the layers).
fn rows<'a>(gs: impl Iterator<Item = &'a Group>) -> Vec<Value> {
gs.filter(|g| !g.ids.is_empty()).map(|g| json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) })).collect()
}
fn ids_json(ids: &[NodeId]) -> Vec<u64> {
ids.iter().map(|id| id.0).collect()
}
/// What [`resolve`] found when a document opened.
#[derive(Default)]
pub struct Resolved {
/// `{name, path, ids}` of files that weren't found: their images show the preview.
pub missing: Vec<Value>,
/// Modified files left as they were (Update Links isn't Automatically).
pub modified: Vec<Value>,
/// Modified files read again (Update Links: Automatically).
pub updated: Vec<Value>,
}
impl Resolved {
/// The `document.open` result fields.
pub fn to_json(&self) -> Value {
json!({ "missingLinks": self.missing, "modifiedLinks": self.modified, "updatedLinks": self.updated })
}
}
/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read
/// again (their images had the preview), modified ones are read again when `update`, links found
/// away from their path take the new path. Nothing here is an undo step.
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
let mut out = Resolved::default();
for g in groups(d, None) {
let row = rows(std::iter::once(&g));
match probe(&g, folder(doc_path)) {
(Status::Missing, _) => out.missing.extend(row),
(Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link) {
Ok(read) => {
install(d, &g.ids, read);
out.updated.extend(row);
}
Err(_) => out.missing.extend(row),
},
(Status::Modified, _) => out.modified.extend(row),
(Status::Ok, Some(mut f)) => {
let preview = previewing(d, &g.key);
// Read when the images show the preview, or when the file's details changed.
let bytes = if preview || f.bytes.is_some() { f.read().ok() } else { None };
if preview {
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
out.missing.extend(row);
continue;
};
store_image(d, &g.key, img.blob, true);
} else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) {
// Linked before links kept a preview.
let b = b.clone().with_proxy();
d.images.insert(g.key.clone(), b);
}
// Found away from its path, or read again: the link follows.
let link = match &bytes {
Some(b) => f.link(b, &g.link),
None => LinkInfo { path: f.path.clone(), ..g.link.clone() },
};
if link != g.link {
set_links(d, &g.ids, &link);
}
}
(Status::Ok, None) => {}
}
}
out
}
/// Image objects `ids` link to `link`.
fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) {
for id in ids {
if let Some(n) = d.node_mut(*id)
&& let NodeKind::Image(im) = &mut n.kind
{
im.link = Some(link.clone());
}
}
}
// ---------- relative paths ----------
/// `target` relative to folder `base` with `/` separators; `None` when they share no root (another
/// drive, or either is relative).
fn relative_path(target: &Path, base: &Path) -> Option<String> {
let (t, b): (Vec<Component>, Vec<Component>) = (target.components().collect(), base.components().collect());
if !matches!(t.first(), Some(Component::Prefix(_) | Component::RootDir)) {
return None;
}
// Windows paths compare without case.
let same = |x: &Component, y: &Component| if cfg!(windows) { x.as_os_str().eq_ignore_ascii_case(y.as_os_str()) } else { x == y };
let common = t.iter().zip(&b).take_while(|(x, y)| same(x, y)).count();
if common == 0 {
return None;
}
let ups = std::iter::repeat_n(std::borrow::Cow::Borrowed(".."), b.len().saturating_sub(common));
let rest = t.iter().skip(common).map(|c| c.as_os_str().to_string_lossy());
Some(ups.chain(rest).collect::<Vec<std::borrow::Cow<str>>>().join("/"))
}
/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they
/// are already.
pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
let dest = absolute_path(dest);
let dir = Path::new(&dest).parent()?;
let mut changes = vec![];
d.walk(|n| {
if let NodeKind::Image(im) = &n.kind
&& let Some(l) = &im.link
{
let r = relative_path(Path::new(&l.path), dir);
if r != l.relative {
changes.push((n.id, r));
}
}
});
if changes.is_empty() {
return None;
}
let mut d = d.clone();
for (id, r) in changes {
if let Some(n) = d.node_mut(id)
&& let NodeKind::Image(im) = &mut n.kind
&& let Some(l) = &mut im.link
{
l.relative = r;
}
}
Some(d)
}
// ---------- commands ----------
fn check(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let ids = ids_param(p, "ids");
let (mut missing, mut modified) = (0, 0);
let mut links = vec![];
for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) {
let (status, found) = probe(g, folder(st.path.as_deref()));
missing += usize::from(status == Status::Missing);
modified += usize::from(status == Status::Modified);
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
if let Some(f) = found.filter(|f| f.path != g.link.path) {
row["found"] = json!(f.path);
}
if previewing(&st.doc, &g.key) {
row["preview"] = json!(true);
}
links.push(row);
}
Ok(json!({ "links": links, "missing": missing, "modified": modified }))
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let ids = ids_param(p, "ids");
let (mut reads, mut missing) = (vec![], vec![]);
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) {
match probe(&g, folder(st.path.as_deref())) {
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
// Unchanged and showing the file: nothing to read unless asked for by id.
(Status::Ok, Some(_)) if ids.is_none() && !previewing(&st.doc, &g.key) => {}
// Unreadable as an image counts as missing.
(_, Some(mut f)) => match read_image(&mut f, &g.link) {
Ok(read) => reads.push((g.ids, read)),
Err(_) => missing.extend(ids_json(&g.ids)),
},
}
}
let updated: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
if !reads.is_empty() {
s.edit("Update Links", |d, _| {
reads.into_iter().for_each(|(ids, read)| install(d, &ids, read));
Ok(())
})?;
}
Ok(json!({ "updated": updated, "missing": missing }))
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.relink";
let st = s.doc()?;
let folder_param = str_param(p, "folder");
let image = |id: &NodeId| st.doc.node(*id).and_then(|n| if let NodeKind::Image(im) = &n.kind { Some(im) } else { None });
let ids: Vec<NodeId> = match ids_param(p, "ids") {
Some(ids) => ids,
None => {
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
match folder_param {
Some(_) if selected.is_empty() => groups(&st.doc, None)
.into_iter()
.filter(|g| probe(g, folder(st.path.as_deref())).0 == Status::Missing)
.flat_map(|g| g.ids)
.collect(),
_ => selected,
}
}
};
if let Some(id) = ids.iter().find(|id| image(id).is_none()) {
return Err(bad(C, format!("object {} is not an image", id.0)));
}
if ids.is_empty() {
return Err(bad(C, "select the images to relink, or pass ids"));
}
// (ids, the file) per file to read.
let mut files: Vec<(Vec<NodeId>, String)> = vec![];
let mut not_found = vec![];
match (str_param(p, "path"), folder_param) {
(Some(path), _) => files.push((ids, absolute_path(path))),
(None, Some(dir)) => {
for id in ids {
let Some(link) = image(&id).and_then(|im| im.link.as_ref()) else { continue };
let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy());
match files.iter_mut().find(|(_, p)| *p == path) {
Some((ids, _)) => ids.push(id),
None if file_stamp(&path).is_some() => files.push((vec![id], path)),
None => not_found.push(link.name().to_string()),
}
}
}
(None, None) => return Err(bad(C, "give path (a file) or folder")),
}
let mut reads = vec![];
for (ids, path) in files {
let bytes = read_file(&path)?;
let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?;
reads.push((ids, (link_info(&path, &bytes), img)));
}
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
if !reads.is_empty() {
s.edit("Relink", |d, _| {
reads.into_iter().for_each(|(ids, read)| install(d, &ids, read));
Ok(())
})?;
}
not_found.sort();
not_found.dedup();
Ok(json!({ "relinked": relinked, "notFound": not_found }))
}
+2 -2
View File
@@ -395,7 +395,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
let id = s.edit("Rasterize", |d, sel| {
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let key = unique_key(d, "raster");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
let id = d.alloc_id();
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
@@ -547,7 +547,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
s.edit("Crop Image", |d, _| {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
if let Some(n) = d.node_mut(id) {
n.kind = NodeKind::Image(ImageObject {
key,
+2
View File
@@ -26,6 +26,7 @@ pub(crate) mod gradient;
pub(crate) mod graph;
pub mod help;
mod layer;
pub mod links;
mod live;
pub(crate) mod maskedit;
pub(crate) mod menucmds;
@@ -218,6 +219,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(docsetup::specs());
v.extend(newdoc::specs());
v.extend(place::specs());
v.extend(links::specs());
v
})
}
+15 -13
View File
@@ -2,8 +2,8 @@
//! the clipboard.
//!
//! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its path with
//! `link`.
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with
//! `link` ([`super::links`]).
//! - An SVG becomes one group of its art.
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
@@ -20,7 +20,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling};
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_pdf::CropTo;
@@ -98,8 +98,8 @@ struct Loaded {
/// The art's natural box (what `at` centres and `rect` fits).
natural: Rect,
warnings: Vec<String>,
/// The file's path (for Link).
path: Option<String>,
/// The link to a raster image's file (for Link), when read from a path.
link: Option<LinkInfo>,
}
/// Points per pixel of a raster image at the resolution it declares (72 ppi when none).
@@ -127,12 +127,12 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
));
}
let crop = opts.crop != CropTo::Bounding;
let path = src.path.map(str::to_string);
if format.raster {
let img = fileio::raster_image(&src.bytes)?;
let (sx, sy) = pt_per_px(img.ppi);
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], path });
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link });
}
let (mut doc, warnings) = match format.id {
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
@@ -142,7 +142,9 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))?
}
_ => {
let l = fileio::load(src.name, &src.bytes)?;
let mut l = fileio::load(src.name, &src.bytes)?;
// Linked images (a native document's) show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false);
(l.doc, l.warnings)
}
};
@@ -167,7 +169,7 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
};
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, path })
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None })
}
/// A visible, non-template layer.
@@ -191,11 +193,11 @@ fn art_of<'a>(nodes: impl Iterator<Item = &'a Arc<Node>>) -> Vec<Node> {
}
/// The art as one object of `d` (its resources added), at its natural size and position.
fn build(d: &mut Document, l: Loaded, link: Option<String>) -> Node {
fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
let mut node = match l.art {
Art::Image(img) => {
let (sx, sy) = pt_per_px(img.ppi);
d.images.entry(img.key.clone()).or_insert(img.blob);
super::links::store_image(d, &img.key, img.blob, link.is_some());
let im = ImageObject { key: img.key, width: img.width, height: img.height, xf: Affine::scale_non_uniform(sx, sy), link };
Node::new(d.alloc_id(), NodeKind::Image(im))
}
@@ -288,7 +290,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
};
let parent = st.insertion_parent();
let below = st.current_layer().or_else(|| parent.and_then(|p| st.doc.layer_of(p)));
let link = loaded.path.take().filter(|_| bool_or(p, "link", true) && loaded.format.raster);
let link = loaded.link.take().filter(|_| bool_or(p, "link", true));
let linked = link.is_some();
let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings));
let (id, size) = s.edit("Place", |d, sel| {
@@ -462,7 +464,7 @@ fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
"id": id.0,
"name": n.display_name(),
"linked": im.link.is_some(),
"link": im.link,
"link": im.link.as_ref().map(|l| &l.path),
"colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)),
"pixelWidth": im.width,
"pixelHeight": im.height,
+1 -1
View File
@@ -201,7 +201,7 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
while out.images.contains_key(&key) {
key.push('+');
}
out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
out.images.insert(key.clone(), ImageBlob::new("image/png", png));
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut image = Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
image.name = Some("Raster effect".into());