Merge pull request #3686 from dotCooCoo/feat/export-for-screens-off-ui-thread

Export for Screens: files are encoded and written on the background thread
This commit is contained in:
BFlat
2026-10-23 09:23:49 +07:00
committed by GitHub
40 changed files with 636 additions and 35 deletions
+30
View File
@@ -92,6 +92,15 @@ pub fn check() -> Result<(), Cancelled> {
if is_cancelled() { Err(Cancelled) } else { Ok(()) }
}
/// Run `f` with this thread's token still in effect and its progress reports dropped: for work
/// that reports its own steps around calls that report theirs (each file of a batch export).
pub fn quiet<T>(f: impl FnOnce() -> T) -> T {
match CURRENT.with(|c| c.borrow().as_ref().map(|s| s.token.clone())) {
Some(token) => scope(&token, None, f),
None => f(),
}
}
/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`]
/// that has a sink.
pub fn progress(done: u64, total: u64, what: &str) {
@@ -139,4 +148,25 @@ mod tests {
progress(2, 3, "after");
assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]);
}
#[test]
fn quiet_work_reports_nothing_and_can_still_be_cancelled() {
let seen = Rc::new(RefCell::new(vec![]));
let sink: ProgressSink = {
let seen = seen.clone();
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
};
let t = CancelToken::new();
scope(&t, Some(sink), || {
progress(0, 2, "file");
quiet(|| {
progress(0, 1, "artboard");
t.cancel();
assert_eq!(check(), Err(Cancelled));
});
progress(1, 2, "file");
});
assert_eq!(*seen.borrow(), vec![(0, 2, "file".to_string()), (1, 2, "file".to_string())]);
assert_eq!(quiet(check), Ok(()), "outside a scope nothing is cancelled");
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// `e` as an error of command `cmd` (for errors of the export underneath).
fn of_cmd(cmd: &str, e: EngineError) -> EngineError {
pub(crate) fn of_cmd(cmd: &str, e: EngineError) -> EngineError {
match e {
EngineError::BadParams { msg, .. } => bad(cmd, msg),
e => e,
+1 -1
View File
@@ -78,7 +78,7 @@ pub use save::{
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
};
pub use screens::{
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize,
builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_rows as screen_preset_rows,
};
pub(crate) use screens::{
+15
View File
@@ -8,6 +8,9 @@
//! renderer stops drawing and keeps no half-drawn pattern tile or effect raster. Encoders that
//! make one file in one go report nothing: the frontend shows them as busy and checks
//! [`Progress::cancelled`] itself before writing, so a cancelled export never writes a file.
//! Export for Screens ([`super::ScreenExport::write`]) reports one step per file instead, keeps
//! its encoders' reports out, and stops before the next file, so the files it wrote before the
//! cancel stay.
use std::rc::Rc;
use std::sync::Arc;
@@ -21,6 +24,8 @@ pub struct Progress {
token: CancelToken,
done: AtomicU64,
total: AtomicU64,
/// The steps are files (Export for Screens), not artboards.
files: bool,
}
impl Progress {
@@ -28,6 +33,16 @@ impl Progress {
Arc::new(Self::default())
}
/// A [`Progress`] whose steps are files: the export of [`super::ScreenExport::write`].
pub fn of_files() -> Arc<Self> {
Arc::new(Self { files: true, ..Self::default() })
}
/// Are the steps files rather than artboards?
pub fn counts_files(&self) -> bool {
self.files
}
/// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go).
pub fn counts(&self) -> (u64, u64) {
let total = self.total.load(Ordering::Relaxed);
+96 -17
View File
@@ -26,6 +26,8 @@ use super::{
};
const C: &str = "document.exportForScreens";
/// The Asset Export panel's export, which exports through [`export`].
const ASSETS: &str = "assets.export";
/// How big a raster row's files are.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -364,9 +366,9 @@ impl ScreenParams {
}
/// How a batch export names its files and sorts them into folders.
struct Naming<'a> {
struct Naming {
/// The `name` pattern, when it holds more than spaces.
pattern: Option<&'a str>,
pattern: Option<String>,
/// The `prefix`, through [`file_part`].
prefix: String,
/// The document's name, safe as a file name (`<filename>`, the Full Document's item).
@@ -379,9 +381,9 @@ struct Naming<'a> {
folders: Folders,
}
impl<'a> Naming<'a> {
impl Naming {
/// The naming of params `o` for a document titled `title`.
fn of(o: &'a ScreenParams, title: &str) -> Result<Self> {
fn of(o: &ScreenParams, title: &str) -> Result<Self> {
let folders = Folders::of(o)?;
// A prefix names files, not folders.
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
@@ -395,7 +397,7 @@ impl<'a> Naming<'a> {
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
};
let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into());
let pattern = o.name.as_deref().filter(|n| !n.trim().is_empty());
let pattern = o.name.clone().filter(|n| !n.trim().is_empty());
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders })
}
@@ -405,7 +407,7 @@ impl<'a> Naming<'a> {
let counter = self.counter_start.saturating_add(if self.per_file { files } else { index as u64 });
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
let prefix = &self.prefix;
let stem = match self.pattern {
let stem = match self.pattern.as_deref() {
Some(pattern) => {
let parts = NameParts {
filename: &self.filename,
@@ -507,8 +509,15 @@ impl Output {
None
}
/// Write `bytes` as `name` (relative) under `folder`, or keep them.
fn put(&mut self, folder: Option<&str>, keep: bool, name: String, bytes: Vec<u8>) -> Result<()> {
/// Write `bytes` as `name` (relative) under `folder` with `writer`, or keep them.
fn put(
&mut self,
folder: Option<&str>,
keep: bool,
name: String,
bytes: Vec<u8>,
writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>,
) -> Result<()> {
match folder {
Some(dir) if !keep => {
if let Some((sub, _)) = name.rsplit_once('/')
@@ -517,7 +526,9 @@ impl Output {
create_dir(&format!("{dir}/{sub}"))?;
}
let path = format!("{dir}/{name}");
write_file(&path, &bytes)?;
// A cancel that came while the file was encoded writes nothing more.
vectorcraft_doc::cancel::check()?;
writer(&path, &bytes)?;
self.written.push(path);
}
_ => self.kept.push((name, bytes)),
@@ -659,12 +670,8 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let rows = rows_of(s, p, o.preset.as_deref())?;
o.one_source()?;
let naming = Naming::of(&o, &s.doc()?.doc.title)?;
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
let selection = o.selection_mode()?;
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
if let Some(dir) = folder {
create_dir(dir)?;
}
let doc = s.doc()?.doc.clone();
// Include Bleed: each artboard grown by the document's bleed.
let doc = if o.include_bleed && doc.setup.has_bleed() {
@@ -678,12 +685,77 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
};
let crop = rows.iter().any(|r| r.format.id != "pdf");
let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?;
// Appearance of Black › Printing / Exporting for RGB raster rows.
let rows = rows.into_iter().map(|r| Row { options: with_black_output(s, &r.options), ..r }).collect();
let export = ScreenExport { cmd: C, o, rows, naming, items };
match &mut s.screens_hand_off {
Some(slot) => {
*slot = Some(export);
Ok(Value::Null)
}
None => export.write(&mut |path, bytes| write_file(path, bytes)),
}
}
/// An export as `document.exportForScreens` and `assets.export` take it from the session: the
/// params, the format rows, the naming, and each item with the document it writes
/// ([`Session::prepare_screen_export`]). [`ScreenExport::write`] encodes and writes its files
/// without the session, on any thread.
pub struct ScreenExport {
/// The command whose errors it gives: `document.exportForScreens` or `assets.export`.
cmd: &'static str,
o: ScreenParams,
rows: Vec<Row>,
naming: Naming,
items: Vec<Item>,
}
impl Session {
/// Run `assets.export` when `id` names it, else `document.exportForScreens`, with `params` as
/// a command (checked and journaled as any other; Export for Screens remembers its settings)
/// that encodes and writes nothing → the export it prepared, which [`ScreenExport::write`]
/// encodes and writes (on the desktop with Background Export on, Export for Screens and the
/// Asset Export panel do this on the background thread).
pub fn prepare_screen_export(&mut self, id: &str, params: &Value) -> Result<ScreenExport> {
let cmd = if id == ASSETS { ASSETS } else { C };
self.screens_hand_off = Some(None);
let r = self.execute(cmd, params);
let handed = self.screens_hand_off.take().flatten();
match (r, handed) {
(Err(e), _) => Err(e),
(Ok(_), Some(export)) => Ok(ScreenExport { cmd, ..export }),
// Every run that succeeds hands one over.
(Ok(_), None) => Err(bad(cmd, "nothing to export")),
}
}
}
impl ScreenExport {
/// Make the export folder, encode the files and write each with `writer` into the folder and
/// its sub-folders, or keep them to zip (the zip written with `writer` into the folder) or to
/// return → the reply of `document.exportForScreens`. It reports one progress step per file
/// ([`vectorcraft_doc::cancel::progress`]), and a cancel stops it before it encodes or writes
/// another file.
pub fn write(self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let cmd = self.cmd;
// A refusal names `assets.export` when that command prepared the export, as its reply does.
encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) })
}
}
/// [`ScreenExport::write`].
fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let ScreenExport { o, rows, naming, items, .. } = export;
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
if let Some(dir) = folder {
create_dir(dir)?;
}
let mut out = Output::default();
// The encoders' warnings for every file, each once.
let mut warnings: Vec<String> = Vec::new();
// Appearance of Black › Printing / Exporting for RGB raster rows.
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
let (bytes, notes) = encode_with_warnings(doc, format, &with_black_output(s, options))?;
// The steps are the files: an encoder's own steps (artboards, pages) are not reported.
let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?;
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
@@ -694,8 +766,14 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
// A whole-document copy of an artboard, for the formats that write documents to edit.
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
let mut files = 0u64;
// One step per item and row, the files a name collision skips included.
let steps = (items.len() * rows.len()) as u64;
let mut step = 0u64;
for (index, item) in items.iter().enumerate() {
for r in &rows {
vectorcraft_doc::cancel::check()?;
vectorcraft_doc::cancel::progress(step, steps, &format!("Exporting file {} of {steps}", step + 1));
step += 1;
let (sub, file) = naming.file(item, index, files, r);
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
@@ -711,7 +789,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
} else {
write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)?
};
out.put(folder, o.zip, name, bytes)?;
out.put(folder, o.zip, name, bytes, writer)?;
files += 1;
}
}
@@ -722,7 +800,8 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
return match folder {
Some(dir) => {
let path = format!("{dir}/{zip_name}");
write_file(&path, &zip)?;
vectorcraft_doc::cancel::check()?;
writer(&path, &zip)?;
Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings }))
}
None => Ok(
@@ -1,6 +1,8 @@
//! Export for Screens: sizes (factor, width, height, resolution), presets and sub-folders, Full
//! Document, Include Bleed, per-format settings, ZIP downloads and the settings the document keeps.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use super::screens::ScreenSize;
@@ -257,6 +259,169 @@ fn a_zip_is_written_into_the_folder() {
let _ = std::fs::remove_dir_all(dir);
}
/// The reply of a prepared export's [`super::ScreenExport::write`] and the files it gave the
/// writer, by path.
fn write_prepared(export: super::ScreenExport) -> (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 = export.write(&mut write).unwrap_or_else(|e| panic!("{e}"));
(r, files)
}
/// The files reply `r` names (its `files`, else the zip at its `path`) as they are on disk, by path.
fn on_disk(r: &Value) -> BTreeMap<String, Vec<u8>> {
let paths: Vec<&str> = match r["path"].as_str() {
Some(zip) => vec![zip],
None => r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(),
};
paths.into_iter().map(|p| (p.to_string(), std::fs::read(p).unwrap())).collect()
}
/// [`Session::prepare_screen_export`] runs `document.exportForScreens` and `assets.export` as
/// commands, journaled, and writes nothing; the export it gives writes the files the command
/// writes, with the same names (`-2` included), sub-folders, bytes and reply, into a folder, as a
/// zip in it and without a folder, through the writer it is given. Export for Screens remembers
/// its settings when it is prepared.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_prepared_export_writes_what_the_command_writes() {
let mut s = session(2);
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "name": "icon"})).unwrap();
let dir = std::env::temp_dir().join(format!("vc-screens-prepared-{}", std::process::id()));
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
let rows = json!([{"format": "png", "suffix": ""}, {"format": "png", "scale": "2x"}, {"format": "svg"}]);
for zip in [false, true] {
let p = json!({"folder": folder, "zip": zip, "folders": "rows", "name": "<name>", "formats": rows});
let want = export(&mut s, p.clone());
if !zip {
let names = ["1x/Icon.png", "2x/Icon@2x.png", "SVG/Icon.svg", "1x/icon-2.png", "2x/icon@2x-2.png", "SVG/icon-2.svg"];
assert_eq!(want["files"], json!(names.map(|n| format!("{folder}/{n}"))));
}
let disk = on_disk(&want);
std::fs::remove_dir_all(&dir).unwrap();
let journaled = s.journal.len();
let prepared = s.prepare_screen_export("document.exportForScreens", &p).unwrap();
assert_eq!((s.journal.len(), s.journal.last().map(|(id, _)| id.as_str())), (journaled + 1, Some("document.exportForScreens")));
assert!(!dir.exists(), "nothing is written until the export writes");
let (got, files) = write_prepared(prepared);
assert_eq!(got, want, "zip: {zip}");
assert_eq!(files, disk, "zip: {zip}");
let _ = std::fs::remove_dir_all(&dir);
}
// Without a folder, the files come back in the reply and nothing is written.
let p = json!({"name": "<name>", "formats": rows});
let want = export(&mut s, p.clone());
let (got, files) = write_prepared(s.prepare_screen_export("document.exportForScreens", &p).unwrap());
assert_eq!((got, files.len()), (want, 0));
// The settings are remembered when the export is prepared.
s.prepare_screen_export("document.exportForScreens", &json!({"prefix": "pre-", "formats": rows})).unwrap();
assert_eq!(s.doc().unwrap().doc.export_settings.get("prefix"), Some(&json!("pre-")));
// The Asset Export panel's export.
s.execute("select.all", &json!({})).unwrap();
s.execute("assets.add", &json!({"multiple": false})).unwrap();
let p = json!({"folder": folder, "formats": rows});
let want = s.execute("assets.export", &p).unwrap();
let disk = on_disk(&want);
assert_eq!(disk.len(), 3, "{want}");
std::fs::remove_dir_all(&dir).unwrap();
let prepared = s.prepare_screen_export("assets.export", &p).unwrap();
assert_eq!(s.journal.last().map(|(id, _)| id.as_str()), Some("assets.export"));
assert_eq!(write_prepared(prepared), (want, disk));
// An encoder's refusal names the command, as the command's own reply does.
let p = json!({"folder": folder, "formats": [{"format": "bmp", "colorModel": "cmyk"}]});
let want = s.execute("assets.export", &p).unwrap_err();
let got = s.prepare_screen_export("assets.export", &p).unwrap().write(&mut |_: &str, _: &[u8]| Ok(())).unwrap_err();
assert!(matches!(&got, crate::EngineError::BadParams { cmd, .. } if cmd == "assets.export"), "{got:?}");
assert_eq!(got.to_string(), want.to_string());
// The checks come first, as the command's do.
let journaled = s.journal.len();
assert!(s.prepare_screen_export("document.exportForScreens", &json!({"range": "9"})).is_err());
assert_eq!(s.journal.len(), journaled);
assert!(s.screens_hand_off.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_prepared_export_holds_the_document_as_it_was() {
let mut s = session(1);
let p = json!({"formats": [{"format": "svg"}]});
let want = export(&mut s, p.clone());
let prepared = s.prepare_screen_export("document.exportForScreens", &p).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
assert_ne!(export(&mut s, p), want, "the edit changes the file");
assert_eq!(write_prepared(prepared).0, want);
}
/// Output All Blacks as Rich Black (Appearance of Black › Printing / Exporting) makes the RGB files
/// draw a CMYK black as RGB black, prepared or not.
#[test]
fn rich_black_reaches_the_files() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 30, "colorMode": "cmyk"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
let p = json!({"formats": [{"format": "png"}]});
let px = |r: &Value| image::load_from_memory(&files(r)[0].1).unwrap().to_rgba8().get_pixel(10, 10).0;
let accurate = px(&export(&mut s, p.clone()));
assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}");
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "rich"})).unwrap();
assert_eq!(px(&export(&mut s, p.clone())), [0, 0, 0, 255]);
let (prepared, _) = write_prepared(s.prepare_screen_export("document.exportForScreens", &p).unwrap());
assert_eq!(px(&prepared), [0, 0, 0, 255]);
}
/// A cancel stops a prepared export before its next file; the file written before it stays.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_canceled_export_stops_before_its_next_file() {
let mut s = session(3);
let dir = std::env::temp_dir().join(format!("vc-screens-cancel-{}", std::process::id()));
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
let prepared = s.prepare_screen_export("document.exportForScreens", &json!({"folder": folder, "formats": [{"format": "svg"}]})).unwrap();
let progress = super::progress::Progress::new();
let mut written = vec![];
let r = super::progress::scope(&progress, || {
prepared.write(&mut |path: &str, _: &[u8]| {
written.push(path.to_string());
progress.cancel();
Ok(())
})
});
assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{r:?}");
assert_eq!(written, [format!("{folder}/Artboard-1.svg")]);
let _ = std::fs::remove_dir_all(dir);
}
/// A prepared export reports one progress step per file, and the raster encoders' artboard steps
/// are not reported.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_prepared_export_counts_its_files() {
let mut s = session(3);
let dir = std::env::temp_dir().join(format!("vc-screens-steps-{}", std::process::id()));
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
let rows = json!([{"format": "png"}, {"format": "png", "scale": "2x"}]);
let prepared = s.prepare_screen_export("document.exportForScreens", &json!({"folder": folder, "formats": rows})).unwrap();
let progress = super::progress::Progress::of_files();
let mut seen = vec![];
let r = super::progress::scope(&progress, || {
prepared.write(&mut |_: &str, _: &[u8]| {
seen.push(progress.counts());
Ok(())
})
});
assert!(r.is_ok(), "{r:?}");
assert_eq!(seen, (0..6).map(|n| (n, 6)).collect::<Vec<_>>());
assert!(progress.counts_files());
let _ = std::fs::remove_dir_all(dir);
}
/// The warnings in `r` that contain `text`.
fn warned<'a>(r: &'a Value, text: &str) -> Vec<&'a str> {
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains(text)).collect()
+5
View File
@@ -1405,6 +1405,10 @@ pub struct Session {
/// 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>>,
/// While [`Session::prepare_screen_export`] runs `document.exportForScreens` or
/// `assets.export`: the export the command prepared, which it leaves here instead of writing
/// it; `None` otherwise.
pub(crate) screens_hand_off: Option<Option<cmd::fileio::ScreenExport>>,
/// 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
@@ -1508,6 +1512,7 @@ impl Session {
queue_hand_off: None,
place_cursor_requests: false,
package_hand_off: None,
screens_hand_off: None,
recovery: Default::default(),
in_tool_actions: false,
previewing: false,
@@ -11,6 +11,8 @@
//! `YYYYMMDD`…, see `document.exportForScreens`) names every file alike, with a live example, and
//! the files go into one folder, or a folder per row, format, item, or item and format
//! (`folders`). Export presets (built-in and saved) set the formats, naming and folders at once.
//! On the desktop with Background Export on, the files are encoded and written on the background
//! thread ([`crate::background`]) after the dialog closes.
//!
//! Fields (`ui.dialog.set`): `tab` (artboards|assets|layers|selection), `select` (all|range|full), `range`,
//! `boards` ([bool] per artboard), `assets` ([asset id] checked on the Assets tab), `includeBleed`,
+200 -1
View File
@@ -1,9 +1,10 @@
//! Export for Screens, drawn headlessly: the fields it opens on (defaults, then the document's last
//! settings, and the artboards the Artboard tool has selected), the artboard choice, Format
//! Settings, desktop folders and web downloads.
//! Settings, desktop folders, the export on the background thread and web downloads.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_engine::Session;
@@ -13,6 +14,8 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::tests_export::{frame, kind, set};
use super::*;
use crate::Services;
use crate::background;
use crate::tests_background::{Files, Gate, written};
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
@@ -214,6 +217,202 @@ fn exports_into_the_folder_and_shows_it() {
let _ = std::fs::remove_dir_all(dir);
}
/// [`app`] on the desktop with Background Export's thread-safe writer, which records the files it
/// is given once `gate` opens.
fn app_with_worker(artboards: usize, gate: &Gate) -> (VectorcraftApp, Log, Files) {
let (mut app, log) = app(artboards, false);
let (files, g) = (Files::default(), gate.clone());
let recorded = files.clone();
app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| {
g.wait();
recorded.lock().unwrap().push((p.to_string(), b.to_vec()));
Ok(())
}));
(app, log, files)
}
fn background_export(app: &mut VectorcraftApp, on: bool) {
app.session.execute("prefs.set", &json!({"values": {"backgroundExport": on}})).unwrap();
}
/// The files reply `r` names (its `files`, else the ZIP at its `path`) as they are on disk.
fn on_disk(r: &Value) -> Vec<(String, Vec<u8>)> {
let paths: Vec<&str> = match r["path"].as_str() {
Some(zip) => vec![zip],
None => r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(),
};
paths.into_iter().map(|p| (p.to_string(), std::fs::read(p).unwrap())).collect()
}
/// With Background Export on, OK closes the dialog with nothing written, and the background thread
/// writes the files OK writes at once with it off: the same names (`-2` included), sub-folders and
/// bytes, and the same status; Open Location shows the first file once it is written. The same
/// holds for one ZIP in the folder.
#[test]
fn the_files_are_written_in_the_background_as_at_once() {
let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "png", "scale": "2x", "suffix": "@2x"}, {"format": "svg", "scale": "1x", "suffix": ""}]);
let dir = temp_folder("background");
let folder = dir.to_string_lossy().replace('\\', "/");
for zip in [false, true] {
let gate = Gate::default();
let (mut app, log, files) = app_with_worker(2, &gate);
app.run("artboard.setProps", json!({"index": 0, "name": "Icon"})).unwrap();
app.run("artboard.setProps", json!({"index": 1, "name": "icon"})).unwrap();
let export = |app: &mut VectorcraftApp| {
if zip {
let p = json!({"folder": folder, "zip": true, "folders": "rows", "name": "<name>", "formats": rows, "openLocation": true});
return app.run("file.exportForScreens", p);
}
open(app);
set(app, "folder", json!(folder));
set(app, "folders", json!("rows"));
set(app, "name", json!("<name>"));
set(app, "formats", rows.clone());
confirm(app)
};
background_export(&mut app, false);
let r = export(&mut app).unwrap();
let want = on_disk(&r);
if !zip {
assert_eq!(want.len(), 6, "{r}");
assert_eq!(want[3].0, format!("{folder}/1x/icon-2.png"));
}
let (status, shown) = (app.ui.status.clone(), log.borrow().clone());
assert_eq!(shown.len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
log.borrow_mut().clear();
background_export(&mut app, true);
let r = export(&mut app).unwrap();
assert_eq!(r["background"], true, "{r}");
assert!(app.ui.dialog.is_none(), "closed");
assert_eq!(app.ui.status, format!("Exporting {}…", dir.file_name().unwrap().to_string_lossy()));
assert!(written(&files).is_empty() && log.borrow().is_empty(), "nothing written yet");
gate.open();
background::wait_all(&mut app);
assert_eq!(*files.lock().unwrap(), want, "zip: {zip}");
assert_eq!((app.ui.status.clone(), log.borrow().clone()), (status, shown));
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn the_asset_export_panel_writes_in_the_background_as_at_once() {
let gate = Gate::default();
let (mut app, _, files) = app_with_worker(1, &gate);
app.run("select.all", json!({})).unwrap();
app.run("assets.add", json!({"multiple": false})).unwrap();
let dir = temp_folder("assets-background");
let folder = dir.to_string_lossy().replace('\\', "/");
let p = json!({"folder": folder, "formats": [{"format": "png", "scale": "2x", "suffix": "@2x"}, {"format": "svg"}]});
background_export(&mut app, false);
let want = on_disk(&crate::io::export_files(&mut app, "assets.export", p.clone()).unwrap());
assert_eq!(want.len(), 2);
let status = app.ui.status.clone();
std::fs::remove_dir_all(&dir).unwrap();
background_export(&mut app, true);
assert_eq!(crate::io::export_files(&mut app, "assets.export", p).unwrap()["background"], true);
assert!(written(&files).is_empty(), "nothing written yet");
gate.open();
background::wait_all(&mut app);
assert_eq!((files.lock().unwrap().clone(), app.ui.status.clone()), (want, status));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn edits_after_ok_do_not_reach_the_files() {
let gate = Gate::default();
let (mut app, _, files) = app_with_worker(1, &gate);
let dir = temp_folder("snapshot");
let p = json!({"folder": dir.to_string_lossy().replace('\\', "/"), "formats": [{"format": "svg"}]});
background_export(&mut app, false);
let want = on_disk(&app.run("file.exportForScreens", p.clone()).unwrap());
std::fs::remove_dir_all(&dir).unwrap();
background_export(&mut app, true);
assert_eq!(app.run("file.exportForScreens", p).unwrap()["background"], true);
app.run("select.all", json!({})).unwrap();
app.run("edit.clear", json!({})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
gate.open();
background::wait_all(&mut app);
assert_eq!(*files.lock().unwrap(), want, "the document as it was at OK");
let _ = std::fs::remove_dir_all(&dir);
}
/// Cancel stops the export before its next file; the file being written when it came stays.
#[test]
fn cancel_stops_the_export_before_its_next_file() {
let (gate, writing) = (Gate::default(), Gate::default());
let (mut app, _) = app(3, false);
let files = Files::default();
let (f, g, w) = (files.clone(), gate.clone(), writing.clone());
app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| {
w.open();
g.wait();
f.lock().unwrap().push((p.to_string(), b.to_vec()));
Ok(())
}));
let dir = temp_folder("cancel");
let folder = dir.to_string_lossy().replace('\\', "/");
let r = app.run("file.exportForScreens", json!({"folder": folder, "formats": [{"format": "svg"}]})).unwrap();
assert_eq!(r["background"], true, "{r}");
writing.wait();
assert_eq!(app.run("file.cancelExport", json!({})).unwrap()["cancelled"], 1);
gate.open();
background::wait_all(&mut app);
assert_eq!(written(&files), [format!("{folder}/Artboard-1.svg")]);
assert_eq!(app.ui.status, crate::io::EXPORT_CANCELLED);
let _ = std::fs::remove_dir_all(&dir);
}
/// With Background Export turned off, an export into the same folder is written after the one in
/// the background.
#[test]
fn an_export_made_at_once_waits_for_one_in_the_background() {
let gate = Gate::default();
let (mut app, _, files) = app_with_worker(1, &gate);
let dir = temp_folder("twice");
let folder = dir.to_string_lossy().replace('\\', "/");
let p = json!({"folder": folder, "formats": [{"format": "svg"}]});
assert_eq!(app.run("file.exportForScreens", p.clone()).unwrap()["background"], true);
background_export(&mut app, false);
let opener = gate.clone();
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
opener.open();
});
let r = app.run("file.exportForScreens", p).unwrap();
assert!(app.background.jobs.is_empty(), "the export in the background was written first");
release.join().unwrap();
let file = format!("{folder}/Artboard-1.svg");
assert_eq!((written(&files), r["files"].clone()), (vec![file.clone()], json!([file])));
assert_eq!(app.ui.status, format!("Exported 1 file(s) to {folder}"));
let _ = std::fs::remove_dir_all(&dir);
}
/// Without a folder (a script or an agent on the desktop) the files come back in the reply at once,
/// with Background Export on too.
#[test]
fn an_export_without_a_folder_replies_with_its_files() {
let (mut app, _, files) = app_with_worker(1, &Gate::default());
let r = app.run("file.exportForScreens", json!({"formats": [{"format": "svg"}]})).unwrap();
assert!(r.get("background").is_none(), "{r}");
assert_eq!(r["files"][0]["name"], "Artboard-1.svg");
assert!(app.background.jobs.is_empty() && written(&files).is_empty());
}
/// The command's refusal keeps the dialog open and starts nothing, as at once.
#[test]
fn a_refused_export_keeps_the_dialog_open_with_background_export_on() {
let (mut app, _, files) = app_with_worker(2, &Gate::default());
open(&mut app);
set(&mut app, "folder", json!(temp_folder("refused").to_string_lossy().replace('\\', "/")));
set(&mut app, "formats", json!([{"format": "dxf", "scale": "1x", "suffix": ""}]));
let e = confirm(&mut app).unwrap_err();
assert!(e.contains("Export for Screens writes png"), "{e}");
assert_eq!((kind(&app), app.ui.status.as_str()), (Some(export_for_screens::KIND), e.as_str()));
assert!(app.background.jobs.is_empty() && written(&files).is_empty());
}
#[test]
fn the_web_downloads_one_zip_or_the_file() {
let (mut app, log) = app(2, true);
+50 -8
View File
@@ -1,8 +1,9 @@
//! The export progress dialog: while an export runs in the background (Background Export) a modal
//! window shows what is being exported, how far it has got (artboard by artboard for raster
//! exports of several artboards; busy for one artboard and for formats made in one go, which
//! report no steps) and two buttons: Cancel (`file.cancelExport`, also
//! Esc) stops it and writes nothing, Continue in Background (`file.exportInBackground`) closes the
//! exports of several artboards, file by file for Export for Screens; busy for one artboard and for
//! formats made in one go, which report no steps) and two buttons: Cancel (`file.cancelExport`, also
//! Esc) stops it and writes nothing (Export for Screens keeps the files it wrote before the
//! Cancel), Continue in Background (`file.exportInBackground`) closes the
//! dialog and leaves the export to the status bar.
//!
//! It opens after [`DELAY`] so a quick export doesn't flash it, and waits while another dialog is
@@ -22,8 +23,9 @@ fn seen_id() -> egui::Id {
egui::Id::new("export-progress-seen")
}
/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one).
fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize)> {
/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one,
/// whether the steps are files).
fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, bool)> {
let jobs: Vec<_> = app.background.jobs.iter().filter(|j| !j.hidden && !j.cancelled()).filter(|j| j.progress.is_some()).collect();
let first = jobs.first()?;
let since = ctx.data_mut(|m| {
@@ -38,7 +40,8 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String,
}
let (done, total) = first.progress.as_ref().map(|p| p.counts()).unwrap_or_default();
let frac = first.progress.as_ref().and_then(|p| p.fraction()).unwrap_or(0.0) as f32;
Some((first.label.clone(), done, total, frac, jobs.len() - 1))
let files = first.progress.as_ref().is_some_and(|p| p.counts_files());
Some((first.label.clone(), done, total, frac, jobs.len() - 1, files))
}
/// Draws the window for the first export running in the background that wasn't hidden or
@@ -46,7 +49,7 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String,
/// of the button pressed.
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let Some((label, done, total, frac, more)) = shown(app, now, ctx) else { return };
let Some((label, done, total, frac, more, files)) = shown(app, now, ctx) else { return };
if app.ui.dialog.is_some() {
return;
}
@@ -55,7 +58,8 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let pressed = window(ctx, "export-progress", &heading, |ui| {
// One step says nothing a spinner doesn't: the bar would sit at 0 % until the file is written.
if total > 1 {
let step = crate::i18n::fmt(tl!("Artboard {n} of {total}"), &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]);
let step = if files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") };
let step = crate::i18n::fmt(step, &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]);
// The count on its own line: drawn over the bar it would straddle the fill's edge.
ui.horizontal(|ui| {
ui.label(egui::RichText::new(step).color(t.text_dim));
@@ -225,4 +229,42 @@ mod tests {
background::wait_all(&mut app);
assert!(app.background.jobs.is_empty());
}
#[test]
fn an_export_of_files_counts_files() {
let go = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut app = app(go.clone());
let p = Progress::of_files();
let reporting = p.clone();
background::run_with_progress(
&mut app,
true,
"Exporting icons".into(),
None,
Some(p.clone()),
move |w| {
vectorcraft_engine::cmd::fileio::progress::scope(&reporting, || {
vectorcraft_doc::cancel::progress(1, 3, "file 2 of 3");
w("/icons/b.png", b"x").map(|()| json!({}))
})
},
|_, r| r,
)
.unwrap();
// The worker reports the step before it waits on the writer.
for _ in 0..10_000 {
if p.counts() == (1, 3) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx, 0.0);
frame(&mut app, &ctx, 1.0);
let shown = frame(&mut app, &ctx, 1.1);
assert!(shown.contains("File 2 of 3") && !shown.contains("Artboard"), "{shown}");
go.store(true, std::sync::atomic::Ordering::SeqCst);
background::wait_all(&mut app);
}
}
+1
View File
@@ -5316,6 +5316,7 @@ theme Appearance Външен вид
@msg unknown snap option `{key}` (use {options}) неизвестна опция за прилепване `{key}` (използвайте {options})
Gap Length: Дължина на пропуска:
Artboard {n} of {total} Работна област {n} от {total}
File {n} of {total} Файл {n} от {total}
Encoding and writing the file… Кодиране и записване на файла…
@plural {n} more export waiting|{n} more exports waiting Още {n} експортиране в изчакване|Още {n} експортирания в изчакване
Continue in Background Продължаване във фонов режим
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5362,6 +5362,7 @@ mark-style Japanese Japonské
@msg unknown snap option `{key}` (use {options}) neznámá možnost přichytávání `{key}` (použijte {options})
Gap Length: Délka mezery:
Artboard {n} of {total} Pracovní plocha {n} z {total}
File {n} of {total} Soubor {n} z {total}
Encoding and writing the file… Kódování a zápis souboru…
@plural {n} more export waiting|{n} more exports waiting Čeká ještě {n} export|Čekají ještě {n} exporty|Čeká ještě {n} exportů
Continue in Background Pokračovat na pozadí
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5312,6 +5312,7 @@ swatch Bright Violet Kraftig violet
@msg unknown snap option `{key}` (use {options}) ukendt fastgørelsesindstilling `{key}` (brug {options})
Gap Length: Hullængde:
Artboard {n} of {total} Tegneflade {n} af {total}
File {n} of {total} Fil {n} af {total}
Encoding and writing the file… Koder og skriver filen…
@plural {n} more export waiting|{n} more exports waiting {n} eksport mere venter|{n} eksporter mere venter
Continue in Background Fortsæt i baggrunden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4942,6 +4942,7 @@ effect Soft Weich
@msg unknown snap option `{key}` (use {options}) unbekannte Einrastoption `{key}` (möglich: {options})
Gap Length: Lückenlänge:
Artboard {n} of {total} Zeichenfläche {n} von {total}
File {n} of {total} Datei {n} von {total}
Encoding and writing the file… Datei wird codiert und geschrieben…
@plural {n} more export waiting|{n} more exports waiting {n} weiterer Export wartet|{n} weitere Exporte warten
Continue in Background Im Hintergrund fortsetzen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5314,6 +5314,7 @@ effect Points Σημεία
@msg unknown snap option `{key}` (use {options}) άγνωστη επιλογή προσκόλλησης `{key}` (χρησιμοποιήστε {options})
Gap Length: Μήκος κενού:
Artboard {n} of {total} Πίνακας σχεδίασης {n} από {total}
File {n} of {total} Αρχείο {n} από {total}
Encoding and writing the file… Κωδικοποίηση και εγγραφή του αρχείου…
@plural {n} more export waiting|{n} more exports waiting {n} ακόμη εξαγωγή σε αναμονή|{n} ακόμη εξαγωγές σε αναμονή
Continue in Background Συνέχεια στο παρασκήνιο
Can't render this file because it contains an unexpected character in line 2045 and column 19.
+1
View File
@@ -5304,6 +5304,7 @@ mark-style Japanese Japonesas
@msg unknown snap option `{key}` (use {options}) opción de ajuste desconocida `{key}` (use {options})
Gap Length: Longitud del hueco:
Artboard {n} of {total} Mesa de trabajo {n} de {total}
File {n} of {total} Archivo {n} de {total}
Encoding and writing the file… Codificando y escribiendo el archivo…
@plural {n} more export waiting|{n} more exports waiting {n} exportación más en espera|{n} exportaciones más en espera
Continue in Background Continuar en segundo plano
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5326,6 +5326,7 @@ mark-style Japanese Japanilainen
@msg unknown snap option `{key}` (use {options}) tuntematon tartuntavalinta `{key}` (käytä: {options})
Gap Length: Aukon pituus:
Artboard {n} of {total} Piirtoalusta {n}/{total}
File {n} of {total} Tiedosto {n}/{total}
Encoding and writing the file… Tiedostoa koodataan ja kirjoitetaan…
@plural {n} more export waiting|{n} more exports waiting {n} vienti odottaa|{n} vientiä odottaa
Continue in Background Jatka taustalla
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5361,6 +5361,7 @@ theme Appearance Apparence
@msg unknown snap option `{key}` (use {options}) option de magnétisme inconnue `{key}` (utilisez {options})
Gap Length: Longueur de lacune :
Artboard {n} of {total} Plan de travail {n} sur {total}
File {n} of {total} Fichier {n} sur {total}
Encoding and writing the file… Encodage et écriture du fichier…
@plural {n} more export waiting|{n} more exports waiting {n} autre exportation en attente|{n} autres exportations en attente
Continue in Background Continuer en arrière-plan
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5348,6 +5348,7 @@ imageTrace Outline Körvonal
@msg unknown snap option `{key}` (use {options}) ismeretlen illesztési beállítás: `{key}` (használható: {options})
Gap Length: Rés hossza:
Artboard {n} of {total} Rajztábla: {n} / {total}
File {n} of {total} Fájl: {n} / {total}
Encoding and writing the file… A fájl kódolása és írása…
@plural {n} more export waiting|{n} more exports waiting Még {n} exportálás várakozik
Continue in Background Folytatás a háttérben
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5312,6 +5312,7 @@ swatch Bright Violet Violet cerah
@msg unknown snap option `{key}` (use {options}) opsi snap `{key}` tidak dikenal (gunakan {options})
Gap Length: Panjang celah:
Artboard {n} of {total} Artboard {n} dari {total}
File {n} of {total} Berkas {n} dari {total}
Encoding and writing the file… Mengodekan dan menulis berkas…
@plural {n} more export waiting|{n} more exports waiting {n} ekspor lagi menunggu
Continue in Background Lanjutkan di latar belakang
Can't render this file because it contains an unexpected character in line 3816 and column 19.
+1
View File
@@ -4976,6 +4976,7 @@ lighting Light Luce
@msg unknown snap option `{key}` (use {options}) opzione di aggancio sconosciuta `{key}` (usare {options})
Gap Length: Lunghezza lacuna:
Artboard {n} of {total} Tavola da disegno {n} di {total}
File {n} of {total} File {n} di {total}
Encoding and writing the file… Codifica e scrittura del file…
@plural {n} more export waiting|{n} more exports waiting {n} altra esportazione in attesa|{n} altre esportazioni in attesa
Continue in Background Continua in background
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5016,6 +5016,7 @@ lighting Light ライト
@msg unknown snap option `{key}` (use {options}) 不明なスナップオプション `{key}` ({options} を使用してください)
Gap Length: ギャップの長さ:
Artboard {n} of {total} アートボード {n} / {total}
File {n} of {total} ファイル {n} / {total}
Encoding and writing the file… ファイルをエンコードして書き込み中…
@plural {n} more export waiting|{n} more exports waiting 待機中の書き出し: あと {n} 件
Continue in Background バックグラウンドで続行
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5237,6 +5237,7 @@ imageTrace Outline კონტური
Clear History ისტორიის გასუფთავება
Continue in Background ფონურ რეჟიმში გაგრძელება
Artboard {n} of {total} სამუშაო დაფა {n} / {total}
File {n} of {total} ფაილი {n} / {total}
Auto Leading: ავტომატური სტრიქონთა დაშორება:
Desired სასურველი
Encoding and writing the file… ფაილის კოდირება და ჩაწერა…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4939,6 +4939,7 @@ lighting Light 광원
@msg unknown snap option `{key}` (use {options}) 알 수 없는 스냅 옵션 `{key}` ({options} 중에서 사용하세요)
Gap Length: 간격 길이:
Artboard {n} of {total} 아트보드 {n} / {total}
File {n} of {total} 파일 {n} / {total}
Encoding and writing the file… 파일을 인코딩하고 쓰는 중…
@plural {n} more export waiting|{n} more exports waiting 대기 중인 내보내기 {n}개 더
Continue in Background 백그라운드에서 계속
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5355,6 +5355,7 @@ swatch Bright Violet Felviolet
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. De afbeelding is {_1} × {_2} pixels, meer dan {LARGE_TRACE_SIDE} aan één zijde: ze wordt getraceerd, maar langzamer. Een voorinstelling met minder detail of een kleinere kopie van de afbeelding traceert sneller.
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). De afbeelding is getraceerd op {_1} × {_2} pixels, verkleind van {_3} × {_4} (maximaal {_5} megapixels).
Artboard {n} of {total} Tekengebied {n} van {total}
File {n} of {total} Bestand {n} van {total}
Encoding and writing the file… Bestand coderen en schrijven…
@plural {n} more export waiting|{n} more exports waiting Nog {n} export in de wachtrij|Nog {n} exports in de wachtrij
Continue in Background Doorgaan op achtergrond
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5214,6 +5214,7 @@ mark-style Japanese Japońskie
@msg unknown snap option `{key}` (use {options}) nieznana opcja przyciągania `{key}` (użyj: {options})
Gap Length: Długość przerwy:
Artboard {n} of {total} Obszar kompozycji {n} z {total}
File {n} of {total} Plik {n} z {total}
Encoding and writing the file… Kodowanie i zapisywanie pliku…
@plural {n} more export waiting|{n} more exports waiting Czeka jeszcze {n} eksport|Czekają jeszcze {n} eksporty|Czeka jeszcze {n} eksportów
Continue in Background Kontynuuj w tle
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5279,6 +5279,7 @@ mark-style Japanese Japonesas
@msg unknown snap option `{key}` (use {options}) opção de ajuste desconhecida `{key}` (use {options})
Gap Length: Comprimento da Lacuna:
Artboard {n} of {total} Mesa de Trabalho {n} de {total}
File {n} of {total} Arquivo {n} de {total}
Encoding and writing the file… Codificando e gravando o arquivo…
@plural {n} more export waiting|{n} more exports waiting Mais {n} exportação aguardando|Mais {n} exportações aguardando
Continue in Background Continuar em Segundo Plano
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4972,6 +4972,7 @@ lighting Light Свет
@msg unknown snap option `{key}` (use {options}) неизвестный параметр привязки `{key}` (допустимо: {options})
Gap Length: Длина зазора:
Artboard {n} of {total} Монтажная область {n} из {total}
File {n} of {total} Файл {n} из {total}
Encoding and writing the file… Кодирование и запись файла…
@plural {n} more export waiting|{n} more exports waiting Ещё {n} экспорт в очереди|Ещё {n} экспорта в очереди|Ещё {n} экспортов в очереди
Continue in Background Продолжить в фоне
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5318,6 +5318,7 @@ theme Appearance Utseende
@msg unknown snap option `{key}` (use {options}) okänt fästalternativ `{key}` (använd {options})
Gap Length: Lucklängd:
Artboard {n} of {total} Rityta {n} av {total}
File {n} of {total} Fil {n} av {total}
Encoding and writing the file… Kodar och skriver filen…
@plural {n} more export waiting|{n} more exports waiting {n} export till väntar|{n} exporter till väntar
Continue in Background Fortsätt i bakgrunden
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -2662,6 +2662,7 @@
Desired İstenen
Edit Path Yolu Düzenle
Encoding and writing the file… Dosya kodlanıyor ve yazılıyor…
File {n} of {total} Dosya {n} / {total}
Full Justify Tam Yasla
Glyph Scaling: Glif Ölçekleme:
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Çizim tabletinin kalemi çizmiyorsa, bir X11 (XWayland) seçeneği belirleyin.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4956,6 +4956,7 @@ lighting Light Світло
@msg unknown snap option `{key}` (use {options}) невідомий параметр прив’язки `{key}` (використовуйте {options})
Gap Length: Довжина проміжку:
Artboard {n} of {total} Монтажна область {n} з {total}
File {n} of {total} Файл {n} з {total}
Encoding and writing the file… Кодування та запис файлу…
@plural {n} more export waiting|{n} more exports waiting Ще {n} експорт в очікуванні|Ще {n} експорти в очікуванні|Ще {n} експортів в очікуванні
Continue in Background Продовжити у фоні
Can't render this file because it contains an unexpected character in line 733 and column 40.
+1
View File
@@ -4956,6 +4956,7 @@ theme Appearance Giao diện
@msg unknown snap option `{key}` (use {options}) tùy chọn hút không hợp lệ `{key}` (dùng {options})
Gap Length: Độ dài khoảng hở:
Artboard {n} of {total} Bảng vẽ {n} / {total}
File {n} of {total} Tệp {n} / {total}
Encoding and writing the file… Đang mã hóa và ghi tệp…
@plural {n} more export waiting|{n} more exports waiting Còn {n} lượt xuất đang chờ
Continue in Background Tiếp tục trong nền
Can't render this file because it contains an unexpected character in line 4030 and column 10.
+1
View File
@@ -5200,6 +5200,7 @@ lighting Light 光源
@msg {id} opens another app: only the person at the keyboard can run it {id} 会打开其他应用程序:只有使用键盘的人才能运行它
@msg “{name}” is not a file “{name}” 不是文件
Artboard {n} of {total} 画板 {n} / {total}
File {n} of {total} 文件 {n} / {total}
Encoding and writing the file… 正在编码并写入文件…
@plural {n} more export waiting|{n} more exports waiting 另有 {n} 个导出等待中
Continue in Background 在后台继续
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4990,6 +4990,7 @@ lighting Light 光源
@msg unknown snap option `{key}` (use {options}) 未知的靠齊選項 `{key}`(可用:{options})
Gap Length: 間隙長度:
Artboard {n} of {total} 工作區域 {n} / {total}
File {n} of {total} 檔案 {n} / {total}
Encoding and writing the file… 正在編碼並寫入檔案…
@plural {n} more export waiting|{n} more exports waiting 另有 {n} 個匯出等待中
Continue in Background 在背景繼續
Can't render this file because it contains an unexpected character in line 6 and column 47.
+39 -2
View File
@@ -1003,10 +1003,47 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Val
}
/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`).
/// On the desktop with Background Export on, an export into a folder is checked, journaled and
/// prepared from the document as it is now, and the files are encoded and written on the
/// background thread ([`background`]): this returns `{background: true, status}` at once, with the
/// progress dialog's Cancel, and the status bar reports the files once they are written.
pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Result<Value, String> {
let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false);
let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string();
let r = app.run(id, params)?;
// Without a folder the files come back in the reply, which the caller waits for.
let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty();
if !in_background {
// Made at once, after the exports and saves started before it.
background::wait_all(app);
let r = app.run(id, params)?;
return report_exported(app, r, &folder, open_location);
}
let export = app.session.prepare_screen_export(id, &params).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?;
let label = format!("Exporting {}", fileio::file_name(&folder));
// The progress dialog counts the files, and its Cancel stops the export before the next one.
let progress = Progress::of_files();
let (reporting, asked) = (progress.clone(), progress.clone());
let work = move |write: Writer| {
progress::scope(&reporting, || {
let mut write = |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
export.write(&mut write).map_err(|e| e.to_string())
})
};
background::run_with_progress(app, true, label, None, Some(progress), work, move |app, r| {
// Files written before the Cancel arrived stay in the folder.
if r.is_err() && asked.cancelled() {
app.status(EXPORT_CANCELLED);
return Ok(json!({ "cancelled": true }));
}
let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?;
report_exported(app, r, &folder, open_location)
})
}
/// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count
/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`,
/// the file manager shows the first file written.
fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result<Value, String> {
let count = r["files"].as_array().map_or(0, Vec::len);
let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty());
// Files that came back as bytes: (name, base64).
@@ -1020,7 +1057,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) ->
let first = r["path"].as_str().or_else(|| r["files"][0].as_str());
let mut status = format!("Exported {count} file(s) to {folder}");
if warned {
status = saved_status(r["path"].as_str().unwrap_or(&folder), &r);
status = saved_status(r["path"].as_str().unwrap_or(folder), &r);
}
if open_location
&& let Some(first) = first
+1 -1
View File
@@ -162,7 +162,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.exportForScreens",
"Export for Screens…",
"Cmd+Alt+E",
"{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager)",
"{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})",
),
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
+3 -2
View File
@@ -3,8 +3,9 @@
//! of them all), and so does the selection with the + button. Clicking selects assets (Shift or
//! Cmd adds), double-clicking one renames it and the bin removes the selected ones. Below are the
//! export settings, the format rows Export for Screens shows too (one set per document), and
//! Export, which writes the selected assets (else all of them) into a picked folder; the web
//! downloads them. The screen button opens Export for Screens on its Assets tab.
//! Export, which writes the selected assets (else all of them) into a picked folder (on the
//! desktop with Background Export on, on the background thread); the web downloads them. The
//! screen button opens Export for Screens on its Assets tab.
//!
//! Everything runs through the `assets.*` commands.
+1 -1
View File
@@ -118,7 +118,7 @@ the two can still win that race.
| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning |
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the first of the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` |
| Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog |
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written |
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written |
| `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes |
| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) |
| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog |
+2 -1
View File
@@ -271,7 +271,8 @@ while an export runs (#2165, #2171, #2172):
- **Progress.** A request with `_meta.progressToken` gets `notifications/progress`
([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress)) before
its reply: the PDF writer reports "Writing page 2 of 5" with `progress` 2 and `total` 5, raster
export reports each artboard, and the PDF flattening stage reports a message without a total. `progress` only grows, updates come at most every 200 ms (the last page always),
export reports each artboard, `document.exportForScreens` and `assets.export` report each file
("Exporting file 3 of 250", in place of their encoders' steps), and the PDF flattening stage reports a message without a total. `progress` only grows, updates come at most every 200 ms (the last page always),
and none follows the reply. A client that does not read fast enough misses updates, never the reply.
- **A busy engine.** `ping`, `tools/list`, `resources/list`, `resources/templates/list`,
`prompts/list` and `prompts/get` need no engine and are answered at once, whatever is running.
+1
View File
@@ -908,6 +908,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before.
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
- On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before.
- On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before.
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.
- On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing.
- On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed.