Merge pull request #2975 from nrq6993-a11y/stream/vc-feat3-g12-low-res-proxy-eps

G12: honor Use Low Resolution Proxy for Linked EPS (#394)
This commit is contained in:
BFlat
2026-10-23 00:37:02 +07:00
committed by GitHub
8 changed files with 166 additions and 22 deletions
+4 -4
View File
@@ -370,7 +370,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
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<String>, p: &Value, update_links: bool) -> Result<ReadFile> {
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
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)
}
+59 -4
View File
@@ -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<LinkedFi
.collect()
}
/// `d` for output: linked raster images that still show only their saved preview read their files
/// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only).
pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec<String>) {
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;
+16 -9
View File
@@ -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<String>) {
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<PlacedDocument> = 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<String>) {
}
}
}
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)",
+2 -2
View File
@@ -242,7 +242,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
));
}
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false);
super::links::resolve(&mut l.doc, src.path, false, false);
(l.doc, l.warnings)
};
doc.drop_edit_modes();
@@ -297,7 +297,7 @@ pub(crate) fn pdf_art(
fileio::page_or_editing_data(src, bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
if alone {
// Linked images show their files, found from its folder.
super::links::resolve(&mut doc, path, false);
super::links::resolve(&mut doc, path, false, false);
}
doc.drop_edit_modes();
let layers = std::mem::take(&mut doc.layers);
+1 -1
View File
@@ -999,7 +999,7 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, 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;
+55 -1
View File
@@ -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");
+27
View File
@@ -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]
+2 -1
View File
@@ -49,13 +49,14 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec<u8>) -> Result<Val
let (name, file) = (path.to_string(), bytes.clone());
// Update Links (Preferences › File Handling) applies there as it is set now.
let update_links = app.session.prefs.update_links == "automatically";
let low_res_proxy_eps = app.session.prefs.low_res_proxy_eps;
// The automation roots in force here are in force there.
let scope = ScopeHandle::capture();
let spawned = std::thread::Builder::new().name("vectorcraft-open".into()).spawn(move || {
let r = scope
.run(|| {
vectorcraft_engine::guard::catch_panic(|| {
fileio::read_for_open(&name, &file, Some(name.clone()), &Value::Null, update_links).map_err(|e| e.to_string())
fileio::read_for_open(&name, &file, Some(name.clone()), &Value::Null, update_links, low_res_proxy_eps).map_err(|e| e.to_string())
})
})
.unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)")));