From 515ce2ac94782b45ac1ee1edf96cee94a23d049a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:18:06 +0700 Subject: [PATCH] M4.14: Save As format chooser, Save writes back its own format, Save a Copy Save As offers VectorCraft, VectorCraft Template, PDF, SVG and SVGZ and asks for the chosen format's options before writing: SVG Options for SVG/SVGZ, the Save PDF dialog for PDF (its OK finishes the save); on the web a Save As dialog first names the file and picks the format. A document opened from or saved as SVG/PDF remembers its format and options in one place (DocState.format, save_options: the SVG options, or every Save PDF setting), so Save writes it again; the result's warnings name what the format loses, then the encoder's notes. Save a Copy writes " copy." and leaves the path, title and modified state alone. Save is off for a clean saved document, and Save as PDF... is in the File menu. Engine: document.save, file.saveAs and file.saveCopy take {path?, format?, options?, svg?} (SVG options also flat) and write linked images beside an SVG; file.formatOptions reads a format's options. save_plan/save_with are the one save path of every frontend, on the shared SVG encoder (one SVGZ path). The template format is writable. Save panels get per-format filters, a folder and a suggested name. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/main.rs | 28 +- crates/engine/src/cmd/fileio/encode.rs | 6 + crates/engine/src/cmd/fileio/export.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 35 +- crates/engine/src/cmd/fileio/mod.rs | 49 +-- crates/engine/src/cmd/fileio/pdf.rs | 6 + crates/engine/src/cmd/fileio/pdfimport.rs | 6 + crates/engine/src/cmd/fileio/save.rs | 392 ++++++++++++++---- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 5 +- crates/engine/src/cmd/fileio/tests_svg.rs | 2 +- crates/engine/src/cmd/place/mod.rs | 5 +- crates/engine/src/lib.rs | 14 +- crates/engine/src/tests_save.rs | 169 ++++++++ crates/mcp/src/headless.rs | 8 +- crates/mcp/src/tests_fileio.rs | 2 +- .../ui-egui/src/dialogs/flattener_presets.rs | 7 +- crates/ui-egui/src/dialogs/form.rs | 7 +- crates/ui-egui/src/dialogs/missing_links.rs | 5 +- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/pdf_presets.rs | 5 +- crates/ui-egui/src/dialogs/save_options.rs | 334 +++++++++++++++ crates/ui-egui/src/dialogs/save_pdf.rs | 16 +- crates/ui-egui/src/dialogs/svg_options.rs | 9 +- crates/ui-egui/src/dialogs/tests_export.rs | 6 +- crates/ui-egui/src/io.rs | 273 ++++++++---- crates/ui-egui/src/lib.rs | 24 +- crates/ui-egui/src/menus.rs | 35 +- crates/ui-egui/src/panels/library_panel.rs | 9 +- crates/ui-egui/src/panels/separations.rs | 6 +- .../src/panels/tests_style_libraries.rs | 2 +- crates/ui-egui/src/place.rs | 2 +- crates/ui-egui/src/shortcut_editor.rs | 4 +- crates/ui-egui/src/tests_place.rs | 2 +- crates/ui-egui/src/tests_svg.rs | 2 +- crates/ui-egui/src/unsaved.rs | 6 +- docs/control-protocol.md | 1 + docs/mcp.md | 9 +- 38 files changed, 1202 insertions(+), 295 deletions(-) create mode 100644 crates/engine/src/tests_save.rs create mode 100644 crates/ui-egui/src/dialogs/save_options.rs diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 928885f2f..d512d1644 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -13,7 +13,7 @@ mod native_menu; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; -use vectorcraft_ui_egui::{Services, VectorcraftApp}; +use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp}; struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option); @@ -97,14 +97,19 @@ fn save_prefs(app: &VectorcraftApp) { } } +/// A native file dialog showing `pick`'s file types, folder and suggested name. +fn file_dialog(pick: &FilePick) -> rfd::FileDialog { + let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts)); + let d = match &pick.folder { + Some(folder) => d.set_directory(folder), + None => d, + }; + if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) } +} + fn services() -> Services { Services { - pick_open: Some(Box::new(|| { - fileio::open_filters() - .fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(name, exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })), + pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))), pick_open_multi: Some(Box::new(|| { fileio::place_filters() .fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)) @@ -114,14 +119,7 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) .collect() })), - // Saving a document offers every format Save writes (.vectorcraft, .ai, .svg, .svgz). - pick_save: Some(Box::new(|name: &str| { - fileio::save_filters(name) - .into_iter() - .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, exts)| d.add_filter(label, exts)) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })), + pick_save: Some(Box::new(|pick: &FilePick| file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 98645c9fb..74b323754 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -248,6 +248,12 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { } let bytes = match f.id { "vectorcraft" => vectorcraft_format::save_file(doc), + // A native file flagged so opening it starts a new untitled document. + "template" => { + let mut d = doc.clone(); + d.template = true; + vectorcraft_format::save_file(&d) + } "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index dae097018..e16e97711 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -100,7 +100,7 @@ fn screen_format(row: &Value) -> Result { return Err(bad(C, "each format is an object {format, scale?, suffix?}")); } let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; - if format.id == "vectorcraft" { + if matches!(format.id, "vectorcraft" | "template") { return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf")); } // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 612aacf3f..ca9ed0547 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -8,7 +8,7 @@ use vectorcraft_geom::Affine; use super::super::*; use super::pdfimport::LoadOptions; -use super::{Format, format, format_for_name, read_file}; +use super::{Format, SAVE_FORMATS, format, format_for_name, read_file}; use crate::EngineError; /// A file read into a document, with its format and non-fatal import notes. @@ -46,7 +46,8 @@ pub fn file_name(name: &str) -> String { pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { let by_name = format_for_name(name).filter(|f| f.read); if vectorcraft_format::sniff(bytes) { - return format("vectorcraft"); + // A template keeps its meaning from the extension, like .ait. + return by_name.filter(|f| f.id == "template").or_else(|| format("vectorcraft")); } if vectorcraft_svg::is_svgz(bytes) { return format("svgz"); @@ -113,7 +114,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; let title = file_name(name); let (mut doc, warnings, restored) = match format.id { - "vectorcraft" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false), + "vectorcraft" | "template" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false), "svg" | "svgz" => { let text = vectorcraft_svg::text_of(bytes).map_err(err)?; let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); @@ -132,17 +133,17 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). - if format.id != "vectorcraft" || doc.title.is_empty() { + if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { doc.title = title; } Ok(Loaded { doc, format, warnings, restored }) } /// Open a file's bytes as the new active document (what `document.open` does) → -/// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. `path` -/// is kept for Save only for a non-template native file or a `.ai` file whose native document came -/// back (Save writes both again); the document's linked files are looked for from it -/// ([`crate::cmd::links::resolve`]). +/// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. The +/// document keeps `path` (and its format, for Save) when Save can write that format back: not a +/// template, a partly read PDF, or a `.ai` file whose native document didn't come back. The +/// document's linked files are looked for from `path` ([`crate::cmd::links::resolve`]). pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option) -> Result { open_bytes_with(s, name, bytes, path, &Value::Null) } @@ -152,14 +153,24 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option Vec { always, load::open ), - cmd!( - "document.save", - "Save Document", - [], - None, - "{path?, format?: vectorcraft|ai|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}, …document.exportPdf options (.ai)} (default path: the document's) → {path, linked?, warnings?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with, and keeps hidden layers (display:none) unless hiddenLayers is false. A .ai save is a PDF-compatible file: every artboard as a PDF page with the native document embedded (preserveEditing is always on), so document.open restores it exactly. A never-saved document without path → {dataBase64} (stays modified)", - has_doc, - save::save - ), cmd!( query "document.serialize", "Serialize Document", [], None, - "{format?: vectorcraft (default)|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -71,7 +63,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -84,15 +76,6 @@ pub fn specs() -> Vec { has_selection, export::export_selection ), - cmd!( - "file.saveAsTemplate", - "Save as Template…", - ["File"], - None, - "{path?} a native copy that opens as a new untitled document (no path → {dataBase64})", - has_doc, - save::save_template - ), cmd!( "document.exportForScreens", "Export for Screens", @@ -122,6 +105,9 @@ pub fn specs() -> Vec { pdfimport::pdf_info ), ] + .into_iter() + .chain(save::specs()) + .collect() } /// One option a format's encoder reads from the export params. @@ -287,18 +273,28 @@ pub const FORMATS: &[Format] = &[ }, reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true), reader("bmp", "BMP", &["bmp"], "image/bmp", true), + Format { + id: "template", + label: "VectorCraft Template", + extensions: &["vctemplate"], + mime: "application/json", + read: true, + write: true, + raster: false, + options: &[], + }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). pub const OPEN_EXTS: &[&str] = - &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; + &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate"]; /// Text files: File → Place sets them as area type (Text Import Options). pub const TEXT_EXTS: &[&str] = &["txt"]; /// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`]. pub const PLACE_EXTS: &[&str] = - &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "txt"]; + &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "txt"]; /// One dialog filter per readable format. fn format_filters() -> impl Iterator { @@ -331,6 +327,11 @@ pub fn extension(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } +/// A file name or path without its folder and extension (`/a/Poster.svg` → `Poster`). +pub fn file_stem(name: &str) -> String { + std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()) +} + /// The format a file name's extension names. pub fn format_for_name(name: &str) -> Option<&'static Format> { Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e)) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index a13779b02..ea8f6a5ae 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -241,3 +241,9 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result { let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; Ok(json!({ "settings": v, "presets": presets(s), "changed": diff, "warnings": warnings })) } + +/// Is `key` a top-level PDF setting (a [`PdfSettings`] field as `document.exportPdf` names it)? +pub fn is_setting(key: &str) -> bool { + static DEFAULTS: std::sync::LazyLock = std::sync::LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default()); + DEFAULTS.get(key).is_some() +} diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 3023bd877..5c600ef92 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -54,6 +54,12 @@ impl LoadOptions { Ok(Self { pages, crop, password, color_mode }) } + /// Does the document read only part of the file, or read it differently (a page range, another + /// box, a password)? Writing it back would lose the rest, so Save doesn't (it asks for a name). + pub fn is_partial(&self) -> bool { + self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() + } + /// The PDF import options: `pages` resolved against the file's page count. fn import_options(&self, bytes: &[u8]) -> Result { let pages = match &self.pages { diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 6b6d3c708..d32885012 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1,68 +1,85 @@ -//! Saves: `document.save` (native, SVG or PDF-compatible .ai) and `file.saveAsTemplate`. +//! Saving: `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate` and +//! `file.formatOptions`. +//! +//! Every frontend saves the same way: [`save_plan`] resolves where and how (path, format, options, +//! a suggested name and folder when there is no path), then [`save_with`] encodes, writes through +//! the caller's writer (the file system here; a save panel or a browser download in the apps) and +//! makes the file the document's own (path, format, options, saved state) for Save and Save As. -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; +use vectorcraft_doc::Document; use super::super::*; -use super::{Encoded, Format, default_name, encode_all, format_for_name, writable_format, write_encoded, write_or_return}; +use super::{Encoded, Format, encode_all, file_stem, format, format_for_name, write_encoded, write_file}; use crate::DocState; -/// The formats Save writes, in the order save dialogs offer them; other formats are exports. -pub const SAVE_FORMATS: [&str; 4] = ["vectorcraft", "ai", "svg", "svgz"]; +/// The formats Save As offers, in menu order (append-only). The other writable formats are exports: +/// they never become the document's own file. +pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]; -/// The format Save writes: `format` (an id or extension), else the format the path's extension -/// names, else native (see [`SAVE_FORMATS`]). A `.ai` file is a PDF carrying the native document. -pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { - let f = match (format, path.and_then(format_for_name)) { - (Some(id), _) => super::format(id).filter(|f| SAVE_FORMATS.contains(&f.id)).map_or_else(|| writable_format(format, None), Ok)?, - (None, Some(f)) => f, - (None, None) => super::format("vectorcraft").ok_or("no native format")?, - }; - if SAVE_FORMATS.contains(&f.id) { - Ok(f) - } else { - Err(format!("Save writes .{}, .ai, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label)) - } +/// Save-panel filters `(label, [extension])` for `first` (a format id, or a file name whose +/// extension names one): when Save writes that format, one per [`SAVE_FORMATS`] entry with it +/// leading (the panel's default type), else none (an export picks its own). Only the extension a +/// save writes (never the former native name). +pub fn save_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> { + let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] }; + let mut v: Vec<&'static Format> = SAVE_FORMATS.iter().filter_map(|id| format(id)).collect(); + v.sort_by_key(|f| f.id != first.id); + v.into_iter().map(|f| (f.label, &f.extensions[..1])).collect() } -/// Save dialog filters for a file named `name`: when Save writes its format, every format Save -/// writes (that one first), else none. -pub fn save_filters(name: &str) -> Vec<(&'static str, &'static [&'static str])> { - let Some(first) = format_for_name(name).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] }; - let others = SAVE_FORMATS.iter().filter_map(|id| super::format(id)).filter(|f| f.id != first.id); - std::iter::once(first).chain(others).map(|f| (f.label, f.extensions)).collect() +/// How a save treats the document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveMode { + /// File → Save: the document's own path and format. + Save, + /// File → Save As: a new path or format, which the document takes on. + SaveAs, + /// File → Save a Copy: the document keeps its path, title and modified state. + Copy, + /// File → Save as Template: a template copy; the document keeps its path and state too. + Template, } -/// What Save writes for `st` in format `f` (see [`save_format`]) to `path` → the encoded file and -/// the options to remember for the next Save. An SVG save takes the SVG options in `p`, else the -/// ones the document was last saved with, and keeps hidden layers (hidden) unless they say -/// otherwise. A native file (and the document a `.ai` file carries) records its links' paths -/// relative to `path`. -pub fn save_encoding(st: &DocState, f: &Format, p: &Value, path: Option<&str>) -> Result<(Encoded, Value)> { - const C: &str = "document.save"; - let relative = path.filter(|_| matches!(f.id, "vectorcraft" | "ai")).and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path)); - let doc = relative.as_ref().unwrap_or(&st.doc); - match f.id { - "vectorcraft" => return Ok((encode_all(doc, f.id, p)?, Value::Null)), - // A PDF of every artboard with the PDF options in `p`, always carrying the native document. - "ai" => { - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - if let Some(o) = q.as_object_mut() { - o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); - } - let (bytes, warnings) = super::pdf::encode(C, &doc.without_edit_modes(), &q)?; - return Ok((Encoded { warnings, ..Encoded::one(bytes) }, Value::Null)); +impl SaveMode { + /// The engine command of this mode. + pub fn command(self) -> &'static str { + match self { + Self::Save => "document.save", + Self::SaveAs => "file.saveAs", + Self::Copy => "file.saveCopy", + Self::Template => "file.saveAsTemplate", } - _ => {} } - let given = super::svg_options(p).map_err(|e| bad(C, e))?; - let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) }; - let mut svg = opts.as_object().cloned().unwrap_or_default(); - svg.entry("hiddenLayers").or_insert(Value::Bool(true)); - let enc = encode_all(&st.doc, f.id, &json!({ "svg": svg }))?; - if enc.files.len() != 1 { - return Err(bad(C, "Save writes one artboard: name one, or export several with document.export")); + + /// The mode an engine command id runs. + pub fn of(command: &str) -> Option { + [Self::Save, Self::SaveAs, Self::Copy, Self::Template].into_iter().find(|m| m.command() == command) + } +} + +/// A save resolved against the active document, before anything is encoded or written. +#[derive(Clone, Debug)] +pub struct SavePlan { + pub mode: SaveMode, + /// Where to write. `None` when no path is known (never saved, or a format other than the + /// document's own): the caller asks for one or takes the bytes. + pub path: Option, + pub format: &'static Format, + /// The format's options (only those its encoder reads). + pub options: Map, + /// Suggested file name: `.` or ` copy.`. + pub name: String, + /// Suggested folder: the document's own. + pub folder: Option, +} + +impl SavePlan { + /// Does writing this make the file the document's own (Save and Save As, except to a + /// template)? + pub fn retargets(&self) -> bool { + matches!(self.mode, SaveMode::Save | SaveMode::SaveAs) && self.format.id != "template" } - Ok((enc, opts)) } /// File Info's dates for a save to a file: the modified date (and the created date, when it has @@ -74,38 +91,245 @@ pub fn stamp_save_dates(st: &mut DocState) { d.metadata.modified = Some(now); } -pub(super) fn save(s: &mut Session, p: &Value) -> Result { - const C: &str = "document.save"; - let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone())); - let f = save_format(str_param(p, "format"), path.as_deref()).map_err(|e| bad(C, e))?; - if path.is_some() { - stamp_save_dates(s.doc_mut()?); - } - let expanded = super::pdf::expand_preset(s, C, p)?; - let p = &*expanded; - let st = s.doc()?; - let (enc, opts) = save_encoding(st, f, p, path.as_deref())?; - // The encoder's notes (a .ai file's PDF options not applied yet…). - let notes = if enc.warnings.is_empty() { json!({}) } else { json!({ "warnings": enc.warnings }) }; - let Some(path) = path else { - // Nowhere to save to (web, agents): hand the bytes back; the document stays modified. - return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, notes); - }; - let out = write_encoded(Some(&path), &path, &st.doc, &enc, json!({}))?; - let st = s.doc_mut()?; - st.path = Some(path.clone()); - st.save_options = opts; - st.mark_saved(); - let mut r = super::merge(json!({ "path": path }), notes); - if let Some(linked) = out.get("linked") { - r["linked"] = linked.clone(); - } - Ok(r) +/// The folder of a path (`None` for a bare file name). +fn parent_folder(path: &str) -> Option { + std::path::Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) } -/// File → Save as Template: a native copy flagged so opening it starts a new untitled document. -pub(super) fn save_template(s: &mut Session, p: &Value) -> Result { - let mut d = (*s.doc()?.doc).clone(); - d.template = true; - write_or_return(str_param(p, "path"), &vectorcraft_format::save_file(&d), json!({})) +/// A save format by id or extension, or why it isn't one. +fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> { + match format(f) { + Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f), + Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)), + None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))), + } +} + +/// The format Save writes for `format` (an id or extension), else the format `path`'s extension +/// names, else native (see [`SAVE_FORMATS`]); other formats are exports. +pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { + save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft")) +} + +/// [`save_format_of`] for command `cmd`. +fn checked(cmd: &str, f: &str) -> Result<&'static Format> { + save_format_of(f).map_err(|e| bad(cmd, e)) +} + +/// Resolve a save of the active document from `{path?, format?, options?}` (see the commands' +/// params docs). +pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { + let cmd = mode.command(); + let st = s.doc()?; + let path = str_param(p, "path").map(str::to_string); + let format = match (mode, str_param(p, "format"), path.as_deref().and_then(format_for_name)) { + (SaveMode::Template, ..) => checked(cmd, "template")?, + (_, Some(f), _) => checked(cmd, f)?, + (_, None, Some(f)) => checked(cmd, f.id)?, + (_, None, None) => checked(cmd, st.format)?, + }; + let mut given: Map = match p.get("options") { + Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(), + None | Some(Value::Null) => Map::new(), + Some(_) => return Err(bad(cmd, "options must be an object (see file.formatOptions)")), + }; + if is_svg(format) { + // SVG options also come flat or as `svg: {…}` beside the path; kept flat. + let mut svg = super::svg_options(&Value::Object(given)).map_err(|e| bad(cmd, e))?; + svg.extend(super::svg_options(p).map_err(|e| bad(cmd, e))?); + given = svg; + } else if is_pdf(format) { + // So do PDF options (as the Save PDF dialog and document.exportPdf name them). + if let Some(o) = p.as_object() { + given.extend( + o.iter().filter(|(k, v)| !v.is_null() && k.as_str() != "path" && reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())), + ); + } + } + // None given: the ones the document was last saved with in this format. + let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given }; + // Save writes the document's own file; the other modes only where they are told to. + let path = path.or_else(|| (mode == SaveMode::Save && format.id == st.format).then(|| st.path.clone()).flatten()); + let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title)); + let ext = format.extensions[0]; + let name = match mode { + SaveMode::Copy => format!("{stem} copy.{ext}"), + SaveMode::Save | SaveMode::SaveAs | SaveMode::Template => format!("{stem}.{ext}"), + }; + let folder = st.path.as_deref().and_then(parent_folder); + Ok(SavePlan { mode, path, format, options, name, folder }) +} + +/// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General +/// settings (`createLayers`…) and presets. +fn reads_option(f: &Format, key: &str) -> bool { + match is_pdf(f) { + true => super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key), + false => f.options.iter().any(|o| o.name == key), + } +} + +/// Formats that carry the whole native document: they lose nothing, and record links relative to +/// where they are written. A `.ai` file is a PDF carrying it. +fn is_native(f: &Format) -> bool { + matches!(f.id, "vectorcraft" | "template" | "ai") +} + +fn is_pdf(f: &Format) -> bool { + matches!(f.id, "pdf" | "ai") +} + +fn is_svg(f: &Format) -> bool { + matches!(f.id, "svg" | "svgz") +} + +/// What a format loses against a native file (reported whenever a save writes it). +fn fidelity_warning(f: &Format) -> Option { + (!is_native(f)).then(|| { + format!( + "{} keeps the artwork but not everything a VectorCraft document holds (editable effects, symbols, swatches, styles): save as VectorCraft to keep it all editable", + f.label + ) + }) +} + +/// Encode `plan` and write it with `write` (the file, then the images an SVG links to, beside it). +/// Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?: [{name, +/// dataBase64}]}` and the document is unchanged; with one → `{path, format, bytes, warnings, +/// linked?: [path…]}`, and Save or Save As (except to a template) make the file the document's +/// own: path, title, format, options, saved state. +pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { + let cmd = plan.mode.command(); + let retargets = plan.retargets(); + if plan.path.is_some() && retargets { + stamp_save_dates(s.doc_mut()?); + } + let own: &Document = &s.doc()?.doc; + // A native file records its links' paths relative to where it is written. + let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(own, p)); + let doc = relative.as_ref().unwrap_or(own); + let mut params = plan.options.clone(); + if is_svg(plan.format) { + // A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out. + params.entry("hiddenLayers").or_insert(Value::Bool(true)); + } + let params = Value::Object(params); + let params = super::pdf::expand_preset(s, cmd, ¶ms)?; + let mut enc = if plan.format.id == "ai" { + // A PDF of every artboard with the PDF options, always carrying the native document. + let mut q = params.into_owned(); + if let Some(o) = q.as_object_mut() { + o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); + } + let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &q)?; + Encoded { warnings, ..Encoded::one(bytes) } + } else { + encode_all(doc, plan.format.id, ¶ms)? + }; + if enc.files.len() != 1 { + return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); + } + // What the format loses first, then the encoder's own notes (PDF options not applied yet…). + let mut warnings = std::mem::take(&mut enc.warnings); + warnings.splice(0..0, fidelity_warning(plan.format)); + let extra = json!({ "format": plan.format.id, "warnings": warnings }); + let Some(path) = plan.path else { + let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?; + out["name"] = json!(plan.name); + if let Some(folder) = plan.folder { + out["folder"] = json!(folder); + } + return Ok(out); + }; + let files = enc.named(doc, &path); + for (p, bytes) in &files { + write(p, bytes)?; + } + let bytes = files.first().map_or(0, |f| f.1.len()); + let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra); + if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) { + out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect(); + } + if retargets { + let st = s.doc_mut()?; + st.path = Some(path.clone()); + st.format = plan.format.id; + st.save_options = plan.options; + st.mark_saved(); + } + Ok(out) +} + +/// A save command: plan, then write with the file system. +fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result { + let plan = save_plan(s, mode, p)?; + save_with(s, plan, write_file) +} + +/// `file.formatOptions`: a writable format's options with the values Save would use. +fn format_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.formatOptions"; + let st = s.active(); + let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft"); + let f = format(id).filter(|f| f.write).ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; + let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options); + let mut v = f.to_json(); + if let Some(options) = v["options"].as_object_mut() { + for (name, o) in options.iter_mut() { + o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(&o["default"]).clone(); + } + } + v["saveFormats"] = + SAVE_FORMATS.iter().filter_map(|id| format(id)).map(|f| json!({"id": f.id, "label": f.label, "extensions": f.extensions})).collect(); + Ok(v) +} + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "document.save", + "Save Document", + [], + None, + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + has_doc, + |s, p| save(s, SaveMode::Save, p) + ), + cmd!( + "file.saveAs", + "Save As…", + ["File"], + Some("Cmd+Shift+S"), + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", + has_doc, + |s, p| save(s, SaveMode::SaveAs, p) + ), + cmd!( + "file.saveCopy", + "Save a Copy…", + ["File"], + Some("Cmd+Alt+S"), + "{path?, format?, options?} (as file.saveAs) write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \" copy.\", folder?, warnings}", + has_doc, + |s, p| save(s, SaveMode::Copy, p) + ), + cmd!( + "file.saveAsTemplate", + "Save as Template…", + ["File"], + None, + "{path?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \".vctemplate\", folder?, warnings}", + has_doc, + |s, p| save(s, SaveMode::Template, p) + ), + cmd!( + query "file.formatOptions", + "Format Options", + [], + None, + "{format?: a writable format id (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default", + always, + format_options + ), + ] } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 07da7a6ee..32cafb001 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() { let mut s = Session::new(); let r = s.execute("document.formats", &json!({})).unwrap(); let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); - assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"]); + assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); assert_eq!(png["options"]["scale"]["default"], 1); diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 1d5fba704..c2d0d2f39 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -106,8 +106,9 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() { s.execute("document.save", &json!({})).unwrap(); let again = vectorcraft_pdf::editing(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(vectorcraft_format::load(&again.data).unwrap().layers, s.doc().unwrap().doc.layers); - // PDF stays an export. - assert!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).is_err(), "PDF is written with document.exportPdf"); + // Save As also writes a plain PDF (the document then saves back as PDF); PNG stays an export. + assert_eq!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).unwrap()["format"], "pdf"); + assert!(s.execute("document.save", &json!({"path": tmp("x.png")})).is_err(), "PNG is written with document.export"); // A plain PDF named .ai opens as artwork, without a path to save over. let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap()); let plain_path = tmp("plain.ai"); diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 890d8e914..d52ab20eb 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -165,7 +165,7 @@ fn save_writes_svg_and_remembers_its_options() { let native = dir.join("doc.vectorcraft").to_string_lossy().to_string(); s.execute("document.save", &json!({"path": native})).unwrap(); assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap())); - assert_eq!(s.doc().unwrap().save_options, Value::Null); + assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options"); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 52afd11d1..7878470d5 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -171,12 +171,13 @@ fn load(p: &Value, cmd: &str) -> Result { let index = if pdf { 0 } else { page - 1 }; let n = doc.artboards.len(); let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| { - let what = if format.id == "vectorcraft" { "artboard" } else { "page" }; + let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" }; bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)")) })?; // A PDF import has one layer per page; a native document's art is whatever lies on the // artboard. - let pages: Vec<&Arc> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(index).into_iter().collect() }; + let pages: Vec<&Arc> = + if matches!(format.id, "vectorcraft" | "template") { layers.iter().collect() } else { layers.get(index).into_iter().collect() }; let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)); (nodes, crop.then_some(board)) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d8d61e17b..73627d701 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -112,9 +112,12 @@ pub struct DocState { pub mask_view: Option, /// View → Show Transparency Grid, per document (view state: not saved, not undoable). pub transparency_grid: bool, - /// The SVG options this document was last saved with as SVG (Save reuses them; JSON as in - /// `document.save {svg}`, null when none). - pub save_options: Value, + /// The format Save writes ([`cmd::fileio::SAVE_FORMATS`]): the one the document was opened + /// from or last saved as. + pub format: &'static str, + /// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF; + /// empty for native files): Save reuses them and `file.formatOptions` reads them back. + pub save_options: serde_json::Map, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -138,7 +141,8 @@ impl DocState { uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), mask_view: None, transparency_grid: false, - save_options: Value::Null, + format: "vectorcraft", + save_options: Default::default(), } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). @@ -1014,6 +1018,8 @@ mod tests_recolor; #[cfg(test)] mod tests_registration; #[cfg(test)] +mod tests_save; +#[cfg(test)] mod tests_scalestrokes; #[cfg(test)] mod tests_strokegeom; diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs new file mode 100644 index 000000000..08236c66e --- /dev/null +++ b/crates/engine/src/tests_save.rs @@ -0,0 +1,169 @@ +//! The save pipeline: Save writes a document's own format, Save As, a Copy and as Template. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::*; +use crate::cmd::fileio::{SaveMode, save_plan}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s +} + +fn rect(s: &mut Session) { + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); +} + +/// A fresh folder for one test. +fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vc-save-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn path(d: &std::path::Path, name: &str) -> String { + d.join(name).to_string_lossy().to_string() +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +#[test] +fn an_svg_path_writes_svg_and_save_writes_it_back() { + let d = dir("svg"); + let mut s = session(); + rect(&mut s); + let svg = path(&d, "art.svg"); + let r = s.execute("document.save", &json!({"path": svg})).unwrap(); + assert_eq!(r["format"], "svg"); + assert!(r["warnings"][0].as_str().unwrap().contains("SVG keeps the artwork"), "{r}"); + assert!(std::fs::read_to_string(&svg).unwrap().starts_with(" = o["saveFormats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]); + // SVGZ is gzip-compressed SVG and reopens as SVGZ. + let svgz = path(&d, "doc.svgz"); + s.execute("file.saveAs", &json!({"path": svgz})).unwrap(); + assert_eq!(&std::fs::read(&svgz).unwrap()[..2], [0x1f, 0x8b]); + s.execute("document.open", &json!({"path": svgz})).unwrap(); + assert_eq!(s.doc().unwrap().format, "svgz"); + // Export formats aren't save formats. + let e = s.execute("file.saveAs", &json!({"path": path(&d, "x.png")})).unwrap_err().to_string(); + assert!(e.contains("document.export"), "{e}"); + assert!(s.execute("document.save", &json!({"format": "webp"})).is_err()); + assert!(s.execute("file.saveAs", &json!({"path": svgz, "options": 3})).is_err()); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn another_format_without_a_path_hands_the_bytes_back() { + let d = dir("otherfmt"); + let mut s = session(); + let native = path(&d, "a.vectorcraft"); + s.execute("document.save", &json!({"path": native})).unwrap(); + let r = s.execute("document.save", &json!({"format": "svg"})).unwrap(); + assert!(String::from_utf8(b64(&r)).unwrap().starts_with(" empty && !st.selection.is_empty(), "the template's art is placed and selected"); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index da5a02de6..6de4b57c6 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -29,9 +29,8 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[ ( "file.save", "Save", - "{path?, format?} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .svg or .svgz (default: the document's path)", + "{path?, format?, options?, svg?: {…SVG options}} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .pdf, .svg or .svgz (default: the document's own path and format)", ), - ("file.saveAs", "Save As…", "{path, format?, …the format's options} = document.save"), ("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"), ("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), @@ -106,7 +105,7 @@ impl Headless { fn exec(&mut self, id: &str, params: &Value) -> Result { match id { "file.open" => self.open(params), - "file.save" | "file.saveAs" => self.save(params), + "file.save" => self.save(params), "file.export" => self.export(params), "file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()), "tool.select" => self.select_tool(params), @@ -221,7 +220,8 @@ impl Headless { self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string()) } - /// `app.save {path?}`: native format; remembers the path and clears the dirty flag. + /// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and + /// format); remembers the path and clears the dirty flag. pub fn save(&mut self, p: &Value) -> Result { let st = self.session.active().ok_or("no document")?; if s(p, "path").is_none() && st.path.is_none() { diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 2fa6f4cf0..a0ecfa7f3 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() { let tools = tool_definitions(); let export = tools.iter().find(|t| t["name"] == "export").unwrap(); let props = &export["inputSchema"]["properties"]; - assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"])); + assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"])); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); } diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index e21bf2f75..7f26852bc 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; use vectorcraft_engine::cmd::FlattenOptions; -use vectorcraft_engine::cmd::flatten::PRESET_FORMAT; +use vectorcraft_engine::cmd::flatten::{PRESET_EXTS, PRESET_FORMAT}; use super::flatten::{options_editor, put_options}; use super::{DialogSpec, form}; @@ -180,7 +180,8 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> f(); return Ok(()); } - let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() }; + let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; let r = app.run("flattener.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { select(d, first); @@ -274,7 +275,7 @@ mod tests { let file = dir.join("mine.vcflattener").to_string_lossy().to_string(); let picked = file.clone(); let services = crate::Services { - pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 1bbac2921..8a20a3d41 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -12,9 +12,14 @@ const FIELD_W: f32 = 132.0; /// A labelled text field bound to `d.fields[key]` (one grid row). pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + field_w(ui, d, key, label, FIELD_W - 12.0); +} + +/// [`field`] `width` points wide. +pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(label).color(t.text_dim)); - text(ui, d, key, FIELD_W - 12.0); + text(ui, d, key, width); ui.end_row(); } diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 93c00d8c0..c564ab9d2 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -117,7 +117,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } else { let path = match d.str("path") { p if !p.is_empty() => p, - _ => app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?, + _ => { + let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; + app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")? + } }; out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?; // Apply to All: the others by name in the same folder; any not there are asked about. diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index f8cb41b4d..bcb16b886 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -39,6 +39,7 @@ pub mod raster_effects; pub mod recolor; pub mod saturate; mod save_changes; +pub(crate) mod save_options; mod save_pdf; pub mod save_style_library; pub mod save_swatch_library; @@ -201,6 +202,7 @@ registry! { TextImport: [text_import::KIND] => text_import::SPEC, PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC, PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC, + SaveOptions: [save_options::KIND] => save_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index 89ae5552d..1bb0b8ede 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -175,7 +175,8 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> f(); return Ok(()); } - let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() }; + let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; let r = app.run("pdf.preset.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { select(d, first); @@ -295,7 +296,7 @@ mod tests { let file = dir.join("mine.vcpdfpresets").to_string_lossy().to_string(); let picked = file.clone(); let services = crate::Services { - pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs new file mode 100644 index 000000000..5307ed73e --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -0,0 +1,334 @@ +//! A save format's options before the file is written; on the web, where there is no save panel, +//! also the Save As dialog that names the file and picks the format (SVG and PDF then go on to +//! their own dialogs). The rows come from the format's options in the engine's format table, so a +//! format gains rows as its encoder gains options. +//! +//! Fields: `__action` (the save command it finishes), `__pick` (file name and format +//! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`). + +use std::sync::OnceLock; + +use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::fileio::{self, Format, FormatOption, SAVE_FORMATS, SaveMode}; + +use super::{DialogResult, DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; + +/// Dialog kind. +pub const KIND: &str = "saveOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 380.0, ..DialogSpec::FORM }; + +fn heading(d: &Dialog) -> String { + if d.bool("__pick") { + return match SaveMode::of(&d.str("__action")) { + Some(SaveMode::Copy) => "Save a Copy", + Some(SaveMode::Template) => "Save as Template", + _ => "Save As", + } + .into(); + } + format!("{} Options", fileio::format(&d.str("format")).map_or("", |f| f.label)) +} + +/// `f`'s options with the values a save would use (`file.formatOptions`: as last saved in that +/// format, else the defaults). Text options start empty rather than null. +pub(crate) fn option_values(app: &mut VectorcraftApp, f: &Format) -> Map { + let v = app.session.execute("file.formatOptions", &json!({ "format": f.id })).unwrap_or_default(); + f.options + .iter() + .map(|o| { + let value = v["options"][o.name]["value"].clone(); + (o.name.to_string(), if o.ty == "string" && value.is_null() { json!("") } else { value }) + }) + .collect() +} + +/// Formats whose options have a dialog of their own (SVG Options, Save PDF), which follows this +/// one. +fn own_dialog(f: &Format) -> bool { + matches!(f.id, "svg" | "svgz" | "pdf") +} + +/// The options the dialog shows: arrays and objects are for agents, and a page range replaces the +/// single artboard choice; none for a format with its own dialog. +fn shown(f: &Format) -> impl Iterator { + let range = f.options.iter().any(|o| o.name == "range"); + let options = if own_dialog(f) { &[][..] } else { f.options }; + options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard"))) +} + +/// The Save As format menu (labels in [`SAVE_FORMATS`] order). +fn save_labels() -> &'static [&'static str] { + static LABELS: OnceLock> = OnceLock::new(); + LABELS.get_or_init(|| SAVE_FORMATS.iter().filter_map(|id| fileio::format(id)).map(|f| f.label).collect()) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let Some(f) = fileio::format(&d.str("format")) else { return false }; + let mut switch = None; + egui::Grid::new("save-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + if d.bool("__pick") { + form::field_w(ui, d, "path", "File Name:", 240.0); + if SaveMode::of(&d.str("__action")) != Some(SaveMode::Template) { + ui.label(egui::RichText::new("Format:").color(t.text_dim)); + switch = widgets::dropdown(ui, "save-format", f.label, save_labels(), 254.0).and_then(|i| SAVE_FORMATS.get(i).copied()); + ui.end_row(); + } + } + for o in shown(f) { + option_row(app, ui, d, o); + } + }); + if let Some(id) = switch.filter(|id| *id != f.id) { + switch_format(app, d, id); + } + false +} + +/// One option: a checkbox, an artboard menu, a number or a text field. +fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) { + let t = Tokens::get(ui.ctx()); + let label = form::humanize(o.name); + let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null); + if o.ty == "boolean" { + ui.label(""); + let mut on = value.as_bool().unwrap_or(false); + if ui.checkbox(&mut on, label.trim_end_matches(':')).on_hover_text(o.description).changed() { + d.fields.insert(o.name.into(), json!(on)); + } + ui.end_row(); + return; + } + if o.ty == "string" { + form::field_w(ui, d, o.name, &label, 240.0); + return; + } + ui.label(egui::RichText::new(&label).color(t.text_dim)).on_hover_text(o.description); + let artboards = app.session.active().filter(|_| o.name == "artboard").map(|st| &st.doc.artboards); + if let Some(boards) = artboards { + let i = value.as_u64().unwrap_or(0) as usize; + let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect(); + if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), names.get(i).copied().unwrap_or(""), &names, 254.0) { + d.fields.insert(o.name.into(), json!(i)); + } + } else { + let integer = o.ty == "integer"; + let mut x = value.as_f64().unwrap_or(0.0); + let drag = + egui::DragValue::new(&mut x).range(0.0..=f64::MAX).speed(if integer { 1.0 } else { 0.1 }).max_decimals(if integer { 0 } else { 2 }); + if ui.add(drag).on_hover_text(o.description).changed() { + d.fields.insert(o.name.into(), if integer { json!(x.round() as i64) } else { json!(x) }); + } + } + ui.end_row(); +} + +/// Web Save As: another format brings its own options and file extension. +fn switch_format(app: &mut VectorcraftApp, d: &mut Dialog, id: &str) { + let (Some(old), Some(new)) = (fileio::format(&d.str("format")), fileio::format(id)) else { return }; + for o in old.options { + d.fields.remove(o.name); + } + d.fields.extend(option_values(app, new)); + d.fields.insert("format".into(), json!(new.id)); + d.fields.insert("path".into(), json!(with_extension(&d.str("path"), new))); +} + +/// `path` ending in `f`'s extension (kept when it already names `f`). +fn with_extension(path: &str, f: &Format) -> String { + if fileio::format_for_name(path).is_some_and(|g| g.id == f.id) { + return path.to_string(); + } + std::path::Path::new(path).with_extension(f.extensions[0]).to_string_lossy().to_string() +} + +/// Write the file with the chosen options (blank ones fall back to the format's defaults). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { + let f = fileio::format(&d.str("format")).ok_or("unknown format")?; + let path = d.str("path"); + if path.trim().is_empty() { + return Err("name the file".into()); + } + let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path }; + let options: Map = f + .options + .iter() + .filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone()))) + .collect(); + app.ui.dialog = None; + match SaveMode::of(&d.str("__action")) { + Some(mode) if own_dialog(f) => io::ask_format_options(app, mode, f, &path), + Some(mode) => io::save(app, mode, &json!({ "path": path, "format": f.id, "options": options }), false), + None => Err(format!("`{}` is no save command", d.str("__action"))), + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use vectorcraft_engine::Session; + + use super::*; + use crate::{FilePick, Services, theme}; + + type Written = Rc)>>>; + + /// A desktop app whose save panel answers `picked` and records what it was shown. + fn desktop(picked: &'static str) -> (VectorcraftApp, Written, Rc>>) { + let (written, picks) = (Written::default(), Rc::new(RefCell::new(vec![]))); + let (w, p) = (written.clone(), picks.clone()); + let services = Services { + pick_save: Some(Box::new(move |f: &FilePick| { + p.borrow_mut().push(f.clone()); + Some(picked.to_string()) + })), + write: Some(Box::new(move |path: &str, b: &[u8]| { + w.borrow_mut().push((path.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + (with_art(services), written, picks) + } + + /// A browser app: saves are downloads. + fn web() -> (VectorcraftApp, Written) { + let written = Written::default(); + let w = written.clone(); + let services = Services { + download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))), + ..Default::default() + }; + (with_art(services), written) + } + + fn with_art(services: Services) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app + } + + /// One headless frame of the dialog layer; `enter` presses OK. + fn frame(app: &mut VectorcraftApp, enter: bool) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let events = if enter { + vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }] + } else { + vec![] + }; + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn dialog(app: &VectorcraftApp) -> &Dialog { + app.ui.dialog.as_ref().expect("a dialog is open") + } + + #[test] + fn save_as_svg_opens_svg_options_then_writes_svg() { + let (mut app, written, picks) = desktop("art.svg"); + let r = app.run("file.saveAs", json!({})).unwrap(); + assert_eq!((r["pending"].as_str(), r["path"].as_str()), (Some("svgOptions"), Some("art.svg"))); + // The save panel offers every save format, the document's own first. + let pick = &picks.borrow()[0]; + let labels: Vec<&str> = pick.filters.iter().map(|f| f.0).collect(); + assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); + assert_eq!(pick.name, "Untitled-1.vectorcraft"); + let d = dialog(&app); + assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save")); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + frame(&mut app, false); + assert!(app.ui.dialog.is_some(), "drawing keeps it open"); + app.ui.dialog.as_mut().unwrap().fields.insert("outlineText".into(), json!(true)); + frame(&mut app, true); + assert!(app.ui.dialog.is_none()); + let (path, bytes) = written.borrow()[0].clone(); + assert_eq!(path, "art.svg"); + assert!(bytes.starts_with(b" Value { p } -/// Save PDF: write the file; the dialog stays open when that fails (bad range, cancelled save…). -/// While Save Preset… asks for a name, OK (and Enter) saves the preset instead. +/// Save PDF: write the file (a PDF copy, or the PDF a Save As / Save a Copy asked for, `__save`); +/// the dialog stays open when that fails (bad range, cancelled save…). While Save Preset… asks for +/// a name, OK (and Enter) saves the preset instead. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if d.fields.contains_key(SAVE_AS) { let mut d = d.clone(); @@ -109,7 +110,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dialog = Some(d); return r; } - let r = io::export_pdf(app, params(d)); + let r = match SaveMode::of(&d.str("__save")) { + Some(mode) => io::save_pdf(app, mode, params(d)), + None => io::export_pdf(app, params(d)), + }; if r.is_ok() { app.ui.dialog = None; } @@ -759,9 +763,9 @@ mod tests { // Without a path, bad options are refused before a save dialog asks for one. let asked = Rc::new(RefCell::new(0)); let a = asked.clone(); - app.services.pick_save = Some(Box::new(move |name: &str| { + app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| { *a.borrow_mut() += 1; - Some(format!("/tmp/picked-{name}")) + Some(format!("/tmp/picked-{}", pick.name)) })); app.run("ui.savePdfDialog", json!({"range": "7"})).unwrap(); assert!(super::super::confirm(&mut app).is_err()); diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 18e20321d..4b207b649 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -78,7 +78,12 @@ pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) { if app.session.active().is_some_and(|st| st.doc.setup.export_text == vectorcraft_doc::ExportText::Appearance) { fields.insert("outlineText".into(), json!(true)); } - let saved = app.session.active().map(|st| st.save_options.clone()).filter(|_| mode != Mode::Export); + // Only options saved with an SVG format are SVG options. + let saved = app + .session + .active() + .filter(|st| mode != Mode::Export && matches!(st.format, "svg" | "svgz")) + .map(|st| Value::Object(st.save_options.clone())); for last in [&app.ui.svg_options].into_iter().chain(saved.as_ref()) { if let Some(o) = last.as_object() { fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); @@ -289,7 +294,7 @@ mod tests { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) })), - pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index b654fbc12..42e84016d 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -18,7 +18,7 @@ fn app(artboards: usize) -> (VectorcraftApp, Written) { let written = Written::default(); let w = written.clone(); let services = Services { - pick_save: Some(Box::new(|name: &str| Some(format!("/out/{name}")))), + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))), write: Some(Box::new(move |p: &str, b: &[u8]| { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) @@ -211,7 +211,7 @@ fn old_ids_and_agents_export_directly_with_a_path() { #[test] fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() { let (mut app, written) = app(1); - app.services.pick_save = Some(Box::new(|_: &str| panic!("no save dialog without art"))); + app.services.pick_save = Some(Box::new(|_: &crate::FilePick| panic!("no save dialog without art"))); app.run("select.all", json!({})).unwrap(); app.run("edit.clear", json!({})).unwrap(); app.run("file.exportAs", json!({})).unwrap(); @@ -219,7 +219,7 @@ fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() { assert_eq!(kind(&app), Some("exportAs")); // With Use Artboards the empty artboard is exported. set(&mut app, "useArtboards", json!(true)); - app.services.pick_save = Some(Box::new(|name: &str| Some(format!("/out/{name}")))); + app.services.pick_save = Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))); confirm(&mut app).unwrap(); assert_eq!(kind(&app), Some("pngOptions")); assert!(written.borrow().is_empty()); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 56de6be3d..524ffbddf 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -1,11 +1,13 @@ -//! File I/O through the injected services (pick, read, write, download); the engine's `fileio` -//! decodes and encodes every format. +//! File I/O through the injected services (pick, read, write, download); the engine's +//! `fileio` decodes, encodes and saves every format. use serde_json::{Value, json}; -use vectorcraft_engine::cmd::fileio; +use vectorcraft_engine::EngineError; +use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan}; -use crate::VectorcraftApp; use crate::dialogs::svg_options; +use crate::state::Dialog; +use crate::{FilePick, Services, VectorcraftApp, dialogs}; /// 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 and PDF presets files are @@ -49,48 +51,80 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O Ok(()) } +/// A path from the open dialog, or "cancelled". +fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result { + app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into()) +} + /// 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 = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + let path = pick_open(app, &FilePick { filters: fileio::open_filters().collect(), ..Default::default() })?; open_path(app, &path) } +fn read(app: &VectorcraftApp, path: &str) -> Result, String> { + app.services.read.as_ref().ok_or("no file reader")?(path) +} + pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { - let read = app.services.read.as_ref().ok_or("no file reader")?; - let bytes = read(path)?; + let bytes = read(app, path)?; open_bytes(app, path, &bytes, Some(path.to_string()))?; note_recent(app, path); Ok(()) } -fn write_out(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { - if let Some(dl) = app.services.download.as_mut() { +/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. +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(()); } - let w = app.services.write.as_mut().ok_or("no file writer")?; - w(path, bytes) + services.write.as_mut().ok_or("no file writer")?(path, bytes) } -fn suggested(app: &VectorcraftApp, ext: &str) -> String { - let t = app.session.active().map(|d| d.title()).unwrap_or_else(|| "Untitled".into()); - let stem = std::path::Path::new(&t).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(t); - format!("{stem}.{ext}") +/// The web saves by downloading: no save panel, no folders. +fn is_web(app: &VectorcraftApp) -> bool { + app.services.download.is_some() } -/// `path`, else a suggested name (web download) or one picked in a save dialog. +/// Where a file goes when no path was given: the suggested name on the web (a download), else the +/// save panel's choice. +fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result { + if is_web(app) { + return Ok(pick.name.clone()); + } + app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into()) +} + +/// The active document's file name with extension `ext`, and its folder. +fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option) { + 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, ext: &str) -> Result { - match path { - Some(p) => Ok(p), - None if app.services.download.is_some() => Ok(suggested(app, ext)), - None => { - let s = suggested(app, ext); - app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or_else(|| "cancelled".into()) - } + 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 }) +} + +/// 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 { + match fileio::format_for_name(path) { + Some(g) if SAVE_FORMATS.contains(&g.id) => path.to_string(), + _ => format!("{path}.{}", f.extensions[0]), } } @@ -99,70 +133,137 @@ pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: & fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, String> { let named = enc.named(doc, path); for (p, bytes) in &named { - write_out(app, p, bytes)?; + write_to(&mut app.services, p, bytes)?; } Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect()) } -fn format_param(p: &Value) -> Option<&str> { - p.get("format").and_then(Value::as_str) +fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result { + fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string()) } -/// File → Save / Save As: native, SVG for an .svg path (with the SVG options in `params`, else -/// the ones the document was last saved with), or a PDF-compatible .ai file (with the PDF options -/// in `params`) that reopens editable. -pub fn save(app: &mut VectorcraftApp, path: Option, save_as: bool, params: &Value) -> Result { - let st = app.session.active().ok_or("no document")?; - let existing = if save_as { None } else { st.path.clone() }; - let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?; - let f = fileio::save_format(format_param(params), Some(&path))?; - fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?); - let params = fileio::pdf::expand_preset(&app.session, "file.save", params).map_err(|e| e.to_string())?; - let params = &*params; - let st = app.session.active().ok_or("no document")?; - let (enc, opts) = fileio::save_encoding(st, f, params, Some(&path)).map_err(|e| e.to_string())?; - let doc = st.doc.clone(); - write_encoded(app, &doc, &path, &enc)?; - if let Some(st) = app.session.active_mut() { - st.path = Some(path.clone()); - st.save_options = opts; - st.mark_saved(); - } - app.status(format!("Saved {path}")); - note_recent(app, &path); - Ok(path) -} - -/// File → Save As / Save a Copy: the path (asked for when missing); an SVG path without SVG -/// options in `p` opens SVG Options, whose OK saves. A copy leaves the document's path alone. -pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result { - let path = target_path(app, p.get("path").and_then(Value::as_str).map(str::to_string), vectorcraft_format::EXTENSION)?; - let f = fileio::save_format(format_param(p), Some(&path))?; - let given = p.get("svg").is_some_and(|v| !v.is_null()) || !fileio::svg_options(p)?.is_empty(); - if matches!(f.id, "svg" | "svgz") && !given { - let mode = if copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save }; - svg_options::open(app, mode, Some(&path)); - return Ok(json!({ "dialog": svg_options::KIND, "path": path })); - } - if !copy { - return save(app, Some(path), true, p).map(|p| json!({ "path": p })); - } - let st = app.session.active().ok_or("no document")?; - let doc = st.doc.clone(); - // A .ai copy is written as Save writes it (PDF plus the native document). - let enc = match f.id { - "ai" => fileio::pdf::expand_preset(&app.session, "file.saveCopy", p) - .and_then(|p| fileio::save_encoding(st, f, &p, Some(&path))) - .map(|(enc, _)| enc), - _ => { - let copy = vectorcraft_engine::cmd::links::with_relative_paths(&doc, &path); - fileio::encode_all(copy.as_ref().unwrap_or(&doc), f.id, p) +/// 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), and so does Save As or Save a Copy +/// to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: , +/// path?}` while a dialog is open. +pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + 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); } - .map_err(|e| e.to_string())?; - write_encoded(app, &doc, &path, &enc)?; - app.status(format!("Saved a copy as {path}")); - Ok(json!({ "path": path })) + 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 && !chosen.format.options.is_empty() { + let path = chosen.path.clone().unwrap_or_default(); + return ask_format_options(app, mode, chosen.format, &path); + } + write_plan(app, chosen) +} + +/// 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 { + 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 { + 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 { + 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 ", with the first of the result's warnings. +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. +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { + let retargets = plan.retargets(); + let services = &mut app.services; + let r = fileio::save_with(&mut app.session, plan, |path, bytes| write_to(services, path, bytes).map_err(EngineError::Other)) + .map_err(|e| e.to_string())?; + let path = r["path"].as_str().unwrap_or_default().to_string(); + if retargets { + note_recent(app, &path); + } + report_saved(app, &path, &r); + 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 }) } /// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files). @@ -239,7 +340,7 @@ fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) Some(p) => p, None => target_path(app, None, ext)?, }; - write_out(app, &path, &bytes)?; + write_to(&mut app.services, &path, &bytes)?; Ok((path, v)) } @@ -250,14 +351,10 @@ fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result { 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 && app.services.download.is_none() { - app.open_url(&file_url(&path)); + if view { + view_file(app, &path); } - let warnings: Vec<&str> = v["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}"), - }); + report_saved(app, &path, &v); v["path"] = Value::String(path); Ok(v) } @@ -385,7 +482,7 @@ mod tests { 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(|_: &str| Some("/tmp/sel.svg".into()))); + 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(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bf693816b..9402351af 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,7 +69,25 @@ pub use control::{ControlRequest, ControlResponse}; pub use state::{UiState, View}; pub use sysclip::SystemClipboard; -pub type PickSave = Box Option>; +/// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the +/// file-type filters. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FilePick { + pub name: String, + pub folder: Option, + /// `(label, extensions without the dot)`, the default type first; empty = every file. + pub filters: Vec<(&'static str, &'static [&'static str])>, +} + +impl FilePick { + /// A save dialog suggesting `name`. + pub fn named(name: &str) -> Self { + Self { name: name.to_string(), ..Default::default() } + } +} + +pub type PickOpen = Box Option>; +pub type PickSave = Box Option>; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type Inbox = Arc)>>>; @@ -82,8 +100,8 @@ pub type OpenUrlFn = Box; #[derive(Default)] pub struct Services { /// Show an open dialog; returns a path. - pub pick_open: Option Option>>, - /// Show a save dialog with a suggested file name; returns a path. + pub pick_open: Option, + /// Show a save dialog (suggested name, folder, file types); returns a path. pub pick_save: Option, pub read: Option, pub write: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e9cf27b09..11beddfc3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,6 +7,8 @@ use serde::Serialize; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::SaveMode; + use crate::VectorcraftApp; use crate::io; use crate::state::{DockTab, ICON_PANELS, next_zoom}; @@ -52,19 +54,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, svg?: {…SVG options}, …PDF options for .ai} (a document saved as .svg saves as SVG again, with the same options; one saved or opened as .ai saves as .ai again)", - ), - ( - "file.saveAs", - "Save As…", - "Cmd+Shift+S", - "{path?, svg?: {…SVG options}, …document.exportPdf options for .ai} .vectorcraft, .ai (PDF-compatible: a PDF carrying the native document, which reopens editable) or .svg/.svgz (such a path without svg options opens SVG Options)", - ), - ( - "file.saveCopy", - "Save a Copy…", - "Cmd+Alt+S", - "{path?, svg?: {…SVG options}, …PDF options for .ai} like Save As, but the document keeps its path", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved) the Save As panel asks first", ), ("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"), ("file.revert", "Revert", "F12", "{}"), @@ -440,14 +430,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_path(app, &path).map(|_| Value::Null), None => io::open_dialog(app).map(|_| Value::Null), }, - "file.save" => io::save(app, s("path"), false, p).map(|p| json!({"path": p})), - "file.saveAs" | "file.saveCopy" => io::save_as(app, id == "file.saveCopy", p), + // Saves write through the app (save panel, download); with no path known the panel and the + // format's options dialog ask first. + "file.save" => io::save(app, SaveMode::Save, p, true), + "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), + "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), + "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), "document.exportSelection" => { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } - "file.saveAsTemplate" => { - io::save_command_output(app, id, "vectorcraft", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) - } "file.newFromTemplate" => match s("path") { Some(path) => io::open_path(app, &path).map(|_| Value::Null), None => io::open_dialog(app).map(|_| Value::Null), @@ -944,10 +935,9 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste"); } match id { - "file.save" - | "file.saveAs" - | "file.saveCopy" - | "file.place" + // Save is off for a clean document that already has its own file. + "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.is_dirty()), + "file.place" | "file.export.svg" | "file.export.png" | "file.exportAs" @@ -1022,6 +1012,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Save As…", "file.saveAs"), c("Save a Copy…", "file.saveCopy"), c("Save as Template…", "file.saveAsTemplate"), + c("Save as PDF…", "file.export.pdf"), c("Revert", "file.revert"), Sep, c("Place…", "file.place"), diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 35d81835d..f57e97b36 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -141,7 +141,12 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option) f(); return Ok(None); } - path.or_else(|| app.services.pick_open.as_mut().and_then(|f| f())).map(Some).ok_or_else(|| "cancelled".into()) + path.or_else(|| { + let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() }; + app.services.pick_open.as_mut().and_then(|f| f(&pick)) + }) + .map(Some) + .ok_or_else(|| "cancelled".into()) } /// The User Defined library a Window menu slot (`prefix` and a number from 1) stands for, of @@ -557,7 +562,7 @@ mod tests { let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); let w = written.clone(); let services = crate::Services { - pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))), + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), write: Some(Box::new(move |p: &str, b: &[u8]| { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 96f4eb1c7..d7d484df8 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -107,7 +107,11 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.horizontal(|ui| { if widgets::flat_button(ui, "Load Profile…", 110.0).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f()) + && let Some(path) = app + .services + .pick_open + .as_mut() + .and_then(|f| f(&crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() })) { run(app, "color.loadProfile", json!({ "path": path })); } diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index dbf0037f0..31165c03e 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -164,7 +164,7 @@ fn saving_to_a_file_and_opening_a_vcstyles_file() { let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); let w = written.clone(); let services = crate::Services { - pick_save: Some(Box::new(|name: &str| Some(format!("/tmp/{name}")))), + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), write: Some(Box::new(move |p: &str, b: &[u8]| { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 5cb759322..aaa571ff7 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -106,7 +106,7 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> { } let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) { (Some(f), _) => f(), - (None, Some(f)) => f().into_iter().collect(), + (None, Some(f)) => f(&crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect(), (None, None) => vec![], }; if paths.is_empty() { diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 1064d02cb..303259a92 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -442,7 +442,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides); let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default(); - let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f("VectorCraft Shortcuts.json"))); + let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named("VectorCraft Shortcuts.json")))); match path { Some(path) => match app.services.write.as_mut() { Some(w) => w(&path, &bytes).map(|_| json!({"path": path})), @@ -461,7 +461,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())), (None, _) => Err("cancelled".into()), diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index d1fac8be6..2a7e91cb3 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -38,7 +38,7 @@ fn app() -> VectorcraftApp { /// File → Place… with `path` chosen in the file picker. fn place_picked(app: &mut VectorcraftApp, path: &str) { let path = path.to_string(); - app.services.pick_open = Some(Box::new(move || Some(path.clone()))); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(path.clone()))); app.run("file.place", json!({})).unwrap(); } diff --git a/crates/ui-egui/src/tests_svg.rs b/crates/ui-egui/src/tests_svg.rs index a22bb068b..0708677b6 100644 --- a/crates/ui-egui/src/tests_svg.rs +++ b/crates/ui-egui/src/tests_svg.rs @@ -20,7 +20,7 @@ pub(super) fn app(picked: &str) -> (VectorcraftApp, Written) { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) })), - pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + pick_save: Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index 7d5909af0..7e80a39b6 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -71,7 +71,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result { let i = i.ok_or("no such document")?; if !d.bool("discard") { app.session.set_active(i); - io::save(app, None, false, &Value::Null)?; + io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; } close_now(app, i)?; match d.str("then").as_str() { @@ -91,7 +91,7 @@ mod tests { let written = Arc::new(Mutex::new(vec![])); let w = written.clone(); let services = crate::Services { - pick_save: Some(Box::new(|_: &str| Some("out.vectorcraft".to_string()))), + pick_save: Some(Box::new(|_: &crate::FilePick| Some("out.vectorcraft".to_string()))), write: Some(Box::new(move |p: &str, _: &[u8]| { w.lock().unwrap().push(p.to_string()); Ok(()) @@ -154,7 +154,7 @@ mod tests { #[test] fn a_cancelled_save_as_keeps_the_document_open() { let (mut app, _) = app(); - app.services.pick_save = Some(Box::new(|_: &str| None)); + app.services.pick_save = Some(Box::new(|_: &crate::FilePick| None)); new_doc(&mut app, true); close(&mut app, 0).unwrap(); assert!(confirm(&mut app).is_err()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2f33e97a6..7a94c8c3a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,6 +22,7 @@ | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | diff --git a/docs/mcp.md b/docs/mcp.md index 33b007eb9..6c1d8d1e9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,7 +63,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `open_panel` | `{panel}` | Remote only. | | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | -| `save_file` | `{path?}` | Saves in the native `.vectorcraft` format. | +| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | @@ -139,6 +139,13 @@ hidden layers. A `preserveEditing` SVG carries the native document (CDATA in `