mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
Lists and headings that mix our labels with names no longer send the names through the catalog, so a preset, printer or plug-in called "Regular" or "Black" keeps its name in every language. - dialogs::shown_names / mixed_dropdown: translate only a list's built-in entries and show the rest with widgets::dropdown_names (same indices). - Artboard lists (Save for Office, Save for Web, save options) and plug-in choices: dropdown_names. - Mixed lists translate only their built-in entries: Recolor's Limit to Library, Save for Web, Print, Save PDF and Export for Screens presets, printers (Default Printer, PDF File), colour profiles and flattener presets (profile_pick, flatten::preset_dropdown), perspective presets, New Document's preset cards and Size, and the shortcut set. - Plug-in dialogs and plug-in effects are headed by the plug-in's name as it is; plug-in parameter labels are not translated. - The confirm dialog shows its message and detail as given, and the shared frame no longer translates headings again: each heading is translated by its spec. File > Revert translates its template before filling in the document's name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
732 lines
35 KiB
Rust
732 lines
35 KiB
Rust
//! File I/O through the injected services (pick, read, write, download, reveal); the engine's
|
||
//! `fileio` decodes, encodes and saves every format.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::SavedView;
|
||
use vectorcraft_engine::EngineError;
|
||
use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan};
|
||
|
||
use crate::background::{self, Writer};
|
||
use crate::dialogs::svg_options;
|
||
use crate::state::Dialog;
|
||
use crate::{FilePick, Services, VectorcraftApp, dialogs};
|
||
|
||
/// Template extensions New from Template's open dialog lists first.
|
||
const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"];
|
||
|
||
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
|
||
/// graphic style library files open in the library panel and flattener, PDF, print and perspective
|
||
/// grid presets files are imported.
|
||
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
|
||
let ext = fileio::extension(name);
|
||
// A WebAssembly plug-in is installed.
|
||
if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) {
|
||
let r =
|
||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}))?;
|
||
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name)));
|
||
return Ok(());
|
||
}
|
||
let presets = [
|
||
(vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"),
|
||
(vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"),
|
||
(vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"),
|
||
(vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"),
|
||
];
|
||
if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
|
||
let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;
|
||
let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||
app.status(format!("Imported {what}: {}", names.join(", ")));
|
||
return Ok(());
|
||
}
|
||
let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str());
|
||
if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT {
|
||
let p = match path {
|
||
Some(path) => serde_json::json!({ "path": path }),
|
||
None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}),
|
||
};
|
||
let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library };
|
||
return load(app, p).map(|_| ());
|
||
}
|
||
// A PDF with several pages or a password asks first (the Import PDF dialog), and so does a
|
||
// DXF drawing (DXF Import Options).
|
||
if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None)
|
||
|| crate::dialogs::dxf_import::offer(app, name, bytes, path.clone(), None)
|
||
{
|
||
return Ok(());
|
||
}
|
||
open_document(app, name, bytes, path, &Value::Null)
|
||
}
|
||
|
||
/// Open a document through the engine loader with the `document.open` options in `p`.
|
||
pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<(), String> {
|
||
let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?;
|
||
app.sync_views();
|
||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));
|
||
}
|
||
crate::dialogs::missing_links::after_open(app, &r);
|
||
Ok(())
|
||
}
|
||
|
||
/// A path from the open dialog, or "cancelled".
|
||
fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||
app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
|
||
}
|
||
|
||
/// Object › Plug-ins › Install Plug-in…: installs the `.wasm` at `path`, else a picked one (the
|
||
/// web's file picker hands the file to [`open_bytes`], which installs it).
|
||
pub fn install_plugin(app: &mut VectorcraftApp, path: Option<String>) -> Result<Value, String> {
|
||
let path = match path {
|
||
Some(p) => p,
|
||
None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null),
|
||
None => pick_open(app, &FilePick { filters: vec![("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?,
|
||
};
|
||
let r = app.run("plugin.install", json!({ "path": path }))?;
|
||
app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default()));
|
||
Ok(r)
|
||
}
|
||
|
||
/// File → Open…
|
||
pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> {
|
||
if let Some(f) = app.services.open_async.as_mut() {
|
||
f();
|
||
return Ok(());
|
||
}
|
||
let path = pick_open(app, &FilePick { filters: fileio::open_filters().collect(), ..Default::default() })?;
|
||
open_path(app, &path)
|
||
}
|
||
|
||
fn read(app: &VectorcraftApp, path: &str) -> Result<Vec<u8>, String> {
|
||
app.services.read.as_ref().ok_or("no file reader")?(path)
|
||
}
|
||
|
||
pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
|
||
let bytes = read(app, path)?;
|
||
open_bytes(app, path, &bytes, Some(path.to_string()))?;
|
||
note_recent(app, path);
|
||
Ok(())
|
||
}
|
||
|
||
/// File → New from Template…: a template (or any readable file) as a new untitled document. Without
|
||
/// a path the open dialog starts in the Templates folder. On the web the browser's file picker
|
||
/// opens it (`.vctemplate`, `.ait` and template files open untitled there too).
|
||
pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> Result<Value, String> {
|
||
let path = match path {
|
||
Some(p) => p,
|
||
None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null),
|
||
None => {
|
||
let filters = std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters()).collect();
|
||
pick_open(app, &FilePick { folder: fileio::templates_folder(&app.session.prefs), filters, ..Default::default() })?
|
||
}
|
||
};
|
||
let bytes = read(app, &path)?;
|
||
let r = fileio::open_template(&mut app.session, &path, &bytes, Some(&path)).map_err(|e| e.to_string())?;
|
||
app.sync_views();
|
||
crate::dialogs::missing_links::after_open(app, &r);
|
||
Ok(r)
|
||
}
|
||
|
||
/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`.
|
||
pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||
if let Some(dl) = services.download.as_mut() {
|
||
dl(&fileio::file_name(path), bytes);
|
||
return Ok(());
|
||
}
|
||
services.write.as_mut().ok_or("no file writer")?(path, bytes)
|
||
}
|
||
|
||
/// The web saves by downloading: no save panel, no folders.
|
||
pub(crate) fn is_web(app: &VectorcraftApp) -> bool {
|
||
app.services.download.is_some()
|
||
}
|
||
|
||
/// Write `bytes` to `path`, else to a file picked with `name` suggested next to the document (the
|
||
/// web downloads them as `name`) → where they went.
|
||
pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name: &str, bytes: &[u8]) -> Result<String, String> {
|
||
let path = match path {
|
||
Some(p) => p,
|
||
None => {
|
||
let (_, folder) = suggested(app, "");
|
||
pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })?
|
||
}
|
||
};
|
||
write_to(&mut app.services, &path, bytes)?;
|
||
Ok(path)
|
||
}
|
||
|
||
/// Where a file goes when no path was given: the suggested name on the web (a download), else the
|
||
/// save panel's choice, given the suggested name's extension when it names no file type (a name
|
||
/// typed without one).
|
||
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||
if is_web(app) {
|
||
return Ok(pick.name.clone());
|
||
}
|
||
let picked = app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or("cancelled")?;
|
||
Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true))
|
||
}
|
||
|
||
/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format
|
||
/// `keeps` takes, else with `.ext` added.
|
||
fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String {
|
||
let has = fileio::extension(path);
|
||
if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) {
|
||
path.to_string()
|
||
} else {
|
||
format!("{path}.{ext}")
|
||
}
|
||
}
|
||
|
||
/// The active document's file name with extension `ext`, and its folder.
|
||
fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option<String>) {
|
||
let st = app.session.active();
|
||
let t = st.map(|d| d.path.clone().unwrap_or_else(|| d.doc.title.clone())).unwrap_or_else(|| "Untitled".into());
|
||
let folder = st.and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(|p| p.to_string_lossy().to_string());
|
||
(format!("{}.{ext}", fileio::file_stem(&t)), folder.filter(|f| !f.is_empty()))
|
||
}
|
||
|
||
/// `path`, else one picked in a save panel (the web: the suggested name) for the active document
|
||
/// with extension `ext`, filtered to that format when it is one.
|
||
pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
|
||
if let Some(p) = path {
|
||
return Ok(p);
|
||
}
|
||
let (name, folder) = suggested(app, ext);
|
||
let filters = fileio::format(ext).map(|f| vec![(f.label, f.extensions)]).unwrap_or_default();
|
||
pick_path(app, &FilePick { name, folder, filters })
|
||
}
|
||
|
||
/// Keep the active document's saved view current before a save (native files reopen at it).
|
||
pub fn remember_view(app: &mut VectorcraftApp) {
|
||
let Some(v) = app.view().copied().filter(|v| v.fitted) else { return };
|
||
if let Some(st) = app.session.active_mut() {
|
||
st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation });
|
||
}
|
||
}
|
||
|
||
/// A path typed in a save panel: kept when its extension names a save format (the panel's file
|
||
/// type), else `f`'s extension is added.
|
||
fn with_save_extension(path: &str, f: &Format) -> String {
|
||
with_extension(path, f.extensions[0], |g| SAVE_FORMATS.contains(&g.id))
|
||
}
|
||
|
||
/// Write every file of an export (one per artboard, linked images) for the destination `path`.
|
||
/// → the export's own files (without the linked images).
|
||
fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<Vec<String>, String> {
|
||
let named = enc.named(doc, path);
|
||
for (p, bytes) in &named {
|
||
write(p, bytes)?;
|
||
}
|
||
Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect())
|
||
}
|
||
|
||
fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result<SavePlan, String> {
|
||
fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the
|
||
/// engine command's `{path?, format?, options?, svg?}`.
|
||
///
|
||
/// When no path is known, desktop asks with a save panel (one file type per save format) and the
|
||
/// web opens the Save As dialog (file name and format). With `ask_options` and no options in `p`,
|
||
/// a format picked that way that has options asks for them before anything is written (SVG
|
||
/// Options, the Save PDF dialog, or the save options dialog: native and `.ai` files on Save As
|
||
/// only), and so does Save As or Save a Copy to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: <dialog kind>,
|
||
/// path?}` while a dialog is open.
|
||
pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result<Value, String> {
|
||
remember_view(app);
|
||
let first = plan(app, mode, p)?;
|
||
let ask = ask_options && !has_options(p);
|
||
if let Some(path) = first.path.clone() {
|
||
if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") {
|
||
return ask_format_options(app, mode, first.format, &path);
|
||
}
|
||
return write_plan(app, first);
|
||
}
|
||
if is_web(app) && ask_options {
|
||
return Ok(open_options(app, mode.command(), first.format, &first.name, true));
|
||
}
|
||
let filters = match mode {
|
||
SaveMode::Template => vec![(first.format.label, first.format.extensions)],
|
||
_ => fileio::save_filters(first.format.id),
|
||
};
|
||
let picked = pick_path(app, &FilePick { name: first.name.clone(), folder: first.folder.clone(), filters })?;
|
||
// The picked file type wins over a `format` param.
|
||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||
if let Some(o) = q.as_object_mut() {
|
||
o.remove("format");
|
||
o.insert("path".into(), json!(with_save_extension(&picked, first.format)));
|
||
}
|
||
let chosen = plan(app, mode, &q)?;
|
||
if ask && asks_options(mode, chosen.format) {
|
||
let path = chosen.path.clone().unwrap_or_default();
|
||
return ask_format_options(app, mode, chosen.format, &path);
|
||
}
|
||
write_plan(app, chosen)
|
||
}
|
||
|
||
/// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai`
|
||
/// files only on Save As (the options dialog after the save panel); Save, a Copy and templates
|
||
/// reuse the remembered options or Use Compression.
|
||
fn asks_options(mode: SaveMode, f: &Format) -> bool {
|
||
!f.options.is_empty()
|
||
&& match f.id {
|
||
"vectorcraft" | "ai" => mode == SaveMode::SaveAs,
|
||
"template" => false,
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
/// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)?
|
||
fn has_options(p: &Value) -> bool {
|
||
["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty())
|
||
}
|
||
|
||
/// Ask for format `f`'s options before a save (`mode`) to `path`: SVG Options for SVG, the Save
|
||
/// PDF dialog for PDF, else the save options dialog. OK finishes the save.
|
||
pub fn ask_format_options(app: &mut VectorcraftApp, mode: SaveMode, f: &Format, path: &str) -> Result<Value, String> {
|
||
match f.id {
|
||
"pdf" => ask_pdf_options(app, mode, path),
|
||
"svg" | "svgz" => {
|
||
let m = if mode == SaveMode::Copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save };
|
||
svg_options::open(app, m, Some(path));
|
||
Ok(json!({ "pending": svg_options::KIND, "dialog": svg_options::KIND, "path": path }))
|
||
}
|
||
_ => Ok(open_options(app, mode.command(), f, path, false)),
|
||
}
|
||
}
|
||
|
||
/// A save as PDF asks with the Save PDF dialog, filled with the PDF options the document was last
|
||
/// saved with; its OK finishes the save ([`save_pdf`]).
|
||
pub fn ask_pdf_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) -> Result<Value, String> {
|
||
let mut p = Value::Object(plan(app, mode, &json!({ "path": path, "format": "pdf" }))?.options);
|
||
p["path"] = json!(path);
|
||
dialogs::open_save_pdf(app, &p)?;
|
||
let d = app.ui.dialog.as_mut().ok_or("the Save PDF dialog didn't open")?;
|
||
d.fields.insert("__save".into(), json!(mode.command()));
|
||
Ok(json!({ "pending": d.kind }))
|
||
}
|
||
|
||
/// The Save PDF dialog's OK during a save (`mode`): `params` are its `document.exportPdf` options
|
||
/// and `path`.
|
||
pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) -> Result<Value, String> {
|
||
let path = params.as_object_mut().and_then(|o| o.remove("path"));
|
||
let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false);
|
||
let r = save(app, mode, &json!({ "path": path, "format": "pdf", "options": params }), false)?;
|
||
if view && let Some(path) = r["path"].as_str() {
|
||
view_file(app, path);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Open a written file in the system viewer (not on the web, which downloads it).
|
||
fn view_file(app: &mut VectorcraftApp, path: &str) {
|
||
if !is_web(app) {
|
||
app.open_url(&file_url(path));
|
||
}
|
||
}
|
||
|
||
/// "Saved <path>", with the first of the result's warnings.
|
||
pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) {
|
||
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||
app.status(match warnings.first() {
|
||
Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()),
|
||
None => format!("Saved {path}"),
|
||
});
|
||
}
|
||
|
||
/// Write a planned save through the services and report it in the status bar.
|
||
/// With Background Save on, the file is encoded and written from a snapshot off the UI thread
|
||
/// ([`background`]): this returns `{path, background: true, status}` at once, and the document
|
||
/// takes the file (and counts as saved, as of the snapshot) once it is written.
|
||
fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String> {
|
||
let uid = app.session.active().ok_or("no document")?.uid;
|
||
// Saves of one document finish in order.
|
||
if app.background.busy_with(uid) {
|
||
background::wait_all(app);
|
||
}
|
||
let job = fileio::save_job(&mut app.session, plan).map_err(|e| e.to_string())?;
|
||
let (retargets, target) = (job.plan().retargets(), job.plan().path.clone());
|
||
let label = match &target {
|
||
Some(path) => format!("Saving {}", fileio::file_name(path)),
|
||
None => "Saving".into(),
|
||
};
|
||
// Without a path the bytes come back to the caller: nothing to do in the background.
|
||
let enabled = app.session.prefs.background_save && target.is_some();
|
||
// The work writes the file; the copy makes it the document's own afterwards.
|
||
let done = job.clone();
|
||
let work = move |write: Writer| job.write(|p, b| write(p, b).map_err(EngineError::Other)).map_err(|e| e.to_string());
|
||
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
|
||
let r = r?;
|
||
// The document may have closed meanwhile: the file is written all the same.
|
||
done.complete(&mut app.session, uid);
|
||
let path = r["path"].as_str().unwrap_or_default().to_string();
|
||
if retargets {
|
||
note_recent(app, &path);
|
||
}
|
||
report_saved(app, &path, &r);
|
||
Ok(r)
|
||
};
|
||
let mut r = background::run(app, enabled, label, Some(uid), work, then)?;
|
||
if let Some(path) = target.filter(|_| r.get("path").is_none()) {
|
||
r["path"] = json!(path);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Open the save options dialog of format `f` before writing `path`: `action` is the save command
|
||
/// it finishes, and `pick` (the web's Save As) also names the file and chooses the format.
|
||
fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str, pick: bool) -> Value {
|
||
let mut fields = dialogs::save_options::option_values(app, f);
|
||
fields.insert("__action".into(), json!(action));
|
||
fields.insert("__pick".into(), json!(pick));
|
||
fields.insert("path".into(), json!(path));
|
||
fields.insert("format".into(), json!(f.id));
|
||
app.ui.dialog = Some(Dialog { kind: dialogs::save_options::KIND.into(), fields });
|
||
json!({ "pending": dialogs::save_options::KIND })
|
||
}
|
||
|
||
/// The most recent files remembered: Preferences → File Handling → Number of Recent Files to Display
|
||
/// shows 0 to this many of them.
|
||
pub const MAX_RECENT_FILES: usize = 30;
|
||
|
||
/// Put `path` at the top of File → Open Recent Files.
|
||
pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
|
||
let r = &mut app.ui.recent_files;
|
||
r.retain(|p| p != path);
|
||
r.insert(0, path.to_string());
|
||
r.truncate(MAX_RECENT_FILES);
|
||
}
|
||
|
||
/// The recent files File → Open Recent Files lists.
|
||
pub fn recent_files(app: &VectorcraftApp) -> &[String] {
|
||
let r = &app.ui.recent_files;
|
||
&r[..r.len().min(app.session.prefs.recent_files_count as usize)]
|
||
}
|
||
|
||
/// Export the active document in `format` (default: the path's extension, else PNG) with the
|
||
/// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG
|
||
/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The
|
||
/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are
|
||
/// several).
|
||
pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<String>, params: &Value) -> Result<Value, String> {
|
||
let f = fileio::writable_format(format, path.as_deref())?;
|
||
let st = app.session.active().ok_or("no document")?;
|
||
let doc = match fileio::export_source(st, params).map_err(|e| e.to_string())? {
|
||
std::borrow::Cow::Borrowed(_) => st.doc.clone(),
|
||
std::borrow::Cow::Owned(d) => std::sync::Arc::new(d),
|
||
};
|
||
let path = target_path(app, path, f.extensions[0])?;
|
||
// An SVG given a .svgz name is written compressed.
|
||
let f = match fileio::format_for_name(&path) {
|
||
Some(z) if f.id == "svg" && z.id == "svgz" => z,
|
||
_ => f,
|
||
};
|
||
// Background Export: encoded and written from this snapshot while editing goes on.
|
||
let label = format!("Exporting {}", fileio::file_name(&path));
|
||
let (params, target) = (params.clone(), path.clone());
|
||
let work = move |write: Writer| {
|
||
let enc = fileio::encode_all(&doc, f.id, ¶ms).map_err(|e| e.to_string())?;
|
||
let files = write_encoded(write, &doc, &path, &enc)?;
|
||
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings });
|
||
if files.len() > 1 {
|
||
out["files"] = json!(files);
|
||
}
|
||
Ok(out)
|
||
};
|
||
let enabled = app.session.prefs.background_export;
|
||
let mut r = background::run(app, enabled, label, None, work, |app, r| {
|
||
let r = r?;
|
||
let what = match r["files"].as_array() {
|
||
Some(files) => format!("{} files", files.len()),
|
||
None => r["path"].as_str().unwrap_or_default().to_string(),
|
||
};
|
||
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||
app.status(match warnings.first() {
|
||
Some(first) => format!("Exported {what} with {} note(s): {first}", warnings.len()),
|
||
None => format!("Exported {what}"),
|
||
});
|
||
Ok(r)
|
||
})?;
|
||
if r.get("path").is_none() {
|
||
r["path"] = json!(target);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
|
||
/// (Export Selection, Save as Template).
|
||
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result<String, String> {
|
||
let (path, _) = run_to_file(app, id, ext, params)?;
|
||
app.status(format!("Saved {path}"));
|
||
Ok(path)
|
||
}
|
||
|
||
/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to
|
||
/// that path (else a picked or suggested name) → (path, the command's result without the data).
|
||
/// The command runs before a path is asked for, so bad params never open a save dialog, except
|
||
/// for Export Selection without a `format`, which takes it from the picked path.
|
||
pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> {
|
||
let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string));
|
||
if let Some(o) = params.as_object_mut()
|
||
&& o.get("format").is_none()
|
||
&& id == "document.exportSelection"
|
||
{
|
||
let picked = target_path(app, path, ext)?;
|
||
let e = Some(fileio::extension(&picked)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into());
|
||
o.insert("format".into(), serde_json::json!(e));
|
||
path = Some(picked);
|
||
}
|
||
let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||
// Binary output comes as base64, text (swatch libraries) as is; the result keeps the rest.
|
||
let o = v.as_object_mut().ok_or("no data")?;
|
||
let bytes = match (o.remove("dataBase64"), o.remove("data")) {
|
||
(Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64),
|
||
(_, Some(Value::String(text))) => Some(text.into_bytes()),
|
||
_ => None,
|
||
};
|
||
let bytes = bytes.ok_or("no data")?;
|
||
let path = match path {
|
||
Some(p) => p,
|
||
None => target_path(app, None, ext)?,
|
||
};
|
||
write_to(&mut app.services, &path, &bytes)?;
|
||
Ok((path, v))
|
||
}
|
||
|
||
/// File → Save as PDF: `document.exportPdf` with `params` (the Save PDF dialog's options), written
|
||
/// to `path` (else a picked or suggested name) → `{path, bytes, warnings}`. With
|
||
/// `viewAfterSaving`, the written file opens in the system viewer (not on the web, which
|
||
/// downloads it).
|
||
pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||
let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false);
|
||
let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?;
|
||
if view {
|
||
view_file(app, &path);
|
||
}
|
||
report_saved(app, &path, &v);
|
||
v["path"] = Value::String(path);
|
||
Ok(v)
|
||
}
|
||
|
||
/// `path` as an absolute `file://` URL for the system opener (bytes other than letters, digits and
|
||
/// `/-._~:` percent-encoded).
|
||
pub(crate) fn file_url(path: &str) -> String {
|
||
let abs = std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned());
|
||
let abs = abs.replace('\\', "/");
|
||
let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" });
|
||
for b in abs.bytes() {
|
||
if b.is_ascii_alphanumeric() || b"/-._~:".contains(&b) {
|
||
url.push(char::from(b));
|
||
} else {
|
||
url.push_str(&format!("%{b:02X}"));
|
||
}
|
||
}
|
||
url
|
||
}
|
||
|
||
/// File → Revert: ask first; OK runs `file.revert` with `confirmed`, which goes to the engine.
|
||
pub fn ask_revert(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||
let c = vectorcraft_engine::find_command("file.revert").ok_or("no revert command")?;
|
||
(c.enabled)(&app.session)?;
|
||
let name = app.session.active().map(|d| d.title()).unwrap_or_default();
|
||
let message = crate::i18n::fmt(tl!("Revert to the saved version of “{name}”?"), &[("name", &name)]);
|
||
dialogs::confirm::ask(app, &message, tl!("Changes made since it was last saved will be lost."), "file.revert", json!({ "confirmed": true }));
|
||
Ok(json!({ "pending": dialogs::confirm::KIND }))
|
||
}
|
||
|
||
/// File → Show in Folder: the document's file in the system file manager.
|
||
pub fn reveal(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||
let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?;
|
||
reveal_path(app, &path)?;
|
||
Ok(json!({ "path": path }))
|
||
}
|
||
|
||
/// Show the file at `path` in the system file manager (desktop).
|
||
pub(crate) fn reveal_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
|
||
app.services.reveal.as_mut().ok_or("no file manager here")?(path)
|
||
}
|
||
|
||
/// Open `path` (a file or a folder) in the system's default app for it (desktop).
|
||
pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
|
||
app.services.open_file.as_mut().ok_or("no app to open files here")?(path)
|
||
}
|
||
|
||
/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`;
|
||
/// without one the web downloads them (the ZIP, or each file) and the other frontends get them
|
||
/// back as base64. With `openLocation`, the file manager shows the first file written.
|
||
pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||
export_files(app, "document.exportForScreens", params)
|
||
}
|
||
|
||
/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`).
|
||
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)?;
|
||
let count = r["files"].as_array().map_or(0, Vec::len);
|
||
// Files that came back as bytes: (name, base64).
|
||
let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() {
|
||
Some(zip) => vec![(r["name"].as_str().unwrap_or("Export.zip"), zip)],
|
||
None => {
|
||
r["files"].as_array().into_iter().flatten().filter_map(|f| Some((f.get("name")?.as_str()?, f.get("dataBase64")?.as_str()?))).collect()
|
||
}
|
||
};
|
||
if returned.is_empty() {
|
||
let first = r["path"].as_str().or_else(|| r["files"][0].as_str());
|
||
let mut status = format!("Exported {count} file(s) to {folder}");
|
||
if open_location
|
||
&& let Some(first) = first
|
||
&& let Err(e) = reveal_path(app, first)
|
||
{
|
||
status = format!("{status} ({e})");
|
||
}
|
||
app.status(status);
|
||
} else if let Some(download) = app.services.download.as_mut() {
|
||
for (name, data) in &returned {
|
||
let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?;
|
||
download(name, &bytes);
|
||
}
|
||
let what = match returned.as_slice() {
|
||
[(name, _)] => (*name).to_string(),
|
||
files => format!("{} files", files.len()),
|
||
};
|
||
app.status(format!("Downloaded {what}"));
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Place a file's bytes (no path, so embedded) centred in the view: `file.place`.
|
||
pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||
crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_engine::Session;
|
||
|
||
use super::*;
|
||
use crate::Services;
|
||
use vectorcraft_doc::NodeKind;
|
||
|
||
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||
|
||
/// An app whose writer records what it is given.
|
||
fn app() -> (VectorcraftApp, Written) {
|
||
let written = Written::default();
|
||
let w = written.clone();
|
||
let services = Services {
|
||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||
Ok(())
|
||
})),
|
||
..Default::default()
|
||
};
|
||
(VectorcraftApp::new(Session::new(), services), written)
|
||
}
|
||
|
||
fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
|
||
let v = app.session.execute(cmd, &p).unwrap();
|
||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||
}
|
||
|
||
fn image_size(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> (u32, u32) {
|
||
match &app.session.doc().unwrap().doc.node(id).unwrap().kind {
|
||
NodeKind::Image(im) => (im.width, im.height),
|
||
other => panic!("{other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn webp_opens_and_places_at_its_pixel_size() {
|
||
let (mut app, _) = app();
|
||
app.session.execute("file.new", &json!({"width": 3, "height": 2})).unwrap();
|
||
let webp = bytes_of(&mut app, "document.serialize", json!({"format": "webp"}));
|
||
open_bytes(&mut app, "tiny.webp", &webp, None).unwrap();
|
||
assert_eq!(app.session.documents().len(), 2);
|
||
assert_eq!(app.views.len(), 2, "views follow the documents");
|
||
let first = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
|
||
assert_eq!(image_size(&app, first), (3, 2));
|
||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||
place_bytes(&mut app, "tiny.webp", &webp).unwrap();
|
||
let placed = app.session.doc().unwrap().selection.objects[0];
|
||
assert_eq!(image_size(&app, placed), (3, 2));
|
||
assert!(place_bytes(&mut app, "x.xyz", b"hello").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn templates_open_untitled() {
|
||
let (mut app, _) = app();
|
||
app.session.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
|
||
let pdf = bytes_of(&mut app, "document.serialize", json!({"format": "pdf"}));
|
||
open_bytes(&mut app, "brochure.ait", &pdf, Some("/tmp/brochure.ait".into())).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.title().starts_with("Untitled-"), "{}", st.title());
|
||
assert_eq!(st.path, None);
|
||
}
|
||
|
||
#[test]
|
||
fn export_uses_the_engine_options_and_keeps_the_path() {
|
||
let (mut app, written) = app();
|
||
app.session.execute("file.new", &json!({"width": 100, "height": 50, "artboards": 2})).unwrap();
|
||
app.session.execute("artboard.setProps", &json!({"index": 1, "width": 40})).unwrap();
|
||
app.session.doc_mut().unwrap().path = Some("/tmp/doc.vectorcraft".into());
|
||
export(&mut app, None, Some("/tmp/b.png".into()), &json!({"artboard": 1, "scale": 2})).unwrap();
|
||
export(&mut app, None, Some("/tmp/copy.vectorcraft".into()), &Value::Null).unwrap();
|
||
let w = written.borrow();
|
||
assert_eq!(&w[0].1[16..20], 80u32.to_be_bytes(), "artboard 1 (40 pt) at scale 2");
|
||
assert!(vectorcraft_format::sniff(&w[1].1));
|
||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft"));
|
||
drop(w);
|
||
assert!(export(&mut app, None, Some("/tmp/x.dwg".into()), &Value::Null).is_err(), "DWG can't be written");
|
||
}
|
||
|
||
#[test]
|
||
fn export_for_screens_with_params_runs_the_engine() {
|
||
let (mut app, _) = app();
|
||
app.session.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
|
||
let r = app.run("file.exportForScreens", json!({"range": "2-3", "formats": [{"format": "pdf"}]})).unwrap();
|
||
let files = r["files"].as_array().unwrap();
|
||
assert_eq!(files.len(), 2);
|
||
assert_eq!(files[0]["name"], "Artboard-2.pdf");
|
||
assert!(app.ui.dialog.is_none());
|
||
app.run("file.exportForScreens", Value::Null).unwrap();
|
||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("exportForScreens"));
|
||
}
|
||
|
||
#[test]
|
||
fn export_selection_takes_its_format_from_the_picked_path() {
|
||
let (mut app, written) = app();
|
||
app.session.execute("file.new", &json!({"width": 60, "height": 40})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
|
||
app.services.pick_save = Some(Box::new(|_: &FilePick| Some("/tmp/sel.svg".into())));
|
||
assert_eq!(app.run("document.exportSelection", json!({})).unwrap()["path"], "/tmp/sel.svg");
|
||
app.run("document.exportSelection", json!({"path": "/tmp/sel.png"})).unwrap();
|
||
let w = written.borrow();
|
||
assert!(String::from_utf8_lossy(&w[0].1).contains("<svg"), "SVG from the picked name");
|
||
assert_eq!(&w[1].1[1..4], b"PNG", "PNG from the given path");
|
||
}
|
||
|
||
#[test]
|
||
fn written_files_open_as_file_urls() {
|
||
let url = super::file_url("/tmp/My Art #1.pdf");
|
||
assert!(url.starts_with("file:///") && url.ends_with("/tmp/My%20Art%20%231.pdf"), "{url}");
|
||
assert!(!url.contains('\\'), "{url}");
|
||
}
|
||
|
||
#[test]
|
||
fn control_export_without_path_returns_bytes() {
|
||
let (mut app, written) = app();
|
||
app.session.execute("file.new", &json!({"width": 30, "height": 20})).unwrap();
|
||
let (req, _) = crate::control::ControlRequest::new("app.export", json!({"format": "png", "scale": 2}));
|
||
let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") };
|
||
assert_eq!(r["ok"], true, "{r}");
|
||
let png = vectorcraft_format::base64_decode(r["result"]["dataBase64"].as_str().unwrap()).unwrap();
|
||
assert_eq!(&png[16..20], 60u32.to_be_bytes(), "30 pt at scale 2");
|
||
assert!(written.borrow().is_empty(), "no file and no save dialog");
|
||
}
|
||
}
|