Files
vectorcraft/crates/engine/src/link_watch.rs
T
dotCooCoo 99f896f9b8 Place: DCS links follow their plate files, clips and document swatches
- The link watch and links.updateChanged look at a DCS file's plate files
  of their own, so a changed plate file is read again.
- links.update and links.relink apply the file's clipping path to the
  images in symbols, graph designs, patterns and masks too. When the new
  file has no clipping path, a clip group holding the old file's clipping
  path loses that path and stays as a plain group of the image, keeping
  its id, name, visibility and transparency. The image keeps that path with its plates to tell it from a
  clipping mask made in the document; a clipping path given another fill
  rule in the document stays. An even-odd clipping path in the file is
  kept as the same area under the nonzero rule.
- links.update and links.relink return the swatch warnings file.place
  gives, including two files that add one ink in two colors.
- links.relink with copy fails for a DCS file with plate files of its own.
- file.place answers the image id of a clipped DCS placement as image,
  and checks page and crop for a DCS file as it does for an EPS file.
- Print separations and Separations Preview take a spot plate's color
  from the document's swatch of its ink, and a reduced image's plates
  keep their coverage.
- Show DCS Transparency Interactions counts a transparent DCS image and
  characters filled or stroked with a transparent gradient.
- %%PlateFile names keep their spaces, and header bytes that are not
  UTF-8 are read as Latin-1.
- A plate program that draws more than one image, art besides its image,
  or its image through a clipping path is an error naming the plate
  instead of printing the wrong ink.
- Nested clipping paths around the composite clip the image together.
- A kept image with more plates than are read, or more TIFF directories
  than its composite and plates, is the CMYK image of its composite
  only, not a set of plates cut short.
- swatch.unused counts a spot or process swatch as used only while an
  image the art shows has a spot plate of its name.
2026-11-12 18:43:04 -08:00

