Package: File › Package collects and writes the package on the background thread

File › Package… read every linked file and font and wrote the package
on the UI thread, so the window stopped responding until the package
was written. For a document that links 50 images of 21 MB each, OK
with Background Export off, which does the same work, takes a median
of 2.25 s in a release build on Windows.

With Enable Background Export on (Preferences › File Handling), OK now
takes the options, the report's Document Info text and a snapshot of
the document, and closes the dialog. The thread that writes background
saves and exports then collects the links and fonts and writes the
package. The same OK takes 0.001 s, and the package holds the same
bytes. A save of unsaved changes that Package starts is written first.
With the preference off, on the web and over MCP and scripts, the
package is made at once with the same reply as before.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-22 17:20:48 +07:00
committed by bflatastic
parent f553be25f7
commit 052bbb14b4
32 changed files with 364 additions and 13 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ mod object;
pub(crate) mod objects_on_path;
mod opacitymask;
mod overprint;
mod package;
pub mod package;
mod paint;
mod panelcmds;
pub mod params;
+63 -3
View File
@@ -306,6 +306,51 @@ impl<'a> Collector<'a> {
}
fn package(s: &mut Session, p: &Value) -> Result<Value> {
let package = prepare(s, p)?;
match &mut s.package_hand_off {
Some(slot) => {
*slot = Some(package);
Ok(Value::Null)
}
None => package.write(&mut |path, bytes| write_file(path, bytes)),
}
}
/// A package of the active document as `file.package` takes it from the session: the options, the
/// names, the report's Document Info text and the document as it is now
/// ([`Session::prepare_package`]). [`Package::write`] collects and writes it without the session,
/// on any thread.
pub struct Package {
o: Options,
doc_path: String,
stem: String,
name: String,
root: Option<PathBuf>,
info: Option<String>,
doc: std::sync::Arc<Document>,
}
impl Session {
/// Run `file.package` with `params` as a command (checked and journaled as any other) that
/// collects and writes nothing → the package it prepared, which [`Package::write`] collects
/// and writes (on the desktop with Background Export on, File › Package… does this on the
/// background thread).
pub fn prepare_package(&mut self, params: &Value) -> Result<Package> {
self.package_hand_off = Some(None);
let r = self.execute(C, params);
let handed = self.package_hand_off.take().flatten();
match (r, handed) {
(Err(e), _) => Err(e),
(Ok(_), Some(package)) => Ok(package),
// Every run that succeeds hands one over; otherwise the package is prepared here.
(Ok(_), None) => prepare(self, params),
}
}
}
/// What `file.package` reads from the session: the options, the checks of the document and the
/// name, the report's Document Info text and the document.
fn prepare(s: &mut Session, p: &Value) -> Result<Package> {
let o = Options {
copy_links: bool_or(p, "copyLinks", true),
links_folder: bool_or(p, "linksFolder", true),
@@ -322,8 +367,23 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
let folder = str_param(p, "folder");
let root = folder.map(|f| Path::new(f).join(&name));
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
let st = s.doc()?;
let mut doc = (*st.doc).clone();
let doc = s.doc()?.doc.clone();
Ok(Package { o, doc_path, stem, name, root, info, doc })
}
impl Package {
/// Collect the linked files and fonts and write the package's files with `write` into
/// `folder/name`, making its folders first, or into the zip without a folder → the reply of
/// `file.package`.
pub fn write(self, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
collect_and_write(self, write)
}
}
/// [`Package::write`].
fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let Package { o, doc_path, stem, name, root, info, doc } = package;
let mut doc = (*doc).clone();
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
// Prepare original links for the destination *before* rebasing copied links.
// Recalculating afterward would overwrite the portable relative paths.
@@ -366,7 +426,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(dir) = path.parent() {
create_dir(&dir.to_string_lossy())?;
}
write_file(&path.to_string_lossy(), bytes)?;
write(&path.to_string_lossy(), bytes)?;
}
out["folder"] = json!(root.to_string_lossy());
}
+4
View File
@@ -1378,6 +1378,9 @@ pub struct Session {
/// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an
/// error brings back; `None` otherwise.
pub(crate) batch_stash: Option<Vec<DocState>>,
/// While [`Session::prepare_package`] runs `file.package`: the package the command prepared,
/// which it leaves here instead of writing it; `None` otherwise.
pub(crate) package_hand_off: Option<Option<cmd::package::Package>>,
/// Where Data Recovery keeps its copies ([`cmd::recovery`]).
pub recovery: cmd::recovery::Recovery,
/// While the active tool's actions run: their commands aren't "a command from outside the
@@ -1466,6 +1469,7 @@ impl Session {
journal_note: Default::default(),
note_depth: 1,
batch_stash: None,
package_hand_off: None,
recovery: Default::default(),
in_tool_actions: false,
previewing: false,
+74
View File
@@ -124,6 +124,80 @@ fn without_a_folder_the_zip_has_the_same_files() {
assert_eq!(f["files"], r["files"]);
}
/// The reply of a prepared package's [`cmd::package::Package::write`] and the files it gave the
/// writer, by path.
fn write_prepared(package: cmd::package::Package) -> (Value, BTreeMap<String, Vec<u8>>) {
let mut files = BTreeMap::new();
let mut write = |path: &str, bytes: &[u8]| {
files.insert(path.to_string(), bytes.to_vec());
Ok(())
};
let r = package.write(&mut write).unwrap_or_else(|e| panic!("{e}"));
(r, files)
}
#[test]
fn a_prepared_package_writes_what_file_package_writes() {
let dir = Folder::new("package-prepared");
let mut s = poster(&dir);
run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "No Such Font Family"}));
save(&mut s, &dir.file("poster.vectorcraft"));
std::fs::remove_file(dir.file("art/blue.png")).unwrap();
open(&mut s, &dir.file("poster.vectorcraft"));
let p = json!({"folder": dir.file("out")});
let want = run(&mut s, "file.package", p.clone());
assert_eq!((want["missingLinks"].clone(), want["skippedFonts"][0]["font"].clone()), (json!(["blue.png"]), json!("No Such Font Family Regular")));
let root = std::path::Path::new(&dir.file("out")).join("poster Folder");
let on_disk: BTreeMap<String, Vec<u8>> = want["files"]
.as_array()
.unwrap()
.iter()
.map(|f| root.join(f.as_str().unwrap()))
.map(|f| (f.to_string_lossy().into_owned(), std::fs::read(&f).unwrap()))
.collect();
assert_eq!(on_disk.len(), 4, "{want}");
std::fs::remove_dir_all(&root).unwrap();
// A prepared package is journaled as the command, and nothing is written until `Package::write` runs.
let journaled = s.journal.len();
let package = s.prepare_package(&p).unwrap();
assert_eq!((s.journal.len(), s.journal.last().map(|(id, _)| id.as_str())), (journaled + 1, Some("file.package")));
assert!(!root.exists());
let (got, files) = write_prepared(package);
assert_eq!(got, want);
assert_eq!(files.keys().collect::<Vec<_>>(), on_disk.keys().collect::<Vec<_>>());
for (path, bytes) in &on_disk {
assert!(files[path] == *bytes, "{path} differs");
}
// Without a folder, the package is the same zip and no file is written.
let want = run(&mut s, "file.package", json!({}));
let (got, files) = write_prepared(s.prepare_package(&json!({})).unwrap());
assert!(files.is_empty(), "{:?}", files.keys());
let unzipped = |r: &Value| unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
assert_eq!(unzipped(&got), unzipped(&want));
for key in ["files", "links", "fonts", "missingLinks", "skippedFonts", "warnings", "name", "bytes"] {
assert_eq!(got[key], want[key], "{key}");
}
// The checks come first, as the command's do.
let e = s.prepare_package(&json!({"name": "../up"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("can't name a folder"), "{e}");
}
#[test]
fn a_prepared_package_holds_the_document_as_it_was() {
let dir = Folder::new("package-snapshot");
let mut s = poster(&dir);
let nodes = s.doc().unwrap().doc.node_count();
let package = s.prepare_package(&json!({})).unwrap();
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.clear", json!({}));
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
let (r, _) = write_prepared(package);
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
let doc = vectorcraft_format::load(&zip["poster Folder/poster.vectorcraft"]).unwrap();
assert_eq!(doc.node_count(), nodes);
assert_eq!(r["links"], 2, "{r}");
}
#[test]
fn options_leave_out_links_fonts_and_the_report() {
let dir = Folder::new("package-options");