mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
M1: several exported layers arrive as one zip in the browser (#969)
Cause: in the browser, exporting several layers started one download per file. Chrome holds back every download after the first until the site is allowed automatic downloads, so the other files didn't arrive. Fix: file.exportLayers takes `zip: true` (without `dir`) and returns the files together as one base64 zip `data`, written by a new figcraft_format::zip_files (the workspace's existing zip dependency). The UI asks for it when the platform saves by downloads and hands over one `<document name>.zip`. Test: ui-egui several_layers_download_as_one_zip (one download, a zip holding both layers). It fails on the previous commit (two downloads).
This commit is contained in:
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.exportLayers",
|
||||
"Export layers",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}",
|
||||
false,
|
||||
export_layers
|
||||
),
|
||||
@@ -1209,6 +1209,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into()));
|
||||
}
|
||||
let dir = string(p, "dir");
|
||||
// The browser saves one download at a time: several files go as one zip (#969).
|
||||
let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false);
|
||||
let mut members = Vec::new();
|
||||
// Print options passed here stand in for every PDF setting's own.
|
||||
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
|
||||
let mut files = Vec::with_capacity(jobs.len());
|
||||
@@ -1228,12 +1231,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write(&path, &bytes)?;
|
||||
f["path"] = json!(path);
|
||||
}
|
||||
None if zip => members.push((job.file.as_str(), bytes)),
|
||||
None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)),
|
||||
}
|
||||
files.push(f);
|
||||
}
|
||||
let with_settings: std::collections::BTreeSet<NodeId> = jobs.iter().map(|j| j.id).collect();
|
||||
Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}))
|
||||
let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())});
|
||||
if zip {
|
||||
let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area`
|
||||
|
||||
@@ -343,6 +343,20 @@ pub(crate) fn put<W: Write + Seek>(
|
||||
z.write_all(bytes).map_err(io_err)
|
||||
}
|
||||
|
||||
/// `files` (name, bytes; a `/` in a name makes a folder) as one zip archive: several exports
|
||||
/// delivered as a single download (#969).
|
||||
pub fn zip_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Result<Vec<u8>, FormatError> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
for (name, bytes) in files {
|
||||
put(&mut z, name, zip::write::SimpleFileOptions::default(), bytes)?;
|
||||
}
|
||||
z.finish().map_err(io_err)?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
/// Add `doc` as a `document.json`-style member, serialized straight into the compressor.
|
||||
fn put_json<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, doc: &Document) -> Result<(), FormatError> {
|
||||
z.start_file(name, json_options()).map_err(io_err)?;
|
||||
|
||||
@@ -320,13 +320,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) {
|
||||
[] => {}
|
||||
// The suggested name keeps only the file's own name (a layer's folders apply to folders).
|
||||
[one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)),
|
||||
// No folders in the browser: each file arrives as its own download (#969).
|
||||
// No folders in the browser, and it lets a page start one download at a time: the files
|
||||
// arrive together as one zip (#969).
|
||||
_ if app.services.downloads() => {
|
||||
let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets})) else { return };
|
||||
let files = r["files"].as_array().map_or(&[][..], Vec::as_slice);
|
||||
let n = files.iter().filter(|f| app.download(f["name"].as_str().unwrap_or_default(), f)).count();
|
||||
if n > 0 {
|
||||
app.toast(trn("Exported {n} files", n, &[]));
|
||||
let name = format!("{}.zip", app.session.doc().map_or("Export", |d| d.name.as_str()));
|
||||
if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets, "zip": true}))
|
||||
&& app.download(&name, &r)
|
||||
{
|
||||
app.toast(trn("Exported {n} files", r["files"].as_array().map_or(0, Vec::len), &[]));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -53,3 +53,19 @@ fn a_refused_download_says_so() {
|
||||
assert_eq!(got.lock().unwrap().len(), 1);
|
||||
assert_eq!(toast(&app), "Couldn't download Untitled.figcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_layers_download_as_one_zip() {
|
||||
// The browser lets a page start one download at a time: two downloads lost the second.
|
||||
let (mut app, got, a) = browser(true);
|
||||
let r = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let b: NodeId = serde_json::from_value(r["id"].clone()).unwrap();
|
||||
app.session.execute("export.add", &json!({"ids": [a, b]})).unwrap();
|
||||
crate::export_panel::export_layers(&mut app, &[a, b], false);
|
||||
let got = got.lock().unwrap();
|
||||
let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) };
|
||||
assert_eq!(name, "Untitled.zip");
|
||||
assert!(bytes.starts_with(b"PK\x03\x04"));
|
||||
assert!(bytes.windows(15).any(|w| w == b"Rectangle 2.png"), "both layers are in the zip");
|
||||
assert_eq!(toast(&app), "Exported 2 files");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user