diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index b7bb78098..72574e5e0 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -370,7 +370,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let update_links = s.prefs.update_links == "automatically"; - open_read(s, read_for_open(name, bytes, path, p, update_links)?) + open_read(s, read_for_open(name, bytes, path, p, update_links, s.prefs.low_res_proxy_eps)?) } /// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]). @@ -396,10 +396,10 @@ impl ReadFile { /// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update /// Links set to Automatically; see [`crate::cmd::links::resolve`]). -pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool) -> Result { +pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; let mut loaded = load_with(name, bytes, &opts)?; - let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links); + let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) } @@ -419,7 +419,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", )); } - let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically"); + let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index ef4fd5fad..468c18559 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -21,7 +21,8 @@ //! Link) link to their files the same way: checking, updating, relinking and finding them on open //! work alike, and they are listed. Embed (Break Link) turns one into an editable copy of its art. -use std::collections::BTreeMap; +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Component, Path}; use serde_json::{Value, json}; @@ -286,6 +287,18 @@ fn previewing(d: &Document, key: &str) -> bool { d.images.get(key).is_none_or(ImageBlob::is_proxy) } +/// A linked raster image whose file is an EPS (Preferences › File Handling › Use Low Resolution +/// Proxy for Linked EPS). +fn linked_eps(link: &LinkInfo) -> bool { + fileio::format_for_name(link.name()).is_some_and(|f| f.id == "eps") +} + +/// With `low_res_proxy_eps`, a linked EPS image that already shows its saved preview keeps it on +/// open instead of reading the file again (Update Links still reads modified files). +fn keep_eps_preview(low_res_proxy_eps: bool, g: &Group, d: &Document) -> bool { + low_res_proxy_eps && g.document.is_none() && linked_eps(&g.link) && previewing(d, &g.key) +} + /// Does group `g` of `d` show something other than its file: an image its preview, a placed /// document nothing (its file's bytes are gone)? fn stale(d: &Document, g: &Group) -> bool { @@ -324,6 +337,47 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec) { + let mut keys = BTreeSet::new(); + d.visit_images(|_, im| { + if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) { + keys.insert(im.key.clone()); + } + }); + if keys.is_empty() { + return (Cow::Borrowed(d), vec![]); + } + let mut out = d.clone(); + let mut previews = BTreeSet::new(); + let all = groups(d, None); + for key in keys { + let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue }; + let Some(mut f) = locate(&g.link, doc_path) else { + previews.insert(key); + continue; + }; + let Some(bytes) = f.read().ok() else { + previews.insert(key); + continue; + }; + let Ok(img) = fileio::raster_image(&bytes) else { + previews.insert(key); + continue; + }; + let preview = d.images.get(&key).map(|b| b.bytes.clone()); + let mime = fileio::format_for_name(g.link.name()).map(|f| f.mime.to_string()).unwrap_or_else(|| img.blob.mime.clone()); + out.images.insert(key, ImageBlob { mime, bytes: img.blob.bytes, proxy: preview }); + } + let mut warnings = vec![]; + if !previews.is_empty() { + warnings + .push(format!("{} linked image(s) output as their previews: their files can't be found or read (relink or update them)", previews.len())); + } + (Cow::Owned(out), warnings) +} + /// The linked files of the open document `st` that `links.findFiles` looks for, each with the /// objects in the layers that show it: the files of objects `ids` whatever their status, else the /// files that can't be found. @@ -559,11 +613,12 @@ impl Resolved { /// away from their path take the new path. A link nothing is known of whose image shows a preview /// (a file another app wrote kept only that) was never read: its file is read now, into the /// image's bounds, which isn't a change to report. Nothing here is an undo step. -pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved { +pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); for g in groups(d, None) { let row = rows(std::iter::once(&g), doc_path); let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); + let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d); match probe(&g, doc_path) { (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { @@ -598,8 +653,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv (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 bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None }; + if preview && !eps_preview { let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else { out.missing.extend(row); continue; diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 08ce975fb..cc9867dc5 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -127,7 +127,7 @@ pub(crate) fn document_art( } let mut l = fileio::load(name, bytes)?; // Its linked images show their files; its placed documents stay as they are. - super::super::links::resolve(&mut l.doc, path, false); + super::super::links::resolve(&mut l.doc, path, false, false); l.doc.drop_edit_modes(); let n = l.doc.artboards.len(); let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect); @@ -281,22 +281,30 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))) } -/// `d` for output: the placed documents that keep only their previews get their files back, -/// else (their files gone or changed) output as their previews, with a warning. +/// `d` for output: linked images and placed documents that keep only their previews get their +/// files back, else output as their previews, with a warning. pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { + let (d, mut warnings) = super::super::links::full_linked_images(d, None); + let doc = match &d { + Cow::Borrowed(doc) => doc, + Cow::Owned(doc) => doc, + }; let mut need: Vec = vec![]; - d.visit_placed(|_, p| { - if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) { + doc.visit_placed(|_, p| { + if doc.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) { need.push(p.clone()); } }); if need.is_empty() { - return (Cow::Borrowed(d), vec![]); + return (d, warnings); } - let mut out = d.clone(); + let mut out = match d { + Cow::Borrowed(doc) => doc.clone(), + Cow::Owned(doc) => doc, + }; let mut previews = BTreeSet::new(); for p in need { - let Some(blob) = d.images.get(&p.key) else { continue }; + let Some(blob) = out.images.get(&p.key) else { continue }; match vectorcraft_doc::placed_document::full_bytes(&p, blob) { Some(bytes) => { out.images.insert(p.key.clone(), ImageBlob { mime: mime(p.link.name(), &bytes).into(), bytes, proxy: blob.proxy.clone() }); @@ -306,7 +314,6 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { } } } - let mut warnings = vec![]; if !previews.is_empty() { warnings.push(format!( "{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)", diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 1fad8a494..3d4a0d54f 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -242,7 +242,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result, file: &str) -> R doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() }; } // Linked files are looked for from the document's own folder. - let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically"); + let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); let index = s.add_document(doc, meta.path.clone()); let st = s.doc_mut()?; st.recovered = true; diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index b1d5585b2..9ddfa33e9 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -293,6 +293,60 @@ fn a_modified_link_follows_the_update_links_preference() { assert!(s.doc().unwrap().history.undo.is_empty(), "opening is not an undo step"); } +/// Preferences › File Handling › Use Low Resolution Proxy for Linked EPS (#394): an unchanged linked +/// `.eps` keeps its saved preview on open; Update Links still reads a modified file when set to +/// Automatically. +#[test] +fn low_res_proxy_eps_keeps_unchanged_preview_and_automatic_update_reads_modified_eps() { + let dir = Folder::new("eps-proxy"); + let pic = dir.file("photo.eps"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap(); + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "askWhenModified"})).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "unchanged EPS stays on its preview"); + assert!(s.doc().unwrap().doc.images[&image(&s, id).key].is_proxy()); + write(&pic, &png(400, 400, BLUE)); + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!(r["updatedLinks"][0]["ids"].clone(), json!([id.0]), "modified EPS updates on open: {r}"); + assert!(!s.doc().unwrap().doc.images[&image(&s, id).key].is_proxy()); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); +} + +/// With Low Resolution Proxy on, export reads the linked EPS file's full pixels, not the preview. +#[test] +fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() { + let dir = Folder::new("eps-proxy-export"); + let pic = dir.file("photo.eps"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}"); + let key = image(&s, id).key; + assert!(s.doc().unwrap().doc.images[&key].is_proxy()); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(!full.images[&key].is_proxy()); + let dim = + image::ImageReader::new(std::io::Cursor::new(full.images[&key].bytes.as_ref())).with_guessed_format().unwrap().into_dimensions().unwrap(); + assert_eq!(dim, (600, 300)); + let png_out = s.execute("document.export", &json!({"format": "png"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(png_out["dataBase64"].as_str().unwrap()).unwrap(); + let rendered = image::load_from_memory(&bytes).unwrap(); + assert!(rendered.width() >= 400 && rendered.height() >= 300); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "canvas still shows the preview"); +} + /// The half of opening that the desktop app runs on a thread of its own (`read_for_open`) reads /// the linked files and lays out the type. The half left for the UI thread (`open_read`) reads no /// file: with a linked file moved away in between, it opens the document that `document.open` opens. @@ -326,7 +380,7 @@ fn a_file_read_for_opening_has_its_links_read_and_its_type_laid_out() { let expected = open(&mut at_once, &doc_path); let rows = if update == "automatically" { &expected["updatedLinks"] } else { &expected["modifiedLinks"] }; assert!(expected["missingLinks"][0]["name"] == "gone.png" && rows[0]["name"] == "changed.png", "{update}: {expected}"); - let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, update == "automatically").unwrap(); + let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, update == "automatically", false).unwrap(); let doc = file.document(); let NodeKind::Image(im) = &doc.node(photo).unwrap().kind else { panic!("not an image") }; assert!(!doc.images[&im.key].is_proxy(), "{update}: the TIFF is read, not the preview saved for it"); diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 68b9987d0..8c7cb3861 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -1393,6 +1393,33 @@ fn highlight_alternate_glyphs_preference_is_off_by_default() { assert!(!s.prefs.highlight_alternate_glyphs); } +/// File Handling › Use Low Resolution Proxy for Linked EPS (#394): off by default; toggling it does +/// not replace full-resolution pixels already in the document. +#[test] +fn low_res_proxy_eps_preference_does_not_swap_full_resolution_pixels() { + use crate::tests_links::{Folder, RED, place, png, save, write}; + let dir = Folder::new("eps-proxy-pref"); + let pic = dir.file("photo.eps"); + write(&pic, &png(600, 300, RED)); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + assert!(!s.prefs.low_res_proxy_eps); + let id = place(&mut s, &pic); + let doc_path = dir.file("doc.vectorcraft"); + save(&mut s, &doc_path); + s.execute("links.update", &json!({})).unwrap(); + let key = { + let st = s.doc().unwrap(); + let NodeKind::Image(im) = &st.doc.node(id).unwrap().kind else { panic!("image") }; + im.key.clone() + }; + assert!(!s.doc().unwrap().doc.images[&key].is_proxy()); + set_pref(&mut s, "lowResProxyEps", json!(true)); + assert!(!s.doc().unwrap().doc.images[&key].is_proxy(), "full pixels stay in the document"); + set_pref(&mut s, "lowResProxyEps", json!(false)); + assert!(!s.prefs.low_res_proxy_eps); +} + /// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator /// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`. #[test] diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index bfe2b3ed0..348782853 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -49,13 +49,14 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result