Open: the opening thread also reads linked files and lays out type

When the desktop app opened a document on a thread of its own, that
thread parsed it, but open_read, which the app ran on the UI thread,
read the document's linked files, decoding BMP, PSD and RGB TIFF files
and storing them again as PNG, and laid out the type. In release builds,
a document with 12 linked 3000 x 2000 TIFFs kept the UI thread busy for
1.04 s, and one with 300 Auto Size area type objects, every second one
in three columns, kept it busy for 0.85 s.

read_for_open now reads the linked files and lays out the type on the
opening thread, with the path and the Update Links preference that
opening::start passes it, and open_read only adds the finished document.
open_read now takes 0.02 ms and 0.1 ms on those documents. For the
measured documents, the opened documents, results and undo states are
the same as on main.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-14 04:56:30 +07:00
1 parent 37174ac306
commit 85cea7efea
5 files changed
+162 -22

No files matched your search

+34 -13
View File
@@ -9,6 +9,7 @@ use vectorcraft_geom::Affine;
use super::super::*;
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
/// A file read into a document, with its format and non-fatal import notes.
@@ -292,47 +293,67 @@ 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> {
open_read(s, read_for_open(name, bytes, p)?, path)
let update_links = s.prefs.update_links == "automatically";
open_read(s, read_for_open(name, bytes, path, p, update_links)?)
}
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
pub struct ReadFile {
loaded: Loaded,
opts: LoadOptions,
/// Where the file was read from.
path: Option<String>,
/// What became of its linked files.
links: Resolved,
}
/// The first half of [`open_bytes_with`]: read and decode the file, which needs no session (an
/// app may do it on another thread) → what [`open_read`] opens.
pub fn read_for_open(name: &str, bytes: &[u8], p: &Value) -> Result<ReadFile> {
#[cfg(test)]
impl ReadFile {
/// The document as it was read, before it opens.
pub(crate) fn document(&self) -> &Document {
&self.loaded.doc
}
}
/// The first half of [`open_bytes_with`], which needs no session (an app may do it on another
/// thread): read and decode the file, read its linked files and lay out its type → what
/// [`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> {
let opts = LoadOptions::from_params("document.open", p)?;
Ok(ReadFile { loaded: load_with(name, bytes, &opts)?, opts })
let mut loaded = load_with(name, bytes, &opts)?;
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links);
Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links })
}
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
/// document (`path`: where it was read from) → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile, path: Option<String>) -> Result<Value> {
open_loaded(s, file.loaded, path, &file.opts, false)
/// document → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile) -> Result<Value> {
open_loaded(s, file.loaded, file.links, file.path, &file.opts, false)
}
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
/// from there) as a new untitled document.
pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Value> {
let loaded = load(name, bytes)?;
let mut loaded = load(name, bytes)?;
if loaded.preview_only {
return Err(err(
"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",
));
}
open_loaded(s, loaded, path.map(str::to_string), &LoadOptions::default(), true)
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically");
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
}
/// Make a loaded file the new active document; `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
/// Make a loaded file the new active document; `links` is what became of its linked files, and
/// `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs);
// What reading the file left out of the document, if anything.
let losses: Vec<String> = warnings.iter().filter(|w| is_loss(w)).cloned().collect();
let source_path = path.clone().filter(|_| !losses.is_empty());
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/.vctemplate file) opens as a new untitled
// document.
let template = as_template || doc.template || matches!(format.id, "ait" | "template");
+1 -1
View File
@@ -1223,7 +1223,7 @@ impl Session {
}
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
/// selection boxes and hit testing would use the rough estimate until each text is edited.
fn refresh_text_bounds(doc: &mut Document) {
pub(crate) fn refresh_text_bounds(doc: &mut Document) {
// Inline graphics in text take their size from their symbols' art (not saved either).
doc.resolve_inline_art();
let mut texts = vec![];
+50
View File
@@ -257,6 +257,56 @@ fn a_modified_link_follows_the_update_links_preference() {
assert!(s.doc().unwrap().history.undo.is_empty(), "opening is not an undo step");
}
/// 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.
#[test]
fn a_file_read_for_opening_has_its_links_read_and_its_type_laid_out() {
let dir = Folder::new("read-for-open");
let tif = dir.file("art/photo.tif");
let mut tiff = vec![];
image::RgbImage::from_pixel(600, 300, image::Rgb(RED)).write_to(&mut std::io::Cursor::new(&mut tiff), image::ImageFormat::Tiff).unwrap();
write(&tif, &tiff);
let (gone, changed) = (dir.file("gone.png"), dir.file("changed.png"));
write(&gone, &png(300, 100, BLUE));
write(&changed, &png(300, 100, BLUE));
let mut s = session();
let photo = place(&mut s, &tif);
place(&mut s, &gone);
place(&mut s, &changed);
let area = json!({"width": 150, "height": 20, "fit": "autoHeight"});
let r = s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Linked files and type. ".repeat(12), "area": area})).unwrap();
let text = NodeId(r["id"].as_u64().unwrap());
s.execute("text.areaOptions", &json!({"ids": [text.0], "columns": 2})).unwrap();
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
std::fs::remove_file(&gone).unwrap();
write(&changed, &png(200, 200, RED));
let bytes = std::fs::read(&doc_path).unwrap();
let away = dir.file("away.tif");
for update in ["askWhenModified", "automatically"] {
let mut at_once = session();
at_once.execute("prefs.set", &json!({"key": "updateLinks", "value": update})).unwrap();
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 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");
let NodeKind::Text(t) = &doc.node(text).unwrap().kind else { panic!("not type") };
assert!(t.cached_bounds.is_some(), "{update}: the type is laid out");
std::fs::rename(&tif, &away).unwrap();
let mut opened = session();
let r = cmd::fileio::open_read(&mut opened, file).unwrap();
std::fs::rename(&away, &tif).unwrap();
assert_eq!(r, expected, "{update}");
let (a, b) = (at_once.doc().unwrap(), opened.doc().unwrap());
assert!(*b.doc == *a.doc, "{update}: the same document");
assert!(b.history.undo.is_empty() && !b.is_dirty(), "{update}");
}
}
#[test]
fn links_from_older_files_load_and_keep_their_pixels() {
let dir = Folder::new("legacy");