211 lines
11 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.
//! Linked files that change while their document is open (another app saved them: PhotoCraft,
//! LightCraft, Photoshop, a placed VectorCraft document): Preferences › File Handling › Update
//! Links decides, as it does when a document opens. Automatically reads them again
//! (`links.update`, an undo step), Ask When Modified offers to, Manually leaves them modified in
//! the Links panel.
//!
//! The active document's linked files are compared by size and modification time with what they
//! were when last seen. The desktop app takes the stamps every two seconds and when its window
//! comes back to the front, on a worker thread so a file on a slow network share never holds up
//! the interface ([`Session::start_link_scan`], [`Session::poll_link_scan`]);
//! `links.updateChanged` takes them on the spot and reads the changed files again (CLI, agents).
//!
//! Stamps are kept per document: one in the background is looked at when it comes to the front.
//! A file seen for the first time is only remembered (one modified while its document was closed
//! is handled when the document opens); one that can't be read yet (another app is still writing
//! it) keeps its old stamp, so the next look tries again. A change is acted on once: undoing an
//! update, or answering No, leaves the link modified until the file changes again.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use serde_json::{Value, json};
use crate::{Result, Session};
/// A file's size and modification time (nanoseconds since 1970).
pub type Stamp = (u64, u128);
/// Stamps of a document's linked files taken on a worker thread: (document uid, [(file,
/// object ids, stamp)]), the condition the worker signals when it ends, and whether it has.
pub type LinkScan = Arc<(Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>, Condvar, AtomicBool)>;
/// Held by a look's worker: marks the look as ended and wakes [`Session::wait_link_scan`] when
/// dropped, whether the worker returned or unwound.
pub(crate) struct Ended(pub(crate) LinkScan);
impl Drop for Ended {
fn drop(&mut self) {
let (done, finished, ended) = &*self.0;
let _held = done.lock().unwrap_or_else(PoisonError::into_inner);
ended.store(true, Ordering::SeqCst);
finished.notify_all();
}
}
/// Whether a look's worker ended without storing stamps, with `read_ended` reading the flag. The
/// worker stores its stamps and marks the look as ended under the slot's lock, and this holds that
/// lock across both reads, so a look whose worker is storing its stamps never counts as ended without them.
pub(crate) fn ended_without_stamps(scan: &LinkScan, read_ended: impl FnOnce(&AtomicBool) -> bool) -> bool {
let stamps = scan.0.lock().unwrap_or_else(PoisonError::into_inner);
stamps.is_none() && read_ended(&scan.2)
}
/// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when
/// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or
/// automation may not read it.
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str, doc_path: Option<&str>) -> Option<Stamp> {
let path = crate::cmd::fileio::document_path(path, doc_path).ok()?;
let meta = crate::cmd::fileio::file_metadata(&path)?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str, _: Option<&str>) -> Option<Stamp> {
None
}
/// The linked files of `d` with the ids of the objects showing them.
pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
crate::cmd::links::linked_groups(d)
}
/// `links.updateChanged`: stamp the active document's linked files now and read the changed
/// ones again, whatever Update Links says.
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, st.path.as_deref()))).collect();
s.apply_link_stamps(uid, now, true)
}
impl Session {
/// Start stamping the active document's linked files on a worker thread, unless a look is
/// still running, a script is running (or the build has no files to look at: the web app).
pub fn start_link_scan(&mut self) {
if self.link_scan.is_some() || self.script_key.is_some() || cfg!(target_arch = "wasm32") {
return;
}
let Some(st) = self.active() else { return };
let (uid, files, doc_path) = (st.uid, crate::cmd::links::linked_groups(&st.doc), st.path.clone());
// A confined caller's roots, else what the caller that opened the document may read (the
// desktop frame is unconfined).
let opened_by = st.opened_by.clone();
if files.is_empty() {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let scope = crate::file_access::ScopeHandle::capture();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
// Marks the look as ended when the worker returns or unwinds.
let _ended = Ended(out.clone());
let stamps = scope.run(|| {
crate::file_access::confine(crate::file_access::current().or(opened_by).as_ref(), || {
files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect()
})
});
*out.0.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
});
if spawned.is_ok() {
self.link_scan = Some(slot);
}
}
/// Apply a finished look (`None` while there is none, it is still running, or a script is
/// running): see [`Session::apply_link_stamps`]. A look whose worker ended without stamps is
/// cleared, also with `None`, so the next one can start.
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
if self.script_key.is_some() {
return None;
}
self.link_scan.take_if(|scan| ended_without_stamps(scan, |ended| ended.load(Ordering::SeqCst)));
let (uid, stamps) = self.link_scan.as_ref()?.0.lock().unwrap_or_else(PoisonError::into_inner).take()?;
self.link_scan = None;
Some(self.apply_link_stamps(uid, stamps, false))
}
/// Block until the look in flight has ended (at once when none is running), so that the next
/// [`Session::poll_link_scan`] applies its stamps. A look whose worker stopped without stamps
/// ends too, and the poll then clears it and returns `None`. For tests that drive the look step
/// by step.
pub fn wait_link_scan(&self) {
let Some(scan) = &self.link_scan else { return };
let (done, finished, ended) = &**scan;
let guard = done.lock().unwrap_or_else(PoisonError::into_inner);
drop(finished.wait_while(guard, |stamps| stamps.is_none() && !ended.load(Ordering::SeqCst)).unwrap_or_else(PoisonError::into_inner));
}
/// Compare the stamps of document `uid`'s linked files with the ones last seen and act on
/// the changed ones as Update Links says (`update`: read them again regardless). Returns
/// `{updated: [ids], ask: [ids], modified: [ids]}`: read again, to offer (Ask When Modified),
/// or left modified (Manually).
pub fn apply_link_stamps(&mut self, uid: u64, now: Vec<(String, Vec<u64>, Option<Stamp>)>, update: bool) -> Result<Value> {
let none = || json!({"updated": [], "ask": [], "modified": []});
// The document went to the back meanwhile: it's looked at when it's in front again.
if self.active().map(|d| d.uid) != Some(uid) {
return Ok(none());
}
// A drag is in progress: its next preview or cancel would throw an update away while the new
// stamp stayed recorded. Leave the stamps alone; the first look after the drag sees the change.
if self.in_interaction() {
return Ok(none());
}
// This document's last stamps; those of closed documents are dropped.
let open: HashSet<u64> = self.documents().iter().map(|d| d.uid).collect();
let mut before: HashMap<String, Stamp> = HashMap::new();
self.link_stamps.retain(|(u, p), s| {
if *u == uid {
before.insert(p.clone(), *s);
false
} else {
open.contains(u)
}
});
let mut changed: Vec<(String, Vec<u64>)> = Vec::new();
for (path, ids, stamp) in now {
match (before.get(&path), stamp) {
// Not there right now (an app saving by delete and rename): keep the last stamp.
(Some(old), None) => {
self.link_stamps.insert((uid, path), *old);
}
(old, Some(new)) => {
if old.is_some_and(|o| *o != new) {
changed.push((path.clone(), ids));
}
self.link_stamps.insert((uid, path), new);
}
(None, None) => {}
}
}
if changed.is_empty() {
return Ok(none());
}
// Each once: a DCS file and its plate files list the same objects.
let mut seen = HashSet::new();
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).filter(|id| seen.insert(*id)).collect();
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
match mode {
"automatically" => {
let roots = crate::file_access::current().or_else(|| self.active().and_then(|d| d.opened_by.clone()));
let r = crate::file_access::confine(roots.as_ref(), || self.execute("links.update", &json!({"ids": ids})))?;
// Not readable yet (still being written): the next look tries again.
let unread: BTreeSet<u64> = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
for (path, ids) in &changed {
if ids.iter().any(|i| unread.contains(i))
&& let Some(old) = before.get(path)
{
self.link_stamps.insert((uid, path.clone()), *old);
}
}
Ok(json!({"updated": r["updated"], "ask": [], "modified": []}))
}
"askWhenModified" => Ok(json!({"updated": [], "ask": ids, "modified": []})),
_ => Ok(json!({"updated": [], "ask": [], "modified": ids})),
}
}
}