diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 6debeb84f..fd2e4b815 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -74,8 +74,9 @@ pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, remembered_save_options, save_filters, save_format, - save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, reads_option as save_reads_option, + remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, + templates_folder, }; pub use screens::{ PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize, diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 2cc836ee5..3eb64f3ea 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -95,6 +95,9 @@ pub struct SavePlan { /// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file /// stamps; `None`: the time it is written. pub modified: Option>, + /// Warnings about the params themselves: each native option (`compress`, `version`, + /// `preview`) given beside the path that the format doesn't read. + pub notes: Vec, } impl SavePlan { @@ -234,6 +237,14 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { (_, None, None) => checked(cmd, st.format)?, }; let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path }; + if let Some((key, top, inner)) = disagreeing_option(p, is_svg(format)).map_err(|e| bad(cmd, e))? { + return Err(bad(cmd, format!("`{key}: {top}` and `options.{key}: {inner}` disagree: give the option once"))); + } + let notes = NATIVE_OPTIONS + .iter() + .filter(|k| p.get(**k).is_some_and(|v| !v.is_null()) && !reads_option(format, k)) + .map(|k| format!("`{k}` was not used: {} files don't read it", format.label)) + .collect(); 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(), @@ -273,7 +284,43 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { if let Some(path) = &path { super::check_not_lossy_overwrite(st, path, p, cmd)?; } - Ok(SavePlan { mode, path, format, options, name, folder, modified }) + Ok(SavePlan { mode, path, format, options, name, folder, modified, notes }) +} + +/// The native save options, which a save also takes beside the path. +const NATIVE_OPTIONS: [&str; 3] = ["compress", "version", "preview"]; + +/// The first option `p` gives both beside the path and in `options` with different values, as +/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on +/// either side is the one its `svg: {…}` object gives, else the flat one. +fn disagreeing_option(p: &Value, svg: bool) -> std::result::Result, String> { + let Some(inner) = p.get("options").filter(|o| o.is_object()) else { return Ok(None) }; + let given = |m: &Value| -> std::result::Result, String> { + let mut out: Map = m + .as_object() + .into_iter() + .flatten() + .filter(|(k, v)| !v.is_null() && !matches!(k.as_str(), "path" | "format" | "options") && !(svg && k.as_str() == "svg")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if svg { + out.extend(super::svg_options(m)?); + } + Ok(out) + }; + let top = given(p)?; + Ok(given(inner)?.into_iter().find_map(|(k, v)| { + let t = top.get(&k).filter(|t| !same_value(t, &v))?.clone(); + Some((k, t, v)) + })) +} + +/// `a == b`, with a whole number and a float of the same value (`3` and `3.0`) the same. +fn same_value(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x == y || ((x.is_f64() || y.is_f64()) && x.as_f64() == y.as_f64()), + _ => a == b, + } } /// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`) @@ -370,8 +417,8 @@ fn range_text(picked: &[usize]) -> String { } /// 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 { +/// settings (`createLayers`…) and presets. A save drops each option its format doesn't read. +pub fn reads_option(f: &Format, key: &str) -> bool { f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key))) } @@ -650,18 +697,31 @@ impl SaveJob { /// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with /// the warnings (what the format loses first, then the encoder's own notes: PDF options not - /// applied yet…). + /// applied yet…, then the plan's notes on its params). pub fn encode(&self) -> Result { let mut enc = self.encode_one(&self.doc)?; for &b in &self.boards { let one = self.encode_one(&artboard_doc(&self.doc, b))?; enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); } - // Lost artboards first: the status bar shows only the first warning. + enc.warnings.splice(0..0, self.format_losses()); + enc.warnings.extend(self.plan.notes.iter().cloned()); + Ok(enc) + } + + /// What the file loses against the document: the artboards an SVG file leaves out first (the + /// status bar shows only the first warning), then what the format loses. + fn format_losses(&self) -> impl Iterator { let f = self.plan.format; let lost = if is_svg(f) { super::svg::lost_artboards_warning(&self.doc, &self.params, f.label) } else { None }; - enc.warnings.splice(0..0, lost.into_iter().chain(fidelity_warning(f))); - Ok(enc) + lost.into_iter().chain(fidelity_warning(f)) + } + + /// The warnings known before the file is encoded, in the order a written file's reply gives + /// them: what the format loses, then the plan's notes on its params (a reply sent before the + /// file is written, as Background Save's, gives these). + pub fn known_warnings(&self) -> Vec { + self.format_losses().chain(self.plan.notes.iter().cloned()).collect() } /// Encode one file of `doc` in the job's format. @@ -754,8 +814,7 @@ impl SaveJob { }; let r = stream(path, &mut fill); r.map_err(|e| failed.take().unwrap_or(e))?; - let warnings: Vec = fidelity_warning(f).into_iter().collect(); - Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings })) + Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": self.known_warnings() })) } /// Once written: Save and Save As (except to a template) make the file `st`'s own: path, @@ -883,7 +942,7 @@ pub(super) fn specs() -> Vec { "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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead, compress?: bool (native, also in options: gzip; default: the useCompression preference), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), 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; separateArtboards writes more files, one per artboard. 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, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), 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; separateArtboards writes more files, one per artboard. 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, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index e656965e1..530b681ba 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -702,6 +702,58 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { let _ = std::fs::remove_dir_all(&d); } +/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different +/// values is refused before anything is written; `compress`, `version` or `preview` beside the path of a format that doesn't +/// read it is named in the warnings. +#[test] +fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() { + let d = tmp_dir("save-options"); + let mut s = session(200.0, 100.0, 1); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let path = d.join("both.vectorcraft").to_string_lossy().to_string(); + for cmd in ["document.save", "file.saveAs", "file.saveCopy", "file.saveAsTemplate"] { + let e = s.execute(cmd, &json!({"path": path, "compress": true, "options": {"compress": false}})).unwrap_err().to_string(); + assert!(e.contains("`compress: true` and `options.compress: false` disagree") && e.contains(&format!("`{cmd}`")), "{cmd}: {e}"); + } + assert!(!std::path::Path::new(&path).exists(), "nothing is written"); + assert!(!d.join("both.vctemplate").exists(), "nor a template"); + // An SVG option given in `svg: {…}` or in `options.svg` counts as given there. + let svg = d.join("a.svg").to_string_lossy().to_string(); + for p in [ + json!({"svg": {"hiddenLayers": false}, "options": {"hiddenLayers": true}}), + json!({"hiddenLayers": false, "options": {"svg": {"hiddenLayers": true}}}), + json!({"hiddenLayers": true, "svg": {"hiddenLayers": false}, "options": {"hiddenLayers": true}}), + ] { + let mut p = p; + p["path"] = json!(svg); + let e = s.execute("file.saveCopy", &p).unwrap_err().to_string(); + assert!(e.contains("`hiddenLayers: false` and `options.hiddenLayers: true` disagree") && e.contains("`file.saveCopy`"), "{p}: {e}"); + } + assert!(!std::path::Path::new(&svg).exists(), "nothing is written"); + // Agreeing values, or one of them null, save as asked; `3` and `3.0` agree. + for p in [ + json!({"compress": true, "options": {"compress": true}}), + json!({"compress": null, "options": {"compress": true}}), + json!({"compress": true, "version": 3, "options": {"compress": true, "version": 3.0}}), + ] { + let mut p = p; + p["path"] = json!(path); + let r = s.execute("file.saveCopy", &p).unwrap(); + assert_eq!(r["warnings"], json!([]), "{p}: {r}"); + assert_eq!(std::fs::read(&path).unwrap()[..2], [0x1f, 0x8b], "{p}"); + } + // An SVG file reads none of the native options; a .ai file reads `compress` only. + let r = s.execute("file.saveCopy", &json!({"path": svg, "compress": true, "version": 3, "preview": null})).unwrap(); + let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(w.contains(&"`compress` was not used: SVG files don't read it"), "{w:?}"); + assert!(w.contains(&"`version` was not used: SVG files don't read it") && !w.iter().any(|w| w.starts_with("`preview`")), "{w:?}"); + assert!(std::fs::read(&svg).unwrap().starts_with(b"<"), "plain SVG"); + let r = s.execute("file.saveCopy", &json!({"path": d.join("a.ai").to_string_lossy(), "compress": false, "version": 3})).unwrap(); + let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(w.iter().any(|w| w.starts_with("`version` was not used")) && !w.iter().any(|w| w.starts_with("`compress`")), "{w:?}"); + let _ = std::fs::remove_dir_all(&d); +} + /// A native save whose file write fails partway (a full disk) leaves the file as it was, and no /// temporary file behind. #[test] diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 02c84ae2f..03b632a26 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -816,6 +816,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec String { let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id())); @@ -166,6 +166,120 @@ fn exporting_native_keeps_the_document_path() { assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str())); } +/// The bytes `save_file` writes to `name` with `args`. +fn saved_with(h: &mut Headless, name: &str, mut args: Value) -> Vec { + let path = tmp(name); + args["path"] = json!(path); + let r = text(&call_tool(h, "save_file", &args)); + assert_eq!(r["format"], "vectorcraft", "{r}"); + std::fs::read(&path).unwrap() +} + +#[test] +fn save_file_takes_the_native_save_options() { + let mut h = two_boards(); + let gz = saved_with(&mut h, "gz.vectorcraft", json!({"compress": true})); + assert_eq!(gz[..2], [0x1f, 0x8b], "gzipped"); + let plain = saved_with(&mut h, "plain.vectorcraft", json!({"compress": false})); + assert_eq!(plain[0], b'{', "plain JSON"); + let nodes = h.session.doc().unwrap().doc.node_count(); + for name in ["gz.vectorcraft", "plain.vectorcraft"] { + let mut back = Headless::new(); + text(&call_tool(&mut back, "open_file", &json!({"path": tmp(name)}))); + assert_eq!(back.session.doc().unwrap().doc.node_count(), nodes, "{name} reopens"); + } + let opt = saved_with(&mut h, "opt.vectorcraft", json!({"options": {"compress": true}})); + assert_eq!(opt[..2], [0x1f, 0x8b], "`options` carries them too"); + let v3: Value = serde_json::from_slice(&saved_with(&mut h, "v3.vectorcraft", json!({"version": 3, "preview": null}))).unwrap(); + assert_eq!(v3["version"], 3); + let shown = saved_with(&mut h, "preview.vectorcraft", json!({"preview": true})); + assert!(vectorcraft_format::preview(&shown).is_some(), "a preview"); + assert!(vectorcraft_format::preview(&plain).is_none(), "off by default"); + // Version 2 files can't be compressed: the engine's error comes back. + let r = call_tool(&mut h, "save_file", &json!({"path": tmp("v2.vectorcraft"), "version": 2, "compress": true})); + assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("can't be compressed"), "{r:?}"); + + // The options are in the schema; a misspelled one is still refused. + let mut s = Server::new(Box::new(Headless::with_document())); + let call = |s: &mut Server, args: Value| { + s.handle(json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "save_file", "arguments": args}})).unwrap() + }; + let r = call(&mut s, json!({"path": tmp("ok.vectorcraft"), "compress": true})); + assert_eq!(r["result"]["isError"], false, "{r}"); + let r = call(&mut s, json!({"path": tmp("typo.vectorcraft"), "compresss": true})); + assert_eq!(r["error"]["code"], -32602, "{r}"); + assert!(r["error"]["message"].as_str().unwrap().contains("unknown argument `compresss`"), "{r}"); +} + +/// `warnings` name each native save option the written format doesn't read, and a top-level option +/// that disagrees with the same key in `options` is an error. +#[test] +fn save_file_warns_about_options_it_does_not_use() { + let mut h = two_boards(); + let warnings = |r: &Value| -> Vec { r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect() }; + let ignored = |w: &[String], key: &str| w.iter().any(|w| w.starts_with(&format!("`{key}` was not used"))); + + // SVG reads none of them, at the top level or in `options`. + let path = tmp("ignored.svg"); + let r = text(&call_tool(&mut h, "save_file", &json!({"path": path, "compress": true, "version": 3, "options": {"preview": true}}))); + assert_eq!(r["format"], "svg", "{r}"); + let w = warnings(&r); + assert!(ignored(&w, "compress") && ignored(&w, "version") && ignored(&w, "options.preview"), "{w:?}"); + assert_eq!(w.iter().filter(|w| w.starts_with("`compress`")).count(), 1, "said once: {w:?}"); + assert!(w.iter().any(|w| w.contains("SVG")), "names the format: {w:?}"); + assert!(std::fs::read(&path).unwrap().starts_with(b"<"), "plain SVG"); + // A .ai file reads `compress` but not `version`. + let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp("ignored.ai"), "compress": false, "version": 3}))); + let w = warnings(&r); + assert!(ignored(&w, "version") && !ignored(&w, "compress"), "{w:?}"); + + // A top-level value that disagrees with `options` is refused, and nothing is written. + let path = tmp("both.vectorcraft"); + let _ = std::fs::remove_file(&path); + let r = call_tool(&mut h, "save_file", &json!({"path": path, "compress": true, "options": {"compress": false}})); + let e = r.content[0]["text"].as_str().unwrap(); + assert!(r.is_error && e.contains("`compress: true` and `options.compress: false` disagree"), "{r:?}"); + assert!(!std::path::Path::new(&path).exists(), "nothing is written"); + // Nothing to say when they agree or when every option applies. + for mut args in [json!({"compress": true, "options": {"compress": true}}), json!({"compress": false, "version": 3, "preview": true})] { + args["path"] = json!(tmp("quiet.vectorcraft")); + let r = text(&call_tool(&mut h, "save_file", &args)); + assert_eq!(r["warnings"], json!([]), "{args}: {r}"); + } + + // The app's save warns about the top-level options itself; the reply gets the notes on + // `options`, after the warnings the app sent. + struct App; + impl Backend for App { + fn call(&mut self, _: &str, _: Value) -> Result { + Ok(json!({"path": "/out/a.svg", "format": "svg", "warnings": ["the app's own warning"]})) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "app".into() + } + } + let r = text(&call_tool(&mut App, "save_file", &json!({"path": "/out/a.svg", "compress": true, "options": {"preview": true}}))); + let w = warnings(&r); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0] == "the app's own warning" && ignored(&w[1..], "options.preview"), "{w:?}"); +} + +#[test] +fn save_file_is_the_same_call_in_the_app_and_headless() { + let path = tmp("app.vectorcraft"); + let (addr, app) = recording_app(); + let mut remote = Remote::connect(&addr).unwrap(); + let args = json!({"path": path, "compress": true, "version": 3, "preview": null, "options": {"preview": true}}); + assert!(!call_tool(&mut remote, "save_file", &args).is_error); + drop(remote); + let seen = app.join().unwrap(); + assert_eq!(seen[0]["method"], "app.save"); + assert_eq!(seen[0]["params"], json!({"path": path, "compress": true, "version": 3, "options": {"preview": true}, "wait": true})); +} + /// The app is asked to write the file before it replies (`wait`), even with Background Save on, so a /// save that fails is the call's error; a headless session always does, and is sent no `wait`. #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 6fa01e6b4..a11bbb098 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -2,7 +2,7 @@ //! [`Backend`]. use serde_json::{Map, Value, json}; -use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format, writable_format}; +use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format, save_reads_option, writable_format}; use crate::backend::Backend; @@ -36,6 +36,8 @@ fn num(desc: &str) -> Value { fn string(desc: &str) -> Value { json!({"type": "string", "description": desc}) } +/// The arguments `save_file` passes on to `document.save` beside `path`. +const SAVE_FILE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "options"]; /// The arguments `doc_inspect` / `inspect_document` pass on to `document.inspect`. const INSPECT_KEYS: [&str; 6] = ["depth", "childLimit", "ids", "kind", "limit", "offset"]; @@ -361,10 +363,19 @@ pub fn tool_definitions() -> Vec { "save_file", "Save file", &format!( - "Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). The reply's `format` says what was written and `warnings` what that format loses. In the running app the call returns once the file is written, or with the error, even with Background Save on. When the save asks first (before replacing a file that opened with losses, or with a file dialog for an untitled document without `path`), the reply is `pending` or \"cancelled\" as before.", + "Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). A native file is gzipped when `compress` is true (default: the useCompression preference, Preferences > File Handling > Use Compression); `version` writes it for an older VectorCraft and `preview` embeds a PNG of the first artboard. `options` carries the format's save options (see run_command document.formats); giving `compress`, `version` or `preview` both at the top level and in `options` with different values is an error. The reply's `format` says what was written and `warnings` what that format loses and each option given that the format doesn't read (such as `compress` for .svg). In the running app the call returns once the file is written, or with the error, even with Background Save on. When the save asks first (before replacing a file that opened with losses, or with a file dialog for an untitled document without `path`), the reply is `pending` or \"cancelled\" as before.", save_extensions().join(", .") ), - obj(json!({"path": string("Destination; its extension picks the format (default: the document's own file)")}), &[]), + obj( + json!({ + "path": string("Destination; its extension picks the format (default: the document's own file)"), + "compress": {"type": "boolean", "description": "Native files: gzip the file (default: the useCompression preference)"}, + "version": {"type": "integer", "minimum": 1, "maximum": vectorcraft_format::VERSION, "description": "Native files: the format version to write (default: the current one; 1 and 2 are never compressed)"}, + "preview": {"type": "boolean", "description": "Native files: embed a PNG of the first artboard, at most 256 px (default false)"}, + "options": {"type": "object", "description": "The format's save options, passed through (see run_command document.formats); default: as last saved"}, + }), + &[], + ), false, ), tool( @@ -1076,12 +1087,24 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result j(b.call("app.open", json!({"path": absolute_path(req_str(a, "path")?)?}))?), "save_file" => { let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(absolute_path).transpose()?}); + for k in SAVE_FILE_OPTIONS { + if let Some(v) = a.get(k).filter(|v| !v.is_null()) { + p[k] = v.clone(); + } + } // The app writes the file before it replies, even with Background Save on, so a failed // save is this call's error. if b.has_ui() { p["wait"] = json!(true); } - j(b.call("app.save", p)?) + let mut r = b.call("app.save", p)?; + let notes = save_file_warnings(a, r.get("format").and_then(Value::as_str)); + if let Some(o) = r.as_object_mut().filter(|_| !notes.is_empty()) { + let mut all = o.get("warnings").and_then(Value::as_array).cloned().unwrap_or_default(); + all.extend(notes.into_iter().map(Value::from)); + o.insert("warnings".into(), Value::Array(all)); + } + j(r) } "export" => { // One params object for every backend; the named arguments win over `options` (a named @@ -1159,6 +1182,20 @@ fn named_export_params(a: &Args) -> &'static [&'static str] { } } +/// What `save_file` with arguments `a` adds to the save's `warnings`, for a file written in format +/// `written` (none when nothing was written yet): each key in `options` that the format doesn't +/// read. The save itself warns about `compress`, `version` and `preview` given at the top level. +fn save_file_warnings(a: &Args, written: Option<&str>) -> Vec { + let Some(f) = written.and_then(format) else { return vec![] }; + let nested = a.get("options").and_then(Value::as_object); + nested + .into_iter() + .flatten() + .filter(|(key, v)| !v.is_null() && !save_reads_option(f, key)) + .map(|(key, _)| format!("`options.{key}` was not used: {} files don't read it", f.label)) + .collect() +} + /// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an /// `isError` result so the model can read and correct them. pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 45c644a6a..f64f49519 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4651,6 +4651,7 @@ imageTrace Outline Obrys @msg unsupported: {0} nepodporováno: {0} @msg variables are locked proměnné jsou zamčené @msg variables that aren't text were left out: {kinds} proměnné, které nejsou textové, byly vynechány: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` a `options.{key}: {inner}` si odporují: zadejte volbu jen jednou @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files soubory verze {0} nelze komprimovat: komprimované soubory otevírá až verze {COMPRESSED_SINCE} a novější @msg width and height are at least 1 pixel šířka i výška jsou alespoň 1 obrazový bod @msg workspace name can't be empty název pracovního prostoru nesmí být prázdný diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0c525999d..4ff3f922f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4274,6 +4274,7 @@ theme Appearance Erscheinungsbild @msg unsupported: {0} nicht unterstützt: {0} @msg variables are locked die Variablen sind gesperrt @msg variables that aren't text were left out: {kinds} die Variablen, die kein Text sind, wurden ausgelassen: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` und `options.{key}: {inner}` widersprechen sich: Option nur einmal angeben @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files Dateien der Version {0} können nicht komprimiert werden: Erst Version {COMPRESSED_SINCE} und neuer öffnen komprimierte Dateien @msg width and height are at least 1 pixel Breite und Höhe betragen mindestens 1 Pixel @msg workspace name can't be empty der Name des Arbeitsbereichs darf nicht leer sein diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index cfe1aecf7..566078a29 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4322,6 +4322,7 @@ theme Appearance Εμφάνιση @msg unsupported: {0} δεν υποστηρίζεται: {0} @msg variables are locked οι μεταβλητές είναι κλειδωμένες @msg variables that aren't text were left out: {kinds} οι μεταβλητές που δεν είναι κειμένου παραλείφθηκαν: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once τα `{key}: {top}` και `options.{key}: {inner}` διαφέρουν: δώστε την επιλογή μόνο μία φορά @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files τα αρχεία έκδοσης {0} δεν μπορούν να συμπιεστούν: μόνο η έκδοση {COMPRESSED_SINCE} και νεότερες ανοίγουν συμπιεσμένα αρχεία @msg width and height are at least 1 pixel το πλάτος και το ύψος πρέπει να είναι τουλάχιστον 1 εικονοστοιχείο @msg workspace name can't be empty το όνομα χώρου εργασίας δεν μπορεί να είναι κενό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 26eb60d65..6f9649671 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4349,6 +4349,7 @@ axis Both Ambos @msg unsupported: {0} no compatible: {0} @msg variables are locked las variables están bloqueadas @msg variables that aren't text were left out: {kinds} se omitieron las variables que no son de texto: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` y `options.{key}: {inner}` no coinciden: indique la opción una sola vez @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files los archivos de la versión {0} no se pueden comprimir: solo la versión {COMPRESSED_SINCE} y posteriores abren archivos comprimidos @msg width and height are at least 1 pixel el ancho y el alto son de al menos 1 píxel @msg workspace name can't be empty el nombre del espacio de trabajo no puede estar vacío diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 9a6ace0f7..0e277db8e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4558,6 +4558,7 @@ imageTrace Outline Ääriviiva @msg unsupported: {0} ei tuettu: {0} @msg variables are locked muuttujat on lukittu @msg variables that aren't text were left out: {kinds} muut kuin tekstimuuttujat jätettiin pois: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` ja `options.{key}: {inner}` ovat ristiriidassa: anna asetus vain kerran @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files version {0} tiedostoja ei voi pakata: pakattuja tiedostoja voi avata vain versiosta {COMPRESSED_SINCE} alkaen @msg width and height are at least 1 pixel leveys ja korkeus ovat vähintään 1 pikseli @msg workspace name can't be empty työtilan nimi ei voi olla tyhjä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0eccbfa62..76e7626f6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4346,6 +4346,7 @@ axis Both Les deux @msg unsupported: {0} non pris en charge : {0} @msg variables are locked les variables sont verrouillées @msg variables that aren't text were left out: {kinds} les variables qui ne sont pas du texte ({kinds}) ont été ignorées +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` et `options.{key}: {inner}` ne concordent pas. Indiquez l’option une seule fois @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files les fichiers de version {0} ne peuvent pas être compressés : seules les versions {COMPRESSED_SINCE} et ultérieures ouvrent les fichiers compressés @msg width and height are at least 1 pixel la largeur et la hauteur valent au moins 1 pixel @msg workspace name can't be empty le nom de l’espace de travail ne peut pas être vide diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0461bd6c5..eb7dfa404 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5104,6 +5104,7 @@ imageTrace Outline Körvonal @msg VectorCraft doesn't search the Shared or Public folder in Users: {path} a VectorCraft nem keres a Users alatti Shared és Public mappában: {path} @msg VectorCraft failed to start: {e} A VectorCraft nem tudott elindulni: {e} @msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it a VectorCraft Fonts mappája itt nincs beállítva: az asztali alkalmazás és a vectorcraft-cli állítja be +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` és `options.{key}: {inner}` ellentmond egymásnak: a beállítást csak egyszer adja meg @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files ilyen verziójú fájl nem tömöríthető: {0}; tömörített fájlt csak ez a verzió és az újabbak nyitnak meg: {COMPRESSED_SINCE} @msg width and height are at least 1 pixel a szélesség és a magasság legalább 1 pixel @msg workspace name can't be empty a munkaterület neve nem lehet üres diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c707bbec2..9c4ddf9e2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4389,6 +4389,7 @@ axis Both Entrambi @msg unsupported: {0} non supportato: {0} @msg variables are locked le variabili sono bloccate @msg variables that aren't text were left out: {kinds} le variabili che non sono di testo sono state escluse: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` e `options.{key}: {inner}` non coincidono: indicare l'opzione una sola volta @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files i file della versione {0} non possono essere compressi: solo la versione {COMPRESSED_SINCE} e successive aprono file compressi @msg width and height are at least 1 pixel larghezza e altezza sono di almeno 1 pixel @msg workspace name can't be empty il nome dell'area di lavoro non può essere vuoto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a5c927e70..d1f9fc62e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4269,6 +4269,7 @@ swatch Bright Violet ビビッドバイオレット @msg invalid image data for `{0}` `{0}` の画像データが正しくありません @msg not a VectorCraft file: {0} VectorCraft ファイルではありません: {0} @msg the compressed file unpacks to more than {_1} MB 圧縮ファイルを展開すると {_1} MB を超えます +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` と `options.{key}: {inner}` が一致しません: オプションは 1 回だけ指定してください @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files バージョン {0} のファイルは圧縮できません: 圧縮ファイルを開けるのはバージョン {COMPRESSED_SINCE} 以降のみです @msg a bitmap is empty ビットマップが空です @msg a bitmap is too large to read ビットマップが大きすぎて読み込めません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 6bc19fd7f..62ab638f3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4109,6 +4109,7 @@ font-technology Variable ცვლადი @msg unknown tool or command `{key}` უცნობი ხელსაწყო ან ბრძანება `{key}` @msg unknown workspace (one of {_1}) უცნობი სამუშაო სივრცე (ერთ-ერთი: {_1}) @msg unsupported: {0} მხარდაუჭერელია: {0} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` და `options.{key}: {inner}` ერთმანეთს ეწინააღმდეგება: პარამეტრი მხოლოდ ერთხელ მიუთითეთ @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files {0} ვერსიის ფაილების შეკუმშვა შეუძლებელია: შეკუმშული ფაილები მხოლოდ {COMPRESSED_SINCE} და უფრო ახალი ვერსიებით იხსნება @msg width and height are at least 1 pixel სიგანე და სიმაღლე არანაკლებ 1 პიქსელია @msg workspace name can't be empty სამუშაო სივრცის სახელი ცარიელი ვერ იქნება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 26023590d..1e3bf00e7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4201,6 +4201,7 @@ theme Appearance 외관 @msg invalid image data for `{0}` `{0}`의 이미지 데이터가 올바르지 않습니다 @msg not a VectorCraft file: {0} VectorCraft 파일이 아닙니다: {0} @msg the compressed file unpacks to more than {_1} MB 압축된 파일의 압축을 풀면 {_1}MB를 넘습니다 +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}`와(과) `options.{key}: {inner}`이(가) 일치하지 않습니다: 옵션을 한 번만 지정하세요 @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files 버전 {0} 파일은 압축할 수 없습니다: 버전 {COMPRESSED_SINCE} 이상만 압축 파일을 엽니다 @msg a bitmap is empty 비트맵이 비어 있습니다 @msg a bitmap is too large to read 비트맵이 너무 커서 읽을 수 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b775619be..1941bc050 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4476,6 +4476,7 @@ imageTrace Outline Kontur @msg unsupported: {0} nieobsługiwane: {0} @msg variables are locked zmienne są zablokowane @msg variables that aren't text were left out: {kinds} pominięto zmienne, które nie są tekstowe: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` i `options.{key}: {inner}` są sprzeczne: podaj opcję tylko raz @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files plików w wersji {0} nie można kompresować: skompresowane pliki otwierają tylko wersje {COMPRESSED_SINCE} i nowsze @msg width and height are at least 1 pixel szerokość i wysokość muszą wynosić co najmniej 1 piksel @msg workspace name can't be empty nazwa obszaru roboczego nie może być pusta diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2631c9940..19f0f442d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3922,6 +3922,7 @@ axis Both Ambos @msg unsupported: {0} não suportado: {0} @msg variables are locked as variáveis estão bloqueadas @msg variables that aren't text were left out: {kinds} as variáveis que não são de texto foram omitidas: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` e `options.{key}: {inner}` não coincidem: informe a opção uma só vez @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files arquivos da versão {0} não podem ser compactados: somente a versão {COMPRESSED_SINCE} e posteriores abrem arquivos compactados @msg width and height are at least 1 pixel largura e altura têm pelo menos 1 pixel @msg workspace name can't be empty o nome do espaço de trabalho não pode estar vazio diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7e1da4d53..e465f2a32 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4385,6 +4385,7 @@ axis Both Оба @msg unsupported: {0} не поддерживается: {0} @msg variables are locked переменные заблокированы @msg variables that aren't text were left out: {kinds} переменные, которые не являются текстовыми, пропущены: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` и `options.{key}: {inner}` противоречат друг другу: укажите параметр только один раз @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файлы версии {0} не могут быть сжаты: только версия {COMPRESSED_SINCE} и новее открывают сжатые файлы @msg width and height are at least 1 pixel ширина и высота должны быть не менее 1 пикселя @msg workspace name can't be empty имя рабочей среды не может быть пустым diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b8b4bc6c8..03ad0ddd7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4258,6 +4258,7 @@ axis Both Båda @msg unsupported: {0} stöds inte: {0} @msg variables are locked variablerna är låsta @msg variables that aren't text were left out: {kinds} variabler som inte är text utelämnades: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` och `options.{key}: {inner}` stämmer inte överens: ange alternativet bara en gång @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files filer i version {0} kan inte komprimeras: endast version {COMPRESSED_SINCE} och senare kan öppna komprimerade filer @msg width and height are at least 1 pixel bredd och höjd är minst 1 pixel @msg workspace name can't be empty arbetslayoutens namn får inte vara tomt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a2f734004..ed0514462 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4108,6 +4108,7 @@ axis Both Her İkisi @msg unknown tool or command `{key}` bilinmeyen araç veya komut `{key}` @msg unknown workspace (one of {_1}) bilinmeyen çalışma alanı ({_1}'den biri) @msg unsupported: {0} desteklenmeyen: {0} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` ile `options.{key}: {inner}` çelişiyor: seçeneği yalnızca bir kez verin @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files {0} sürümü dosyaları sıkıştırılamaz: yalnızca {COMPRESSED_SINCE} sürümü ve sonraki sürümler sıkıştırılmış dosyaları açar @msg width and height are at least 1 pixel genişlik ve yükseklik en az 1 pikseldir @msg workspace name can't be empty çalışma alanı adı boş olamaz diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 59b305e61..40ea0ac69 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4373,6 +4373,7 @@ axis Both Обидві @msg unsupported: {0} не підтримується: {0} @msg variables are locked змінні заблоковані @msg variables that aren't text were left out: {kinds} змінні, що не є текстовими, пропущено: {kinds} +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` і `options.{key}: {inner}` суперечать одне одному: вкажіть параметр лише один раз @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файли версії {0} не можуть бути стиснуті: тільки версія {COMPRESSED_SINCE} і новіші відкривають стислі файли @msg width and height are at least 1 pixel ширина та висота повинні бути не менше 1 пікселя @msg workspace name can't be empty ім’я робочого середовища не може бути порожнім diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6abf2e423..858d3f049 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4762,6 +4762,7 @@ theme Appearance Giao diện @msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft không tìm kiếm thư mục Shared hoặc Public trong Users: {path} @msg VectorCraft failed to start: {e} VectorCraft khởi động thất bại: {e} @msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it Thư mục Phông chữ của VectorCraft chưa được đặt ở đây: ứng dụng máy tính và vectorcraft-cli đặt nó +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` và `options.{key}: {inner}` không khớp nhau: chỉ nêu tùy chọn một lần @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files tệp phiên bản {0} không thể nén: chỉ phiên bản {COMPRESSED_SINCE} trở lên mới mở tệp nén @msg width and height are at least 1 pixel chiều rộng và chiều cao phải ít nhất 1 pixel @msg workspace name can't be empty tên không gian làm việc không được để trống diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c4e17fc91..f2175fd33 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3998,6 +3998,7 @@ imageTrace Outline 轮廓 @msg invalid image data for `{0}` `{0}` 的图像数据无效 @msg not a VectorCraft file: {0} 不是 VectorCraft 文件:{0} @msg the compressed file unpacks to more than {_1} MB 压缩文件解压后超过 {_1} MB +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` 与 `options.{key}: {inner}` 不一致:该选项只能给出一次 @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files 版本 {0} 的文件不能压缩:只有版本 {COMPRESSED_SINCE} 及更高版本能打开压缩文件 @msg a bitmap is empty 某个位图为空 @msg a bitmap is too large to read 某个位图过大,无法读取 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4d50170c5..d5b3857e6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1979,6 +1979,7 @@ file-filter Templates 範本 @msg invalid image data for `{0}` `{0}` 的影像資料無效 @msg not a VectorCraft file: {0} 不是 VectorCraft 檔案:{0} @msg the compressed file unpacks to more than {_1} MB 壓縮檔解壓縮後超過 {_1} MB +@msg `{key}: {top}` and `options.{key}: {inner}` disagree: give the option once `{key}: {top}` 與 `options.{key}: {inner}` 不一致:該選項只能指定一次 @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files 版本 {0} 的檔案無法壓縮:只有版本 {COMPRESSED_SINCE} 及更新版本才能開啟壓縮檔 @msg a bitmap is empty 有一個點陣圖是空的 @msg a bitmap is too large to read 有一個點陣圖過大,無法讀取 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 35c27b8ba..90078a677 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -699,8 +699,9 @@ fn saved_status(path: &str, r: &Value) -> String { /// Write a planned save through the services and report it in the status bar. /// With Background Save on, the file is encoded and written from a snapshot off the UI thread -/// ([`background`]): this returns `{path, background: true, status}` at once, and the document -/// takes the file (and counts as saved, as of the snapshot) once it is written. +/// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once +/// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format +/// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. @@ -709,6 +710,7 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result } let job = fileio::save_job(&mut app.session, plan).map_err(|e| e.to_string())?; let (retargets, target) = (job.plan().retargets(), job.plan().path.clone()); + let (format, warnings) = (job.plan().format.id, job.known_warnings()); let label = match &target { Some(path) => format!("Saving {}", fileio::file_name(path)), None => "Saving".into(), @@ -743,6 +745,10 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result if let Some(path) = target.filter(|_| r.get("path").is_none()) { r["path"] = json!(path); } + if r.get("background").and_then(Value::as_bool) == Some(true) { + r["format"] = json!(format); + r["warnings"] = json!(warnings); + } Ok(r) } diff --git a/crates/ui-egui/src/tests_automation.rs b/crates/ui-egui/src/tests_automation.rs index 8fa996dcd..063d81188 100644 --- a/crates/ui-egui/src/tests_automation.rs +++ b/crates/ui-egui/src/tests_automation.rs @@ -85,6 +85,28 @@ fn control_requests_stay_inside_the_roots() { let _ = std::fs::remove_dir_all(base); } +/// `app.save` (MCP's `save_file`) writes a native file with the save options it is given, without +/// asking for them. +#[test] +fn control_saves_take_the_native_save_options() { + let (mut app, tx, base) = confined("saveopts"); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let saved = |app: &mut VectorcraftApp, name: &str, mut params: Value| { + let path = base.join("out").join(name); + params["path"] = json!(s(path.clone())); + let r = request(app, &tx, "app.save", params); + assert_eq!(r["ok"], true, "{r}"); + assert!(r["result"].get("pending").is_none() && app.ui.dialog.is_none(), "no dialog opens: {r}"); + std::fs::read(path).unwrap() + }; + assert_eq!(saved(&mut app, "gz.vectorcraft", json!({"compress": true}))[..2], [0x1f, 0x8b]); + let v3: Value = serde_json::from_slice(&saved(&mut app, "v3.vectorcraft", json!({"version": 3}))).unwrap(); + assert_eq!(v3["version"], 3); + assert!(vectorcraft_format::preview(&saved(&mut app, "p.vectorcraft", json!({"preview": true}))).is_some()); + let _ = std::fs::remove_dir_all(base); +} + /// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true` /// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it /// before they reply. Without `wait` the refusal reaches only the status bar. diff --git a/crates/ui-egui/src/tests_background.rs b/crates/ui-egui/src/tests_background.rs index 96e48553b..bc0f67735 100644 --- a/crates/ui-egui/src/tests_background.rs +++ b/crates/ui-egui/src/tests_background.rs @@ -122,6 +122,29 @@ fn exports_run_in_the_background_and_the_preference_turns_both_off() { assert!(app.background.jobs.is_empty()); } +/// A save that returns before the file is written still says what it writes and which options it +/// does not use, as `app.save` (MCP's `save_file`) replies with it: what the format loses first, as +/// the reply of a save written at once. +#[test] +fn a_background_save_names_its_format_and_unused_options_at_once() { + let gate = Gate::default(); + let (mut app, files) = app(gate.clone()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let r = app.run("file.save", json!({"path": "/out/a.svg", "compress": true})).unwrap(); + assert_eq!((r["background"].as_bool(), r["format"].as_str()), (Some(true), Some("svg")), "{r}"); + let warnings = r["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 2, "{r}"); + assert!(warnings[0].as_str().unwrap().starts_with("SVG keeps the artwork but not everything"), "{r}"); + assert_eq!(warnings[1], "`compress` was not used: SVG files don't read it", "{r}"); + gate.open(); + background::wait_all(&mut app); + let r = app.run("file.save", json!({"path": "/out/b.vectorcraft", "compress": true})).unwrap(); + assert_eq!((r["background"].as_bool(), r["format"].as_str()), (Some(true), Some("vectorcraft")), "{r}"); + assert_eq!(r["warnings"], json!([]), "{r}"); + background::wait_all(&mut app); + assert_eq!(written(&files), ["/out/a.svg", "/out/b.vectorcraft"]); +} + #[test] fn a_cancelled_export_writes_nothing() { let (gate, writing) = (Gate::default(), Gate::default()); diff --git a/docs/mcp.md b/docs/mcp.md index 201bd6a91..b8a56499f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -420,7 +420,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, Targa (`.tga`/`.vda`/`.icb`/`.vst`), PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | -| `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). The app is sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | +| `save_file` | `{path?, compress?, version?, preview?, options?}` | 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). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), and `preview: true` embeds a PNG of the first artboard. `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version` or `preview` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version` or `preview`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version` or `preview`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG, SVGZ and EPS write one file per chosen artboard. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `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?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |