diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 4887476908..1108d3e2ca 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -27,7 +27,6 @@ pub const ARTBOARD_PARAMS: [&str; 3] = ["artboard", "artboards", "range"]; /// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based, or `"all"`) wins over /// `artboards` (0-based), which wins over `artboard`. #[derive(Debug, Default, Deserialize)] -#[cfg_attr(test, derive(serde::Serialize))] #[serde(default)] pub struct ArtboardPick { pub artboard: Option, diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 4d4286d525..367bf3c1b6 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -314,7 +314,6 @@ pub(crate) const PARAM_KEYS: [&str; 25] = [ /// What `document.exportForScreens` reads besides the formats. #[derive(Default, Deserialize)] -#[cfg_attr(test, derive(Serialize))] #[serde(default, rename_all = "camelCase")] struct ScreenParams { #[serde(flatten)] @@ -1385,17 +1384,7 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { #[cfg(test)] mod tests { - use super::{Output, PARAM_KEYS, ScreenParams, ScreenSize, safe_file_name}; - - /// Every key `document.exportForScreens` reads into its params is one the key check takes. - #[test] - fn param_keys_hold_every_screen_param() { - let Ok(serde_json::Value::Object(read)) = serde_json::to_value(ScreenParams::default()) else { panic!("ScreenParams is an object") }; - for key in read.keys() { - assert!(PARAM_KEYS.contains(&key.as_str()), "{key}"); - } - assert!(read.len() > 10); - } + use super::{Output, ScreenSize, safe_file_name}; /// Files given one name are numbered in turn, each numbering going on from the last one (so /// thousands of them don't each try every number used before), and a file asking again for diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 212139cb00..e9f88a9d3e 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -536,10 +536,56 @@ const NO_FORMAT: &str = "no format takes it (see document.formats)"; /// Why a save option that no save format reads is refused. const NO_SAVE_FORMAT: &str = "no save format takes it (see file.formatOptions)"; -/// The format ids Export for Screens writes: the writable formats but those it doesn't write. -fn screen_formats() -> Vec<&'static str> { - use super::fileio::{FORMATS, SCREEN_UNWRITTEN_FORMATS}; - FORMATS.iter().filter(|f| f.write && !SCREEN_UNWRITTEN_FORMATS.contains(&f.id)).map(|f| f.id).collect() +/// The format ids Export for Screens writes: the writable formats, those exporter plug-ins add +/// included, but those it doesn't write. +pub fn screen_formats() -> Vec<&'static str> { + use super::fileio::{FORMATS, SCREEN_UNWRITTEN_FORMATS, plugin_formats}; + let added = plugin_formats(); + FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write && !SCREEN_UNWRITTEN_FORMATS.contains(&f.id)).map(|f| f.id).collect() +} + +/// The keys a format row of Export for Screens, and the settings of a format, take: the row's own +/// (`format`, `scale`…), `includeArtboardFill` and `richBlack`, which the encoders read from a +/// row's options (`with_artboard_fills`, the render options), `params`, an exporter plug-in's own, +/// and a key some format reads, as `document.export` takes them. +fn screen_option_keys() -> Vec<&'static str> { + use super::fileio::{FORMATS, SCREEN_ROW_KEYS}; + let mut keys: Vec<&str> = SCREEN_ROW_KEYS.to_vec(); + keys.extend(["includeArtboardFill", "richBlack", "params"]); + keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name)); + keys +} + +/// A format row of Export for Screens, or the settings of a format, without the keys the export +/// refuses in it ([`check_screen_rows`]), inside `marks`, `svg` and the other object options too: +/// a saved row or preset that holds a key that is no longer read loads without it. +pub fn drop_unread_row_options(row: &mut serde_json::Map) { + fn retain_tree(o: &mut serde_json::Map, schema: &serde_json::Map) { + o.retain(|k, _| schema.contains_key(k)); + for (k, v) in o.iter_mut() { + if let (Some(Value::Object(inner)), Value::Object(v)) = (schema.get(k), v) { + retain_tree(v, inner); + } + } + } + let keys = screen_option_keys(); + row.retain(|k, _| keys.contains(&k.as_str())); + let trees = screen_option_trees(); + for (k, v) in row.iter_mut() { + if let (Some(Value::Object(schema)), Value::Object(v)) = (trees.get(k), v) { + retain_tree(v, schema); + } + } +} + +/// [`option_trees`] of Export for Screens: its remembered settings mark PDF security whose +/// passwords were left out with `security.passwordRequired`, which the export reads. +fn screen_option_trees() -> Value { + let mut trees = option_trees(); + if let Some(security) = trees.get_mut("security").and_then(Value::as_object_mut) { + security.insert("passwordRequired".into(), Value::Null); + } + trees } /// The object options an export or save checks key by key: PDF's `marks`, `bleed` and the others, @@ -559,14 +605,10 @@ fn option_trees() -> Value { /// The format rows (`formats`) and the per-format `settings` of Export for Screens params `p`, at /// `within` (`settings.` in `document.exportPresets.save`). A row, and the settings of a format, -/// take its own keys (`format`, `scale`…), `includeArtboardFill`, `richBlack` and a key some -/// format reads, as `document.export` does; `settings` takes the formats Export for Screens writes. -fn check_screen_rows(id: &str, within: &str, p: &serde_json::Map, trees: &Value) -> Result<()> { - use super::fileio::{FORMATS, SCREEN_ROW_KEYS}; - let mut keys: Vec<&str> = SCREEN_ROW_KEYS.to_vec(); - // The encoders read both from a row's options (`with_artboard_fills`, the render options). - keys.extend(["includeArtboardFill", "richBlack"]); - keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name)); +/// take [`screen_option_keys`]; `settings` takes the formats Export for Screens writes. +fn check_screen_rows(id: &str, within: &str, p: &serde_json::Map) -> Result<()> { + let keys = screen_option_keys(); + let trees = &screen_option_trees(); if let Some(Value::Array(rows)) = p.get("formats") { for (i, row) in rows.iter().enumerate() { if let Value::Object(row) = row { @@ -599,23 +641,28 @@ fn check_screen_rows(id: &str, within: &str, p: &serde_json::Map, /// `document.exportDxf` one that DXF reads. `document.save`, `file.saveAs`, `file.saveCopy` and /// `file.saveAsTemplate` take, beside the path and in `options`, a key that some save format /// reads. `document.exportForScreens` takes the keys it reads, and checks its format rows and -/// settings ([`check_screen_rows`]), as `document.exportPresets.save` does in its `settings`. +/// settings ([`check_screen_rows`]), as `document.exportPresets.save` does in its `settings` and +/// `assets.export` and `assets.settings.set` do in theirs. /// Where a command takes `marks`, `bleed`, `flattener`, `svg` or another object option of PDF, /// EPS or SVG, only the object's own keys pass ([`option_trees`]). Saved presets are not checked: /// one with a key that is no longer read keeps loading. fn check_export_options(id: &str, params: &Value) -> Result<()> { - use super::fileio::{FORMATS, SAVE_FORMATS, TEMPLATE_FORMATS, dxf, eps, pdf}; + use super::fileio::{FORMATS, SAVE_FORMATS, SCREEN_PARAM_KEYS, TEMPLATE_FORMATS, dxf, eps, pdf}; let Value::Object(given) = params else { return Ok(()) }; - const NO_SCREENS: &str = "Export for Screens doesn't take it"; match (id, given.get("settings")) { ("document.exportForScreens", _) => { - check_option_keys(id, "", given, &super::fileio::SCREEN_PARAM_KEYS, NO_SCREENS, &Value::Null)?; - return check_screen_rows(id, "", given, &option_trees()); + if let Some(key) = given.keys().find(|k| !SCREEN_PARAM_KEYS.contains(&k.as_str())) { + return Err(unknown(id, key, &SCREEN_PARAM_KEYS)); + } + return check_screen_rows(id, "", given); } ("document.exportPresets.save", Some(Value::Object(settings))) => { - check_option_keys(id, "settings.", settings, &super::fileio::SCREEN_PARAM_KEYS, NO_SCREENS, &Value::Null)?; - return check_screen_rows(id, "settings.", settings, &option_trees()); + let why = format!("`settings` takes: {}", SCREEN_PARAM_KEYS.join(", ")); + check_option_keys(id, "settings.", settings, &SCREEN_PARAM_KEYS, &why, &Value::Null)?; + return check_screen_rows(id, "settings.", settings); } + // Export Assets exports with, and stores, Export for Screens' format rows and settings. + ("assets.export" | "assets.settings.set", _) => return check_screen_rows(id, "", given), _ => {} } let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"]; diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 9db9da6518..b0345db4ff 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -247,7 +247,9 @@ fn save_options_pass_every_listed_key() { #[test] fn export_for_screens_refuses_a_key_no_format_reads() { let id = "document.exportForScreens"; - assert_eq!(refusal(id, json!({"folder": "out", "noSuchOption": true})), "unknown parameter `noSuchOption`; Export for Screens doesn't take it"); + assert!( + refusal(id, json!({"folder": "out", "noSuchOption": true})).starts_with("unknown parameter `noSuchOption`; the command takes: artboard, ") + ); assert_eq!(refusal(id, json!({"subfolder": true})), "unknown parameter `subfolder`; did you mean `subfolders`?"); let no_format = "no format takes it (see document.formats)"; for (given, message) in [ @@ -289,10 +291,21 @@ fn export_for_screens_refuses_a_key_no_format_reads() { refusal(save, json!({"name": "a", "settings": {"formtas": [{"format": "png"}]}})), "unknown parameter `settings.formtas`; did you mean `settings.formats`?" ); - assert_eq!( - refusal(save, json!({"name": "a", "settings": {"noSuchOption": true}})), - "unknown parameter `settings.noSuchOption`; Export for Screens doesn't take it" + assert!( + refusal(save, json!({"name": "a", "settings": {"noSuchOption": true}})) + .starts_with("unknown parameter `settings.noSuchOption`; `settings` takes: artboard, ") ); + // Export Assets takes the same rows and settings, and stores them. + for asset in ["assets.export", "assets.settings.set"] { + assert_eq!( + refusal(asset, json!({"formats": [{"format": "png", "noSuchOption": 1}]})), + format!("unknown parameter `formats[0].noSuchOption`; {no_format}") + ); + assert!(refusal(asset, json!({"settings": {"pdf": {"marks": {"x": 1}}}})).starts_with("unknown parameter `settings.pdf.marks.x`;")); + check_params(asset, &json!({"formats": [{"format": "jpg", "scale": "2x", "quality": 80}]})).unwrap(); + } + // An exporter plug-in's `params` in a row (its format is a plug-in's: tests/plugin_export.rs). + check_params(id, &json!({"formats": [{"format": "png", "params": {}}]})).unwrap(); // Keys the preset leaves out are still Export for Screens params. check_params(save, &json!({"name": "a", "settings": {"zip": true, "formats": [{"format": "jpg", "quality": 80}]}})).unwrap(); // The encoders read Include Artboard Fill and Appearance of Black from a row or a format's @@ -313,6 +326,11 @@ fn export_for_screens_passes_every_listed_key() { let id = "document.exportForScreens"; let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys; assert!(keys.len() > 20, "{keys:?}"); + // The keys the check takes are the ones the description names. + let (mut named, mut taken) = (keys.clone(), crate::cmd::fileio::SCREEN_PARAM_KEYS.to_vec()); + named.sort_unstable(); + taken.sort_unstable(); + assert_eq!(named, taken); let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect(); assert_eq!(refused, Vec::<&str>::new(), "{id} names and refuses these"); check_params(id, &json!({"artboard": 0, "openLocation": true})).unwrap(); @@ -333,6 +351,34 @@ fn export_for_screens_passes_every_listed_key() { } } +/// A saved row loses the keys Export for Screens refuses in it, inside its object options too, and +/// keeps the others. +#[test] +fn a_saved_row_drops_the_keys_no_format_reads() { + use crate::cmd::params::drop_unread_row_options; + let serde_json::Value::Object(mut row) = json!({ + "format": "pdf", "scale": "1x", "noSuchOption": 1, "params": {"x": 1}, + "marks": {"useDocumentBleed": true, "trim": true}, "preset": "High Quality Print", + "security": {"passwordRequired": true, "noSuchLock": 1} + }) else { + panic!("an object") + }; + drop_unread_row_options(&mut row); + assert_eq!( + serde_json::Value::Object(row.clone()), + json!({ + "format": "pdf", "scale": "1x", "params": {"x": 1}, "marks": {"trim": true}, "preset": "High Quality Print", + "security": {"passwordRequired": true} + }) + ); + check_params("document.exportForScreens", &json!({"formats": [row]})).unwrap(); + // Remembered security whose passwords were left out, with one given again. + let security = json!({"passwordRequired": true, "openPassword": "x"}); + for id in ["document.exportForScreens", "assets.export", "assets.settings.set"] { + check_params(id, &json!({"formats": [{"format": "pdf", "security": security}], "settings": {"pdf": {"security": security}}})).unwrap(); + } +} + /// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a /// misnamed one is refused instead of answering `objects: 1` with nothing changed. #[test] diff --git a/crates/engine/tests/plugin_export.rs b/crates/engine/tests/plugin_export.rs index 1a49f800a9..c3f0ecdd33 100644 --- a/crates/engine/tests/plugin_export.rs +++ b/crates/engine/tests/plugin_export.rs @@ -152,6 +152,22 @@ fn the_exporter_is_given_its_parameters_and_the_file_it_writes() { let _ = std::fs::remove_dir_all(&dir); } +/// Export for Screens writes an exporter's format from a row, with the row's `params`, and takes +/// settings for that format. +#[test] +fn export_for_screens_takes_an_exporter_s_format_and_params() { + let mut s = session(); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.screens", r#"["tss"]"#, params), Echo::Params)); + let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "plugin.test.screens", "params": {"density": 7}}]})).unwrap(); + let file = &r["files"][0]; + assert!(file["name"].as_str().unwrap().ends_with(".tss"), "{r}"); + let got: Value = serde_json::from_slice(&vectorcraft_format::base64_decode(file["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(got["density"], json!(7.0)); + let settings = json!({"formats": [{"format": "plugin.test.screens"}], "settings": {"plugin.test.screens": {"params": {"density": 2}}}}); + s.execute("document.exportForScreens", &settings).unwrap(); +} + /// Only the selected objects, with `selectedOnly`, as for the built-in formats. #[test] fn selected_only_hands_over_the_selection() { diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index f54250e0fa..59b3f973f8 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -137,11 +137,7 @@ impl Headless { "file.open" => self.open(params), "file.save" => self.save(params), "file.export" => self.export(params), - "file.exportForScreens" => { - // The key check of `document.exportForScreens` runs before any file is written. - self.session.check_params("document.exportForScreens", params).map_err(|e| e.to_string())?; - self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()) - } + "file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()), "tool.select" => self.select_tool(params), "tool.setOption" => self.session.set_tool_option_cmd(params), _ => self.session.execute(id, params).map_err(|e| e.to_string()), @@ -313,8 +309,6 @@ impl Headless { if s(p, "path").is_none() && st.path.is_none() { return Err("missing `path` (document was never saved)".into()); } - // The key check of `document.save` runs before the file is written. - self.session.check_params("document.save", p).map_err(|e| e.to_string())?; let roots = self.roots.clone(); file_access::confine(roots.as_ref(), || self.session.execute("document.save", p)).map_err(|e| e.to_string()) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a7766dd8da..151fbec94c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -139,6 +139,7 @@ pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices}; pub use export_as::open as open_export_as; +pub(crate) use export_for_screens::drop_unread_screen_settings; pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ KIND as EXPORT_FOR_SCREENS, PresetPick as ScreenPresetPick, export_preset as screen_export_preset, formats as screen_formats, diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 24f2d7367d..d465ddc0d2 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -27,6 +27,7 @@ use serde::Deserialize; use serde_json::{Map, Value, json}; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, ScreenSize, pdf}; +use vectorcraft_engine::cmd::params::{drop_unread_row_options, screen_formats}; use super::{DialogSpec, form, png_options, svg_options}; use crate::state::Dialog; @@ -212,12 +213,29 @@ pub(crate) fn saved_options(app: &VectorcraftApp, saved: &Map, id options.insert("antiAlias".into(), aa.clone()); } if let Some(o) = saved.get("settings").and_then(|v| v.get(id)).and_then(Value::as_object) { - options.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + let mut o = o.clone(); + drop_unread_row_options(&mut o); + options.extend(o); } options } -/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`). +/// Export settings `settings` (as `document.exportSettings` or a preset holds them) without the +/// keys the export no longer reads in its rows and per-format settings, and without settings for a +/// format it doesn't write. +pub(crate) fn drop_unread_screen_settings(settings: &mut Map) { + if let Some(rows) = settings.get_mut("formats").and_then(Value::as_array_mut) { + rows.iter_mut().filter_map(Value::as_object_mut).for_each(drop_unread_row_options); + } + if let Some(all) = settings.get_mut("settings").and_then(Value::as_object_mut) { + let formats = screen_formats(); + all.retain(|id, _| formats.contains(&id.as_str())); + all.values_mut().filter_map(Value::as_object_mut).for_each(drop_unread_row_options); + } +} + +/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`), without +/// the keys the export no longer reads. fn editable_row(row: &Value) -> Value { let num = |k: &str| row.get(k).and_then(Value::as_f64); let text = match (num("width"), num("height"), num("ppi"), row.get("scale")) { @@ -230,6 +248,7 @@ fn editable_row(row: &Value) -> Value { }; let mut r = row.clone(); if let Some(o) = r.as_object_mut() { + drop_unread_row_options(o); for k in ["width", "height", "ppi"] { o.remove(k); } @@ -879,7 +898,11 @@ pub(super) fn apply_preset(d: &mut Dialog, preset: &Value) { d.fields.insert("formats".into(), json!(rows)); d.fields.insert("preset".into(), json!(p)); } - if let (Some(all), Some(given)) = (d.fields.get_mut("settings").and_then(Value::as_object_mut), st["settings"].as_object()) { + // Settings for a format the export doesn't write, and keys it no longer reads, are left out. + let mut given = Map::from_iter([("settings".to_string(), st["settings"].clone())]); + drop_unread_screen_settings(&mut given); + if let (Some(all), Some(given)) = (d.fields.get_mut("settings").and_then(Value::as_object_mut), given.get("settings").and_then(Value::as_object)) + { for (id, o) in given { match (all.get_mut(id).and_then(Value::as_object_mut), o.as_object()) { (Some(mine), Some(o)) => mine.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))), diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 6b7e13df88..28939b0683 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -380,6 +380,19 @@ mod tests { assert_eq!(app.ui.dialog.as_ref().unwrap().f64("decimals", 0.0), 1.0); } + /// A dialog a save panel opened carries the dialog layer's own `__` fields; the save is given + /// only the SVG options, so it is not refused for an unknown key. + #[test] + fn save_as_svg_from_a_save_panel_passes_only_the_options() { + let (mut app, written) = app("/tmp/panel.svg"); + app.run("file.saveAs", Value::Null).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert(crate::dialogs::AFTER_PANEL.into(), json!(true)); + assert!(options(app.ui.dialog.as_ref().unwrap()).get(crate::dialogs::AFTER_PANEL).is_none()); + let d = app.ui.dialog.take().unwrap(); + confirm(&mut app, &d).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(written.borrow()[0].0, "/tmp/panel.svg"); + } + #[test] fn all_artboards_export_one_file_each() { let (mut app, written) = app("/tmp/set.svg"); diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 3bfda2ab2b..140623f209 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -957,6 +957,34 @@ fn saving_an_applied_preset_keeps_its_bleed_and_fill() { assert_eq!((s.get("includeBleed"), s.get("includeArtboardFill")), (Some(&json!(true)), Some(&json!(false)))); } +/// Remembered settings and a preset that hold keys the export no longer reads, in a row, in a +/// format's settings and inside an object option, and settings for a format it doesn't write, open +/// without them, so the dialog's export is not refused. +#[test] +fn saved_keys_no_longer_read_are_left_out_of_the_dialog_s_export() { + let (mut app, _) = app(1, false); + let stale = json!({ + "formats": [{"format": "png", "scale": "2x", "noSuchOption": 1}, {"format": "pdf", "marks": {"trim": true, "noSuchMark": 1}}], + "settings": {"png": {"noSuchSetting": true}, "dxf": {}, "pdf": {"bleed": {"top": 1, "inner": 2}}} + }); + let Value::Object(saved) = stale.clone() else { panic!("an object") }; + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).export_settings = saved; + let folder = temp_folder("stale"); + let check = |app: &VectorcraftApp| { + let p = export_for_screens::params(app, app.ui.dialog.as_ref().unwrap()).unwrap(); + app.session.check_params("document.exportForScreens", &p).unwrap_or_else(|e| panic!("{e}: {p}")); + p + }; + open(&mut app); + set(&mut app, "folder", json!(folder.to_string_lossy())); + let p = check(&app); + assert_eq!(p["formats"][0]["scale"], json!("2x")); + assert_eq!(p["formats"][1]["marks"]["trim"], json!(true)); + export_for_screens::apply_preset(app.ui.dialog.as_mut().unwrap(), &json!({"name": "Old", "settings": stale})); + check(&app); + let _ = std::fs::remove_dir_all(&folder); +} + /// A preset that puts each item in its own folder (`item` or `itemFormat`, which the dialog has no /// control for) keeps that folder mode when it is exported or saved again from the dialog, until /// the sub-folder controls change (in the dialog or by `ui.dialog.set`). diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 872bcc0d62..039231fb7d 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -21,8 +21,8 @@ use vectorcraft_engine::cmd::fileio::{SCREEN_SETTINGS_KEYS, screen_export_preset use super::{pstate, set_pstate}; use crate::dialogs::{ - EXPORT_FOR_SCREENS, ScreenPresetPick, open_export_for_screens_assets, screen_export_preset, screen_formats, screen_import_presets, - screen_preset_list, screen_saved_options, screen_saved_rows, + EXPORT_FOR_SCREENS, ScreenPresetPick, drop_unread_screen_settings, open_export_for_screens_assets, screen_export_preset, screen_formats, + screen_import_presets, screen_preset_list, screen_saved_options, screen_saved_rows, }; use crate::state::Dialog; use crate::theme::Tokens; @@ -225,7 +225,10 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { let saved = &st.doc.export_settings; let mut settings = saved.get("settings").and_then(Value::as_object).cloned().unwrap_or_default(); settings.insert("tiff".into(), Value::Object(screen_saved_options(app, saved, "tiff"))); - p["settings"] = Value::Object(settings); + // Without the keys the export no longer reads, which it would refuse. + let mut given = serde_json::Map::from_iter([("settings".to_string(), Value::Object(settings))]); + drop_unread_screen_settings(&mut given); + p["settings"] = given.remove("settings").unwrap_or_default(); } if io::is_web(app) { p["zip"] = json!(ids.len() * files_each > 1); @@ -481,6 +484,7 @@ fn preset_settings(app: &VectorcraftApp, ctx: &egui::Context, doc: u64) -> Value settings.remove("preset"); } settings.extend(shared); + drop_unread_screen_settings(&mut settings); Value::Object(settings) } @@ -503,6 +507,7 @@ fn shared_settings(app: &VectorcraftApp) -> Value { settings.insert("formats".into(), json!(rows)); } } + drop_unread_screen_settings(&mut settings); Value::Object(settings) } diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index 3c686d3352..14bee8bf81 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -598,6 +598,29 @@ fn tiff_photometric(bytes: &[u8]) -> u16 { short(entry + 8) } +/// Remembered format settings that hold keys the export no longer reads, and settings for a format +/// it doesn't write, are left out of the panel's export, so it is not refused. +#[test] +fn export_leaves_out_remembered_keys_no_longer_read() { + let (mut app, log, [a, _]) = app(true); + app.run("assets.add", json!({"ids": [a], "name": "Badge"})).unwrap(); + let serde_json::Value::Object(saved) = json!({ + "formats": [{"format": "tiff", "scale": "1x", "noSuchOption": 1}], + "settings": {"png": {"noSuchSetting": true}, "dxf": {}, "tiff": {"lzw": false, "noSuchSetting": 1}} + }) else { + panic!("an object") + }; + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).export_settings = saved; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + let log = log.borrow(); + let [(name, _)] = log.as_slice() else { panic!("expected one TIFF: {}", app.ui.status) }; + assert_eq!(name, "Badge.tif"); +} + #[test] fn tiff_cmyk_defaults_match_screens_and_preserve_explicit_rgb() { for rgb_override in [false, true] { diff --git a/docs/mcp.md b/docs/mcp.md index ebe0f18f42..f16781f63a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1196,11 +1196,15 @@ that only another save format takes. It warns when that key is given in `options `document.exportForScreens` and `file.exportForScreens` refuse a key the command doesn't take. In each row of `formats` and in the options `settings` gives a format, they refuse a key that no format takes, as `document.export` does, and an unknown key inside a PDF, EPS or SVG option object; `settings` takes only the formats the command writes. A row and a -format's settings also take `includeArtboardFill` and `richBlack`. The error names the row -(`formats[1].marks.useDocumentBleed`). `document.exportPresets.save` checks its `settings`, with the rows and settings -in it, the same way. +format's settings also take `includeArtboardFill`, `richBlack`, `params` (an exporter plug-in's own) and +`security.passwordRequired` (which remembered settings hold where PDF passwords were left out), and `settings` +takes an exporter plug-in's format. The error names the row (`formats[1].marks.useDocumentBleed`). +`document.exportPresets.save` checks its `settings`, with the rows and settings in it, the same way, and `assets.export` +and `assets.settings.set` check the rows and settings they are given. -A saved PDF preset or Export for Screens preset that holds a key that is no longer read keeps loading. +A saved PDF preset or Export for Screens preset that holds a key that is no longer read keeps loading. The Export for +Screens dialog opens remembered settings and presets without such keys, and the Asset Export panel leaves them out of +the settings it sends, so their export is not refused. An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ffd1205f67..9ee06c8e59 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -708,7 +708,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. - - Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`. Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`). + - Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`. Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`); Export Assets (`assets.export`, `assets.settings.set`) checks the rows and settings it is given the same way. - The errors of Enter Isolation Mode (`object.isolate`) for a type object, for hidden or locked art and for a call with no `id` while nothing is selected ("missing id", which other commands that take an `id` also give), and the error of the Layers panel's target circle (`layer.target`) for an object outside the isolated group, show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch. - An EPS or PostScript file that stops on a PostScript error gives the error's name as PostScript spells it (`typecheck`, `rangecheck` and the other names the reader raises, except `undefined`, whose message names the unknown name) followed by what it means, in the warning after opening and in the error when the file can't be opened. The status bar and the open-error dialog show the meaning in the interface language: each meaning has a row in every catalog (`postscript_error_meanings_are_translated`). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.