collective

Browse / github.com/storytold/vectorcraft / patch

EPS in Save As, and the Tools panel docks at the right edge too

EPS in Save As, and the Tools panel docks at the right edge too ## What and why Two independent changes, one commit each. **1. File formats: EPS in Save As and Save a Copy** (`4aff6cf6`) EPS was only reachable through Export, although an EPS file VectorCraft writes already carries the native document and reopens exactly. EPS now joins `SAVE_FORMATS` (appended), so it can be the document's own file: - Save As / Save a Copy in EPS ask with the existing EPS Options dialog (its OK reads "OK" there and saves instead of exporting); the web Save As dialog hands EPS on to it, like SVG and PDF. - Save writes the EPS back with the options it was saved with. -

Status
open
Score
0.00
Adopters
0 other operators
Author
vecteza (imported from pull request #3365, open)
Base
75dacb4fec73dac539538c91f92d41e09b255d23
Size
+389 −68 in 42 files
Review
pending
Shared
2026-10-28
Id
cbd1b87e485ad06d9fc954d7

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/fileio/load.rs crates/engine/src/cmd/fileio/save.rs crates/engine/src/tests_recovery.rs crates/engine/src/tests_save.rs crates/mcp/src/tests.rs crates/ui-egui/src/dialogs/eps_options.rs crates/ui-egui/src/dialogs/save_options.rs crates/ui-egui/src/dialogs/tests_eps.rs crates/ui-egui/src/edit_toolbar.rs crates/ui-egui/src/floating.rs crates/ui-egui/src/i18n/bg.tsv crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/da.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fi.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ka.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sv.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv crates/ui-egui/src/io.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panel_docking.rs crates/ui-egui/src/state.rs crates/ui-egui/src/toolbar.rs crates/ui-egui/src/workspaces.rs docs/file-format-parity.md docs/mcp.md

diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs--- a/crates/engine/src/cmd/fileio/load.rs+++ b/crates/engine/src/cmd/fileio/load.rs@@ -443,11 +443,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St         doc.layers_open = Some(vec![]);     }     // Save writes the file back only in a format it writes, only a whole PDF (see-    // [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save-    // writes .ai that way).+    // [`LoadOptions::is_partial`]), and a .ai or EPS file only when it carried the native document+    // (Save writes them that way).     let partial = format.id == "pdf" && opts.is_partial();-    let lossy_ai = format.id == "ai" && !restored;-    let path = path.filter(|_| !template && !partial && !lossy_ai && SAVE_FORMATS.contains(&format.id));+    let lossy = matches!(format.id, "ai" | "eps") && !restored;+    let path = path.filter(|_| !template && !partial && !lossy && SAVE_FORMATS.contains(&format.id));     let saves_back = path.is_some();     let converted = saves_back && converted && s.prefs.append_converted;     let index = s.add_document(doc, path);diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs--- a/crates/engine/src/cmd/fileio/save.rs+++ b/crates/engine/src/cmd/fileio/save.rs@@ -20,7 +20,7 @@ use crate::{DocState, Prefs};  /// The formats Save As offers, in menu order (append-only). The other writable formats are exports: /// they never become the document's own file.-pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"];+pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai", "eps"];  /// The formats Save as Template writes, the default first: a native template, or a /// PDF-compatible `.ait` (written as a `.ai` file is).@@ -312,9 +312,10 @@ fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {     Arc::new(d) } -/// What a format loses against a native file (reported whenever a save writes it).+/// What a format loses against a native file (reported whenever a save writes it). An EPS file+/// always carries the native document, which reopening it restores. fn fidelity_warning(f: &Format) -> Option<String> {-    (!is_native(f)).then(|| {+    (!is_native(f) && f.id != "eps").then(|| {         format!(             "{} keeps the artwork but not everything a VectorCraft document holds (editable effects, symbols, swatches, styles): save as VectorCraft to keep it all editable",             f.label@@ -778,7 +779,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {             "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 <name>-<artboard>.<ext> 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|eps (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 <name>-<artboard>.<ext> 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), …for .eps the options of document.exportEps (level, previewFormat, flattenerPreset; one file of the visible art, or of an artboard with useArtboards), 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; an EPS file carries the native document too, which document.open restores; 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)         ),@@ -787,7 +788,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {             "Save As…",             ["File"],             Some("Cmd+Shift+S"),-            "{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",+            "{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",             has_doc,             |s, p| save(s, SaveMode::SaveAs, p)         ),diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs--- a/crates/engine/src/tests_recovery.rs+++ b/crates/engine/src/tests_recovery.rs@@ -138,6 +138,30 @@ fn a_crash_leaves_copies_that_restore_modified_and_titled() {     assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy"); } +/// A document whose own file is an EPS gets recovery copies too, and a restored one saves as EPS+/// again (once a path is chosen).+#[test]+fn an_eps_documents_copy_restores_as_eps() {+    let (mut s, store) = session();+    let dir = std::env::temp_dir().join(format!("vc-recovery-eps-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let eps = dir.join("Poster.eps").to_string_lossy().to_string();+    s.execute("file.saveAs", &json!({"path": eps, "options": {"previewFormat": "none"}})).unwrap();+    s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();+    let r = s.execute("file.recovery.save", &json!({})).unwrap();+    assert_eq!(r["saved"].as_array().unwrap().len(), 1, "the modified EPS document is copied: {r}");+    let nodes = s.doc().unwrap().doc.node_count();+    drop(s);++    let mut s = relaunch(&store);+    assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);+    let st = s.doc().unwrap();+    assert_eq!((st.path.as_deref(), st.format, st.doc.node_count()), (Some(eps.as_str()), "eps", nodes));+    let saved = s.execute("document.save", &json!({})).unwrap();+    assert_eq!((saved["format"].as_str(), saved["name"].as_str()), (Some("eps"), Some("Poster.eps")), "{saved}");+    let _ = std::fs::remove_dir_all(dir);+}+ #[test] fn a_restored_copy_still_refuses_to_replace_the_file_an_import_left_things_out_of() {     let (mut s, store) = session();diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs--- a/crates/engine/src/tests_save.rs+++ b/crates/engine/src/tests_save.rs@@ -67,6 +67,43 @@ fn an_svg_path_writes_svg_and_save_writes_it_back() {     let _ = std::fs::remove_dir_all(d); } +#[test]+fn save_as_eps_carries_the_document_and_save_writes_it_back() {+    let d = dir("eps");+    let mut s = session();+    rect(&mut s);+    let eps = path(&d, "art.eps");+    let r = s.execute("file.saveAs", &json!({"path": eps, "options": {"previewFormat": "none", "level": 2}})).unwrap();+    assert_eq!(r["format"], "eps");+    assert_eq!(r["warnings"], json!([]), "the file carries the whole document: {r}");+    assert!(std::fs::read(&eps).unwrap().starts_with(b"%!PS-Adobe-3.0 EPSF-3.0"));+    let st = s.doc().unwrap();+    assert_eq!((st.path.as_deref(), st.format, st.is_dirty()), (Some(eps.as_str()), "eps", false));+    assert_eq!(Value::Object(st.save_options.clone()), json!({"previewFormat": "none", "level": 2}));+    // Reopened, it is the same document, keeps its path and saves as EPS again.+    let mut s = Session::new();+    let r = s.execute("document.open", &json!({"path": eps})).unwrap();+    assert_eq!((&r["restored"], &r["format"]), (&json!(true), &json!("eps")), "{r}");+    assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().format), (Some(eps.as_str()), "eps"));+    let n = objects(&s);+    rect(&mut s);+    assert_eq!(s.execute("document.save", &json!({})).unwrap()["format"], "eps");+    let mut s = Session::new();+    s.execute("document.open", &json!({"path": eps})).unwrap();+    assert_eq!(objects(&s), n + 1, "the second rectangle was saved");+    // An EPS file another app wrote (no document of ours in it) is never saved over.+    let foreign = path(&d, "foreign.eps");+    let bytes = std::fs::read(&eps).unwrap();+    let at = bytes.windows(30).position(|w| w == b"%VectorCraft_BeginData: native").unwrap();+    let mut patched = bytes.clone();+    patched[at + 29] = b'x';+    std::fs::write(&foreign, patched).unwrap();+    let r = s.execute("document.open", &json!({"path": foreign})).unwrap();+    assert_eq!(r["restored"], false, "{r}");+    assert_eq!(s.doc().unwrap().path, None);+    let _ = std::fs::remove_dir_all(d);+}+ #[test] fn save_as_takes_on_path_format_and_options() {     let d = dir("saveas");@@ -84,7 +121,7 @@ fn save_as_takes_on_path_format_and_options() {     let svg = s.execute("file.formatOptions", &json!({"format": "svg"})).unwrap();     assert_eq!(svg["options"]["outlineText"]["value"], false, "another format shows its defaults");     let ids: Vec<&str> = o["saveFormats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();-    assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]);+    assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai", "eps"]);     // SVGZ is gzip-compressed SVG and reopens as SVGZ.     let svgz = path(&d, "doc.svgz");     s.execute("file.saveAs", &json!({"path": svgz})).unwrap();diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs--- a/crates/mcp/src/tests.rs+++ b/crates/mcp/src/tests.rs@@ -455,7 +455,7 @@ fn add_text_sets_writing_direction_for_every_kind() { fn save_file_describes_the_formats_it_writes() {     let tools = tool_definitions();     let d = tools.iter().find(|t| t["name"] == "save_file").and_then(|t| t["description"].as_str()).unwrap();-    for ext in [".vectorcraft", ".vctemplate", ".pdf", ".svg", ".svgz", ".ai"] {+    for ext in [".vectorcraft", ".vctemplate", ".pdf", ".svg", ".svgz", ".ai", ".eps"] {         assert!(d.contains(ext), "{ext}: {d}");     }     assert!(d.contains("its own format"), "{d}");diff --git a/crates/ui-egui/src/dialogs/eps_options.rs b/crates/ui-egui/src/dialogs/eps_options.rs--- a/crates/ui-egui/src/dialogs/eps_options.rs+++ b/crates/ui-egui/src/dialogs/eps_options.rs@@ -5,9 +5,11 @@ //! //! Fields: `document.export` params (`format: eps`, `path?`, the artboard choice) and the EPS //! options of `document.exportEps`. OK remembers the options for next time and writes the file-//! (asking where when there is no path); bad options keep the dialog open.+//! (asking where when there is no path); bad options keep the dialog open. File → Save As and Save a+//! Copy in EPS ask with it too (`__save`: the save command), and its OK then saves the file.  use serde_json::{Map, Value, json};+use vectorcraft_engine::cmd::fileio::SaveMode; use vectorcraft_engine::cmd::{FlattenOptions, fileio}; use vectorcraft_eps::{Level, Overprint, Preview}; @@ -23,6 +25,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {     body,     confirm,     ok: Some("Export"),+    // OK when it finishes a Save As or Save a Copy.+    ok_label: Some(|app| if app.ui.dialog.as_ref().is_some_and(saving) { "OK" } else { "Export" }),     min_width: 420.0,     max_width: Some(460.0),     ..DialogSpec::FORM@@ -121,16 +125,29 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {     false } -/// Check the options, remember them, and write the file(s).+/// Does the dialog finish a save (Save As or Save a Copy in EPS) rather than an export?+fn saving(d: &Dialog) -> bool {+    SaveMode::of(&d.str("__save")).is_some()+}++/// Check the options, remember them, and write the file(s): an export, or the save `__save` names+/// (the document's own EPS file, which carries the whole document; the export's artboard choice+/// doesn't apply). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {     let params = fileio::eps::resolve(&super::form::params(d), &app.session.prefs.flattener_presets)?;+    let save = SaveMode::of(&d.str("__save"));     // Nothing selected keeps the dialog open too.-    if let Some(st) = app.session.active() {+    if let (Some(st), None) = (app.session.active(), save) {         fileio::export_source(st, &params).map_err(|e| e.to_string())?;     }     app.ui.eps_options =         Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect());     app.ui.dialog = None;     let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);-    io::export(app, Some("eps"), path, &params)+    let Some(mode) = save else { return io::export(app, Some("eps"), path, &params) };+    let options: Map<String, Value> = params+        .as_object()+        .map(|o| o.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect())+        .unwrap_or_default();+    io::save(app, mode, &json!({ "path": path, "format": "eps", "options": options }), false) }diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs--- a/crates/ui-egui/src/dialogs/save_options.rs+++ b/crates/ui-egui/src/dialogs/save_options.rs@@ -1,6 +1,6 @@ //! A save format's options before the file is written; on the web, where there is no save panel,-//! also the Save As dialog that names the file and picks the format (SVG and PDF then go on to-//! their own dialogs). The rows come from the format's options in the engine's format table, so a+//! also the Save As dialog that names the file and picks the format (SVG, PDF and EPS then go on+//! to their own dialogs). The rows come from the format's options in the engine's format table, so a //! format gains rows as its encoder gains options. //! //! Fields: `__action` (the save command it finishes), `__pick` (file name and format@@ -62,10 +62,10 @@ pub(crate) fn option_values(app: &mut VectorcraftApp, f: &Format) -> Map<String,         .collect() } -/// Formats whose options have a dialog of their own (SVG Options, Save PDF), which follows this-/// one.+/// Formats whose options have a dialog of their own (SVG Options, Save PDF, EPS Options), which+/// follows this one. fn own_dialog(f: &Format) -> bool {-    matches!(f.id, "svg" | "svgz" | "pdf")+    matches!(f.id, "svg" | "svgz" | "pdf" | "eps") }  /// Does `f` save each artboard to a separate file on request (native and `.ai` files)? Its@@ -544,7 +544,7 @@ mod tests {         // The save panel offers every save format, the document's own first.         let pick = &picks.borrow()[0];         let labels: Vec<&str> = pick.filters.iter().map(|f| f.0.as_str()).collect();-        assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]);+        assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai", "EPS"]);         assert_eq!(pick.name, "Untitled-1.vectorcraft");         let d = dialog(&app);         assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save"));diff --git a/crates/ui-egui/src/dialogs/tests_eps.rs b/crates/ui-egui/src/dialogs/tests_eps.rs--- a/crates/ui-egui/src/dialogs/tests_eps.rs+++ b/crates/ui-egui/src/dialogs/tests_eps.rs@@ -67,6 +67,36 @@ fn eps_options_take_saved_flattener_presets_and_refuse_bad_ones() {     assert!(written.borrow()[0].1.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0"), "{:?}", &written.borrow()[0].1[..8]); } +#[test]+fn save_as_eps_asks_with_eps_options_and_save_reuses_them() {+    let (mut app, written) = app(1);+    assert_eq!(app.run("file.saveAs", json!({"format": "eps"})).unwrap()["pending"], "epsOptions");+    let d = app.ui.dialog.as_ref().unwrap();+    assert_eq!((d.str("path"), d.str("__save")), ("/out/Untitled-1.eps".into(), "file.saveAs".into()));+    frame(&mut app);+    set(&mut app, "previewFormat", json!("none"));+    set(&mut app, "level", json!("2"));+    confirm(&mut app).unwrap();+    assert!(app.ui.dialog.is_none());+    assert_eq!(names(&written), ["/out/Untitled-1.eps"]);+    assert!(written.borrow()[0].1.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0"), "no preview: plain PostScript");+    let st = app.session.active().unwrap();+    assert_eq!((st.path.as_deref(), st.format, st.is_dirty()), (Some("/out/Untitled-1.eps"), "eps", false));+    assert_eq!((st.save_options["previewFormat"].clone(), st.save_options["level"].clone()), (json!("none"), json!("2")));+    assert!(vectorcraft_eps::has_native(&written.borrow()[0].1), "the file carries the document");+    // Save writes EPS again with those options, without asking.+    app.run("shape.rectangle", json!({"x": 40, "y": 5, "width": 10, "height": 10})).unwrap();+    app.run("file.save", json!({})).unwrap();+    assert!(app.ui.dialog.is_none());+    assert_eq!(names(&written), ["/out/Untitled-1.eps", "/out/Untitled-1.eps"]);+    assert!(String::from_utf8_lossy(&written.borrow()[1].1).contains("%%LanguageLevel: 2"));+    // Save a Copy as EPS starts from them, and its OK reads OK, not Export.+    app.run("file.saveCopy", json!({"format": "eps"})).unwrap();+    let d = app.ui.dialog.as_ref().unwrap();+    assert_eq!((d.str("__save"), d.str("previewFormat")), ("file.saveCopy".into(), "none".into()));+    assert_eq!(super::spec("epsOptions").ok_label.map(|f| f(&app)), Some("OK"));+}+ #[test] fn the_eps_options_dialog_needs_a_document() {     let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs--- a/crates/ui-egui/src/edit_toolbar.rs+++ b/crates/ui-egui/src/edit_toolbar.rs@@ -411,7 +411,7 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {     let screen = ctx.content_rect();     let id = drawer_id();     let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(WIDTH, 200.0), |r| r.size());-    let x = (bar.right() + GAP).min(screen.right() - size.x - GAP).max(screen.left());+    let x = crate::toolbar::beside(bar, size.x, GAP, crate::toolbar::room(app, ctx).shrink(GAP));     // Never above the toolbar's top: higher up are the window's title bar (and on macOS its     // buttons) and the bars under it.     let top = bar.top().max(screen.top() + GAP);diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs--- a/crates/ui-egui/src/floating.rs+++ b/crates/ui-egui/src/floating.rs@@ -74,6 +74,8 @@ struct Move { pub(crate) enum Drop {     /// Back in the dock (the Tools panel: at the window's left edge).     Dock,+    /// The Tools panel docked on the right, left of the panel dock.+    DockRight,     /// Stacked with the floating group holding this panel.     Stack(&'static str),     /// In a set with the floating group holding this panel, below it.@@ -154,6 +156,11 @@ pub(crate) fn tools_zone_id() -> Id {     Id::new("floating-tools-zone") } +/// Where a Tools panel dropped docks on the right: the strip left of the panel dock.+pub(crate) fn tools_zone_right_id() -> Id {+    Id::new("floating-tools-zone-right")+}+ /// The area of the floating group whose first panel is `first` (of the set it heads). pub fn area_id(first: &str) -> Id {     Id::new(("floating-panels", first))@@ -418,7 +425,16 @@ pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: & /// Where the pointer at `at` would drop `what`, and the zone that lights up for it. pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {     let zone = |id: Id| ctx.data(|d| d.get_temp::<Rect>(id)).filter(|r| r.contains(at));-    let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) };+    let Moving::Panel(id) = what else {+        // Left of the panel dock, or anywhere along the window's right edge: it docks left of+        // the panels either way, so that place lights up.+        let right = ctx.data(|d| d.get_temp::<Rect>(tools_zone_right_id()));+        let at_edge = at.x >= ctx.content_rect().right() - crate::toolbar::WIDTH && right.is_some_and(|r| r.y_range().contains(at.y));+        return zone(tools_zone_id())+            .map(|r| (Drop::Dock, r))+            .or_else(|| zone(tools_zone_right_id()).map(|r| (Drop::DockRight, r)))+            .or_else(|| right.filter(|_| at_edge).map(|r| (Drop::DockRight, r)));+    };     let own = group_of(&app.ui, id);     let moving = own.map(|i| set_of(&app.ui, i)).unwrap_or_default();     // The left edge of the icon column or of a dock column docks the moved groups as a new column@@ -560,7 +576,7 @@ pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) {     match target {         Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect),         Drop::Stack(_) | Drop::Root(..) => tab_zone(ctx, rect),-        Drop::Dock | Drop::Icons => highlight(ctx, rect),+        Drop::Dock | Drop::DockRight | Drop::Icons => highlight(ctx, rect),     } } @@ -588,6 +604,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {         app.ui.open_panel = m.original.open_panel;         app.ui.dock_tab = m.original.dock_tab;         app.ui.toolbar_pos = m.original.toolbar_pos;+        app.ui.toolbar_right = m.original.toolbar_right;         ctx.data_mut(|data| {             data.remove::<Move>(move_id());             data.insert_temp(suppressed_id(), true);@@ -626,6 +643,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {     let Some((drop, _)) = target else { return };     let Moving::Panel(id) = m.what else {         app.ui.toolbar_pos = None;+        app.ui.toolbar_right = drop == Drop::DockRight;         return;     };     // The moved set: its groups' panels, showing the top group's.@@ -657,7 +675,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {     }     match drop {         Drop::Root(..) | Drop::Icons => {}-        Drop::Dock => {+        Drop::Dock | Drop::DockRight => {             dock(&mut app.ui, &ids, active);             // One that went back to its icon pops out of it, so it doesn't seem to vanish.             if app.ui.dock_collapsed || DockTab::from_id(active).is_none() {@@ -1105,9 +1123,20 @@ fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result<     let group = crate::menus::opt_bool(p, "group")?.unwrap_or(false);     let at = position(p)?;     if raw.eq_ignore_ascii_case("tools") || raw.eq_ignore_ascii_case("toolbar") {+        // Docking: at the window's left or right edge (default: the side it docked at last).+        let right = match p.get("side").and_then(Value::as_str) {+            None => app.ui.toolbar_right,+            Some(s) if s.eq_ignore_ascii_case("left") => false,+            Some(s) if s.eq_ignore_ascii_case("right") => true,+            Some(s) => return Err(format!("side is left or right, not `{s}`")),+        };         app.ui.toolbar_pos = float_it.then(|| at.map_or(TOOLS_AT, |p| [p.x, p.y]));         app.ui.toolbar |= float_it;-        return Ok(json!({ "panel": "tools", "floating": float_it, "pos": app.ui.toolbar_pos }));+        if !float_it {+            app.ui.toolbar_right = right;+        }+        let side = if app.ui.toolbar_right { "right" } else { "left" };+        return Ok(json!({ "panel": "tools", "floating": float_it, "pos": app.ui.toolbar_pos, "side": side }));     }     let Some(id) = crate::menus::normalize_panel(raw) else { return Err(format!("unknown panel `{raw}`")) };     let own = group_of(&app.ui, id);@@ -1628,6 +1657,73 @@ mod tests {         assert_ne!(h.app.ui.toolbar_double, double);     } +    /// The Tools panel also docks on the right, dropped at the window's right edge or just left of+    /// the panel dock: it stands between the canvas and the panels (left of their icon column),+    /// stays there through a restart and a workspace, and its flyouts open on its left.+    #[test]+    fn the_tools_panel_docks_on_the_right_edge_too() {+        let mut h = Harness::new();+        let zone = h.temp_rect(tools_zone_id());+        h.drag(zone.left_top() + vec2(30.0, 7.0), pos2(400.0, 300.0));+        let float = h.ctx.memory(|m| m.area_rect(Id::new("toolbar-floating"))).unwrap();+        let edge = pos2(SCREEN.x - 10.0, 400.0);+        h.hold(float.left_top() + vec2(30.0, 7.0), edge);+        assert_eq!(drop_target(&h.app, &h.ctx, Moving::Tools, edge).map(|t| t.0), Some(Drop::DockRight));+        h.release(edge);+        assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, true), "docked on the right");+        h.settle();+        let bar = h.temp_rect(crate::edit_toolbar::toolbar_id());+        let dock = crate::toolbar::dock_left(&h.app, &h.ctx).expect("the panel dock shows");+        assert!(dock < SCREEN.x - 40.0, "the panels keep the window's right edge: {dock}");+        assert!(bar.right() <= dock + 1.0 && bar.right() > dock - 12.0, "left of the panel dock at {dock}: {bar:?}");+        // A flyout opens on the toolbar's left, toward the canvas, not over the panels.+        let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);+        let room = crate::toolbar::room(&h.app, &h.ctx);+        assert!((room.right() - dock).abs() < 1.0, "room ends at the panels: {room:?}");+        let x = crate::toolbar::beside(bar, 160.0, 8.0, room);+        assert!(x + 160.0 <= bar.left() - 7.0, "left of the toolbar: {x}");+        assert_eq!(crate::toolbar::beside(Rect::from_min_size(pos2(0.0, 50.0), vec2(40.0, 40.0)), 160.0, 8.0, screen), 48.0, "right of it elsewhere");+        // Kept in the preferences and in a saved workspace; Essentials docks it on the left.+        let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&h.app.ui).unwrap()).unwrap();+        assert!(prefs.sanitized().toolbar_right);+        h.app.run("window.workspace.new", json!({"name": "Right Tools"})).unwrap();+        h.app.run("window.workspace", json!({"name": "Essentials"})).unwrap();+        assert!(!h.app.ui.toolbar_right);+        h.app.run("window.workspace", json!({"name": "Right Tools"})).unwrap();+        assert!(h.app.ui.toolbar_right);+        // Dragged back to the left edge, it docks there again.+        h.settle();+        let bar = h.temp_rect(crate::edit_toolbar::toolbar_id());+        h.drag(bar.left_top() + vec2(30.0, 7.0), pos2(400.0, 300.0));+        let float = h.ctx.memory(|m| m.area_rect(Id::new("toolbar-floating"))).unwrap();+        h.drag(float.left_top() + vec2(30.0, 7.0), pos2(10.0, 400.0));+        assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, false));+        // Dropped just left of the panel dock (not at the window's edge), it docks there too.+        h.settle();+        let bar = h.temp_rect(crate::edit_toolbar::toolbar_id());+        h.drag(bar.left_top() + vec2(30.0, 7.0), pos2(400.0, 300.0));+        let float = h.ctx.memory(|m| m.area_rect(Id::new("toolbar-floating"))).unwrap();+        let dock = crate::toolbar::dock_left(&h.app, &h.ctx).expect("the panel dock shows");+        let beside = pos2(dock - 10.0, 400.0);+        h.hold(float.left_top() + vec2(30.0, 7.0), beside);+        assert_eq!(drop_target(&h.app, &h.ctx, Moving::Tools, beside).map(|t| t.0), Some(Drop::DockRight));+        h.release(beside);+        assert_eq!((h.app.ui.toolbar_pos, h.app.ui.toolbar_right), (None, true));+    }++    #[test]+    fn the_dock_command_takes_the_tools_panels_side() {+        let mut app = VectorcraftApp::new(Session::new(), Default::default());+        let r = app.run("window.panel.dock", json!({"panel": "tools", "side": "right"})).unwrap();+        assert_eq!((r["side"].as_str(), app.ui.toolbar_right), (Some("right"), true));+        app.run("window.panel.float", json!({"panel": "tools"})).unwrap();+        let r = app.run("window.panel.dock", json!({"panel": "tools"})).unwrap();+        assert_eq!(r["side"], "right", "back where it docked last");+        app.run("window.panel.dock", json!({"panel": "tools", "side": "LEFT"})).unwrap();+        assert!(!app.ui.toolbar_right);+        assert!(app.run("window.panel.dock", json!({"panel": "tools", "side": "top"})).is_err());+    }+     #[test]     fn floating_panels_are_kept_in_workspaces_and_preferences_and_clamped_into_the_window() {         let mut h = Harness::new();diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv--- a/crates/ui-egui/src/i18n/bg.tsv+++ b/crates/ui-egui/src/i18n/bg.tsv@@ -3693,7 +3693,7 @@ axis	Both	И двете 	Drag to float the panel group	Плъзнете, за да направите групата от панели плаваща 	Put back in the dock	Връщане в дока 	Drag to float the toolbar	Плъзнете, за да направите лентата с инструменти плаваща-	Drag to the window's left edge to dock	Плъзнете до левия ръб на прозореца за закотвяне+	Drag to the window's left or right edge to dock	Плъзнете до левия или десния ръб на прозореца за закотвяне 	Float Tool Group	Плаваща група инструменти @msg	no library `{id}`	няма библиотека `{id}` @msg	select some type first	първо изберете текст@@ -5472,3 +5472,5 @@ axis	Y Axis	Y ос 	Rectangular Grid Tool Options	Опции на инструмента „Правоъгълна мрежа“ 	Polar Grid Tool Options	Опции на инструмента „Полярна мрежа“ 	Fill Line	Запълване на линия+@msg	the EPS Options dialog didn't open	диалоговият прозорец „Опции за EPS“ не се отвори+@msg	side is left or right, not `{s}`	side е left или right, а не `{s}`diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -1192,7 +1192,7 @@ lighting	Light	Světlo 	X:	X: 	Y:	Y: 	Drag to float the toolbar	Přetažením vytvoříte plovoucí panel nástrojů-	Drag to the window's left edge to dock	Přetažením k levému okraji okna panel ukotvíte+	Drag to the window's left or right edge to dock	Přetažením k levému nebo pravému okraji okna panel ukotvíte  # Tools 	Crystallize	Krystalizace@@ -4556,6 +4556,7 @@ imageTrace	Outline	Obrys @msg	the PDF is password-protected: give its password	PDF je chráněno heslem: zadejte heslo @msg	the PDF password is wrong	heslo k PDF je chybné @msg	the Save PDF dialog didn't open	dialogové okno Uložit PDF se neotevřelo+@msg	the EPS Options dialog didn't open	dialogové okno Možnosti EPS se neotevřelo @msg	the WMF header is damaged	hlavička WMF je poškozená @msg	the art could not be sampled	z kresby nelze nabrat vzorek @msg	the art has no bounds	kresba nemá hranice@@ -5533,3 +5534,4 @@ axis	Y Axis	osa Y 	Rectangular Grid Tool Options	Možnosti nástroje Obdélníková mřížka 	Polar Grid Tool Options	Možnosti nástroje Polární mřížka 	Fill Line	Výplň úsečky+@msg	side is left or right, not `{s}`	side je left nebo right, ne `{s}`diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv--- a/crates/ui-egui/src/i18n/da.tsv+++ b/crates/ui-egui/src/i18n/da.tsv@@ -1113,7 +1113,7 @@ lighting	Light	Lys 	X:	X: 	Y:	Y: 	Drag to float the toolbar	Træk for at frigøre værktøjslinjen-	Drag to the window's left edge to dock	Træk til vinduets venstre kant for at fastgøre+	Drag to the window's left or right edge to dock	Træk til vinduets venstre eller højre kant for at fastgøre 	Crystallize	Krystallisér 	Add Anchor Point Tool	Tilføj ankerpunkt-værktøj 	Anchor Point Tool	Ankerpunktværktøj@@ -5488,3 +5488,5 @@ axis	Y Axis	Y-akse 	Rectangular Grid Tool Options	Indstillinger for rektangulært gitterværktøj 	Polar Grid Tool Options	Indstillinger for polært gitterværktøj 	Fill Line	Udfyld linje+@msg	the EPS Options dialog didn't open	dialogen EPS-indstillinger blev ikke åbnet+@msg	side is left or right, not `{s}`	side er left eller right, ikke `{s}`diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -3690,7 +3690,7 @@ theme	Appearance	Erscheinungsbild 	Drag to float the panel group	Ziehen, um die Bedienfeldgruppe abzulösen 	Put back in the dock	Zurück ins Dock setzen 	Drag to float the toolbar	Ziehen, um die Werkzeugleiste abzulösen-	Drag to the window's left edge to dock	Zum Andocken an den linken Fensterrand ziehen+	Drag to the window's left or right edge to dock	Zum Andocken an den linken oder rechten Fensterrand ziehen 	Float Tool Group	Werkzeuggruppe ablösen  # Status and error messages (`@msg`), translated where the status bar shows them; the message itself@@ -4179,6 +4179,7 @@ theme	Appearance	Erscheinungsbild @msg	the PDF is password-protected: give its password	die PDF-Datei ist kennwortgeschützt: Geben Sie ihr Kennwort an @msg	the PDF password is wrong	das PDF-Kennwort ist falsch @msg	the Save PDF dialog didn't open	das Dialogfeld „PDF speichern“ wurde nicht geöffnet+@msg	the EPS Options dialog didn't open	das Dialogfeld „EPS-Optionen“ wurde nicht geöffnet @msg	the WMF header is damaged	der WMF-Header ist beschädigt @msg	the art could not be sampled	die Grafik konnte nicht abgetastet werden @msg	the art has no bounds	die Grafik hat keine Begrenzung@@ -5115,3 +5116,4 @@ axis	Y Axis	Y-Achse 	Rectangular Grid Tool Options	Optionen für das Rechteckiges-Raster-Werkzeug 	Polar Grid Tool Options	Optionen für das Radiales-Raster-Werkzeug 	Fill Line	Linie füllen+@msg	side is left or right, not `{s}`	side ist left oder right, nicht `{s}`diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv--- a/crates/ui-egui/src/i18n/el.tsv+++ b/crates/ui-egui/src/i18n/el.tsv@@ -3894,7 +3894,7 @@ theme	Appearance	Εμφάνιση 	Drag to float the panel group	Σύρετε για αποδέσμευση της ομάδας πινάκων 	Put back in the dock	Επαναφορά στην περιοχή σύνδεσης 	Drag to float the toolbar	Σύρετε για αποδέσμευση της γραμμής εργαλείων-	Drag to the window's left edge to dock	Σύρετε στο αριστερό άκρο του παραθύρου για σύνδεση+	Drag to the window's left or right edge to dock	Σύρετε στο αριστερό ή στο δεξί άκρο του παραθύρου για σύνδεση 	Float Tool Group	Αποδέσμευση ομάδας εργαλείων @msg	a PostScript error ({name} in `{op}`)	σφάλμα PostScript ({name} στην εντολή `{op}`) @msg	a PostScript error ({name} in `{op}`), in {procs}	σφάλμα PostScript ({name} στην εντολή `{op}`), στις διαδικασίες {procs}@@ -5485,3 +5485,5 @@ axis	Y Axis	Άξονας Y 	Rectangular Grid Tool Options	Επιλογές εργαλείου ορθογώνιου πλέγματος 	Polar Grid Tool Options	Επιλογές εργαλείου πολικού πλέγματος 	Fill Line	Γέμισμα γραμμής+@msg	the EPS Options dialog didn't open	δεν άνοιξε το παράθυρο Επιλογές EPS+@msg	side is left or right, not `{s}`	το side είναι left ή right, όχι `{s}`diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -3766,7 +3766,7 @@ axis	Both	Ambos 	Drag to float the panel group	Arrastrar para hacer flotar el grupo de paneles 	Put back in the dock	Devolver al área de paneles 	Drag to float the toolbar	Arrastrar para hacer flotar la barra de herramientas-	Drag to the window's left edge to dock	Arrastrar al borde izquierdo de la ventana para anclar+	Drag to the window's left or right edge to dock	Arrastrar al borde izquierdo o derecho de la ventana para anclar 	Float Tool Group	Hacer flotar el grupo de herramientas  # Status and error messages (`@msg`), translated where the status bar shows them; the message itself@@ -4254,6 +4254,7 @@ axis	Both	Ambos @msg	the PDF is password-protected: give its password	el PDF está protegido con contraseña: indica su contraseña @msg	the PDF password is wrong	la contraseña del PDF es incorrecta @msg	the Save PDF dialog didn't open	el cuadro de diálogo Guardar PDF no se abrió+@msg	the EPS Options dialog didn't open	el cuadro de diálogo Opciones EPS no se abrió @msg	the WMF header is damaged	el encabezado WMF está dañado @msg	the art could not be sampled	no se pudo muestrear la ilustración @msg	the art has no bounds	la ilustración no tiene límites@@ -5475,3 +5476,4 @@ axis	Y Axis	Eje Y 	Rectangular Grid Tool Options	Opciones de la herramienta Cuadrícula rectangular 	Polar Grid Tool Options	Opciones de la herramienta Cuadrícula polar 	Fill Line	Rellenar línea+@msg	side is left or right, not `{s}`	side es left o right, no `{s}`diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv--- a/crates/ui-egui/src/i18n/fi.tsv+++ b/crates/ui-egui/src/i18n/fi.tsv@@ -1170,7 +1170,7 @@ lighting	Light	Valo 	X:	X: 	Y:	Y: 	Drag to float the toolbar	Kelluta työkaluriviä vetämällä-	Drag to the window's left edge to dock	Telakoi vetämällä ikkunan vasempaan reunaan+	Drag to the window's left or right edge to dock	Telakoi vetämällä ikkunan vasempaan tai oikeaan reunaan 	Crystallize	Kiteytä 	Add Anchor Point Tool	Lisää ankkuripiste -työkalu 	Anchor Point Tool	Ankkuripistetyökalu@@ -5564,3 +5564,5 @@ axis	Y Axis	Y-akseli 	Rectangular Grid Tool Options	Suorakulmaisen ruudukon työkalun asetukset 	Polar Grid Tool Options	Napakoordinaattiruudukon työkalun asetukset 	Fill Line	Täytä viiva+@msg	the EPS Options dialog didn't open	valintaikkuna EPS-asetukset ei avautunut+@msg	side is left or right, not `{s}`	side on left tai right, ei `{s}`diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -3763,7 +3763,7 @@ axis	Both	Les deux 	Drag to float the panel group	Faire glisser pour rendre le groupe de panneaux flottant 	Put back in the dock	Remettre dans la zone d’ancrage 	Drag to float the toolbar	Faire glisser pour rendre la barre d’outils flottante-	Drag to the window's left edge to dock	Faire glisser vers le bord gauche de la fenêtre pour ancrer+	Drag to the window's left or right edge to dock	Faire glisser vers le bord gauche ou droit de la fenêtre pour ancrer 	Float Tool Group	Rendre le groupe d’outils flottant  # Status and error messages (`@msg`), translated where the status bar shows them; the message itself@@ -4251,6 +4251,7 @@ axis	Both	Les deux @msg	the PDF is password-protected: give its password	le PDF est protégé par un mot de passe : indiquez-le @msg	the PDF password is wrong	le mot de passe du PDF est incorrect @msg	the Save PDF dialog didn't open	la boîte de dialogue Enregistrer au format PDF ne s’est pas ouverte+@msg	the EPS Options dialog didn't open	la boîte de dialogue Options EPS ne s’est pas ouverte @msg	the WMF header is damaged	l’en-tête WMF est endommagé @msg	the art could not be sampled	impossible d’échantillonner l’illustration @msg	the art has no bounds	l’illustration n’a pas de limites@@ -5532,3 +5533,4 @@ axis	Y Axis	Axe Y 	Rectangular Grid Tool Options	Options de l’outil Grille rectangulaire 	Polar Grid Tool Options	Options de l’outil Grille à coordonnées polaires 	Fill Line	Remplir le trait+@msg	side is left or right, not `{s}`	side vaut left ou right, pas `{s}`diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -1102,7 +1102,7 @@ font-technology	Variable	Változó 	Drag to float the toolbar	Húzd el az eszköztár lebegtetéséhez 	Drag to move	Húzással áthelyezhető 	Drag to move out of the set	Húzással kivehető a csoportból-	Drag to the window's left edge to dock	Dokkoláshoz húzd az ablak bal szélére+	Drag to the window's left or right edge to dock	Dokkoláshoz húzd az ablak bal vagy jobb szélére 	Step up or down	Léptetés fel vagy le 	Drag values or labels. Double-click a value to type.	Húzd az értékeket vagy a címkéket. Gépeléshez kattints duplán egy értékre. 	Draw	Rajzolás@@ -5034,6 +5034,7 @@ imageTrace	Outline	Körvonal @msg	the PSD file's image data uses an unknown compression ({m})	a PSD-fájl képadatai ismeretlen tömörítést használnak ({m}) @msg	the PSD image is too large to read ({_1} × {_2} pixels)	a PSD-kép túl nagy a beolvasáshoz ({_1} × {_2} pixel) @msg	the Save PDF dialog didn't open	a PDF mentése párbeszédablak nem nyílt meg+@msg	the EPS Options dialog didn't open	az EPS-beállítások párbeszédablak nem nyílt meg @msg	the scale is not a usable number	a méretarány nem használható szám @msg	the search couldn't start: try again in a moment	a keresés nem indult el: próbáld újra egy kicsit később @msg	the selected stroke's profile is already listed as {_1}	a kijelölt körvonal profilja már szerepel a listán: {_1}@@ -5519,3 +5520,4 @@ axis	Y Axis	Y tengely 	Rectangular Grid Tool Options	Téglalaprács beállításai 	Polar Grid Tool Options	Polárrács beállításai 	Fill Line	Szakasz kitöltése+@msg	side is left or right, not `{s}`	a side értéke left vagy right lehet, nem `{s}`diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv--- a/crates/ui-egui/src/i18n/id.tsv+++ b/crates/ui-egui/src/i18n/id.tsv@@ -1113,7 +1113,7 @@ lighting	Light	Cahaya 	X:	X: 	Y:	Y: 	Drag to float the toolbar	Seret untuk mengambangkan bilah alat-	Drag to the window's left edge to dock	Seret ke tepi kiri jendela untuk memasang+	Drag to the window's left or right edge to dock	Seret ke tepi kiri atau kanan jendela untuk memasang 	Crystallize	Kristalisasi 	Add Anchor Point Tool	Alat tambah titik jangkar 	Anchor Point Tool	Alat titik jangkar@@ -5488,3 +5488,5 @@ axis	Y Axis	Sumbu Y 	Rectangular Grid Tool Options	Opsi alat kisi persegi panjang 	Polar Grid Tool Options	Opsi alat kisi polar 	Fill Line	Isi garis+@msg	the EPS Options dialog didn't open	dialog Opsi EPS tidak terbuka+@msg	side is left or right, not `{s}`	side adalah left atau right, bukan `{s}`diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv--- a/crates/ui-egui/src/i18n/it.tsv+++ b/crates/ui-egui/src/i18n/it.tsv@@ -3806,7 +3806,7 @@ axis	Both	Entrambi 	Drag to float the panel group	Trascina per rendere mobile il gruppo di pannelli 	Put back in the dock	Riporta nell'area dei pannelli 	Drag to float the toolbar	Trascina per rendere mobile la barra degli strumenti-	Drag to the window's left edge to dock	Trascina sul bordo sinistro della finestra per ancorare+	Drag to the window's left or right edge to dock	Trascina sul bordo sinistro o destro della finestra per ancorare 	Float Tool Group	Rendi mobile gruppo strumenti  # Status and error messages (`@msg`), translated where the status bar shows them; the message itself@@ -4294,6 +4294,7 @@ axis	Both	Entrambi @msg	the PDF is password-protected: give its password	il PDF è protetto da password: specifica la password @msg	the PDF password is wrong	la password del PDF è errata @msg	the Save PDF dialog didn't open	la finestra di dialogo Salva PDF non si è aperta+@msg	the EPS Options dialog didn't open	la finestra di dialogo Opzioni EPS non si è aperta @msg	the WMF header is damaged	l'intestazione WMF è danneggiata @msg	the art could not be sampled	impossibile campionare la grafica @msg	the art has no bounds	la grafica non ha limiti@@ -5149,3 +5150,4 @@ axis	Y Axis	Asse Y 	Rectangular Grid Tool Options	Opzioni strumento Griglia rettangolare 	Polar Grid Tool Options	Opzioni strumento Griglia polare 	Fill Line	Riempi linea+@msg	side is left or right, not `{s}`	side è left o right, non `{s}`diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -3743,7 +3743,7 @@ axis	Both	両方 	Drag to float the panel group	ドラッグしてパネルグループをフローティング 	Put back in the dock	ドックに戻す 	Drag to float the toolbar	ドラッグしてツールバーをフローティング-	Drag to the window's left edge to dock	ウィンドウの左端までドラッグしてドッキング+	Drag to the window's left or right edge to dock	ウィンドウの左端または右端までドラッグしてドッキング 	Float Tool Group	ツールグループをフローティング  # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.@@ -4424,6 +4424,7 @@ swatch	Bright Violet	ビビッドバイオレット @msg	no file manager here	この環境にはファイルマネージャーがありません @msg	no revert command	「保存済みの状態に戻す」コマンドがありません @msg	the Save PDF dialog didn't open	PDF 保存ダイアログが開きませんでした+@msg	the EPS Options dialog didn't open	EPS オプションダイアログが開きませんでした @msg	the document has never been saved	ドキュメントはまだ一度も保存されていません @msg	the export returned unreadable data	書き出し処理から読み取れないデータが返されました @msg	{status} ({e})	{status} ({e})@@ -5211,3 +5212,4 @@ axis	Y Axis	Y 軸 	Rectangular Grid Tool Options	長方形グリッドツールオプション 	Polar Grid Tool Options	同心円グリッドツールオプション 	Fill Line	線を塗りつぶす+@msg	side is left or right, not `{s}`	side は left または right です(`{s}` ではありません)diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv--- a/crates/ui-egui/src/i18n/ka.tsv+++ b/crates/ui-egui/src/i18n/ka.tsv@@ -1043,7 +1043,7 @@ lighting	Light	სინათლე 	Toggle single/double column	ერთი/ორი სვეტის გადართვა 	W:	სიგ.: 	Drag to float the toolbar	გადაათრიეთ ხელსაწყოთა ზოლის მცურავად გადასაქცევად-	Drag to the window's left edge to dock	მისამაგრებლად გადაათრიეთ ფანჯრის მარცხენა კიდესთან+	Drag to the window's left or right edge to dock	მისამაგრებლად გადაათრიეთ ფანჯრის მარცხენა ან მარჯვენა კიდესთან 	Crystallize	კრისტალიზაცია 	Add Anchor Point Tool	საყრდენი წერტილის დამატების ხელსაწყო 	Anchor Point Tool	საყრდენი წერტილის ხელსაწყო@@ -5449,3 +5449,5 @@ axis	Y Axis	Y ღერძი 	Rectangular Grid Tool Options	მართკუთხა ბადის ხელსაწყოს პარამეტრები 	Polar Grid Tool Options	პოლარული ბადის ხელსაწყოს პარამეტრები 	Fill Line	ხაზის შევსება+@msg	the EPS Options dialog didn't open	EPS-ის პარამეტრების დიალოგი არ გაიხსნა+@msg	side is left or right, not `{s}`	side არის left ან right და არა `{s}`diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv--- a/crates/ui-egui/src/i18n/ko.tsv+++ b/crates/ui-egui/src/i18n/ko.tsv@@ -3732,7 +3732,7 @@ axis	Both	양쪽 	Drag to float the panel group	드래그하여 패널 그룹 분리 	Put back in the dock	독으로 되돌리기 	Drag to float the toolbar	드래그하여 도구 모음 분리-	Drag to the window's left edge to dock	창 왼쪽 가장자리로 드래그하여 고정+	Drag to the window's left or right edge to dock	창 왼쪽 또는 오른쪽 가장자리로 드래그하여 고정 	Float Tool Group	도구 그룹 분리 	Services	서비스 	Hide VectorCraft	VectorCraft 숨기기@@ -5112,3 +5112,5 @@ axis	Y Axis	Y축 	Rectangular Grid Tool Options	사각형 격자 도구 옵션 	Polar Grid Tool Options	극좌표 격자 도구 옵션 	Fill Line	선 채우기+@msg	the EPS Options dialog didn't open	EPS 옵션 대화상자가 열리지 않았습니다+@msg	side is left or right, not `{s}`	side는 left 또는 right입니다(`{s}` 아님)diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv--- a/crates/ui-egui/src/i18n/nl.tsv+++ b/crates/ui-egui/src/i18n/nl.tsv@@ -1127,7 +1127,7 @@ lighting	Light	Licht 	X:	X: 	Y:	Y: 	Drag to float the toolbar	Sleep om de werkbalk zwevend te maken-	Drag to the window's left edge to dock	Sleep naar de linkerrand van het venster om te dokken+	Drag to the window's left or right edge to dock	Sleep naar de linker- of rechterrand van het venster om te dokken 	Crystallize	Kristalliseren 	Add Anchor Point Tool	Ankerpunt toevoegen 	Anchor Point Tool	Ankerpunt-gereedschap@@ -5531,3 +5531,5 @@ axis	Y Axis	Y-as 	Rectangular Grid Tool Options	Opties rechthoekig-rastergereedschap 	Polar Grid Tool Options	Opties polair-rastergereedschap 	Fill Line	Lijn vullen+@msg	the EPS Options dialog didn't open	het dialoogvenster EPS-opties is niet geopend+@msg	side is left or right, not `{s}`	side is left of right, niet `{s}`diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -1121,7 +1121,7 @@ lighting	Light	Światło 	Toggle single/double column	Przełącz jedną/dwie kolumny 	W:	S: 	Drag to float the toolbar	Przeciągnij, aby odpiąć pasek narzędzi-	Drag to the window's left edge to dock	Przeciągnij do lewej krawędzi okna, aby zadokować+	Drag to the window's left or right edge to dock	Przeciągnij do lewej lub prawej krawędzi okna, aby zadokować 	Crystallize	Krystalizacja 	Add Anchor Point Tool	Narzędzie Dodaj punkt kontrolny 	Anchor Point Tool	Narzędzie Punkt kontrolny@@ -5385,3 +5385,5 @@ axis	Y Axis	Oś Y 	Rectangular Grid Tool Options	Opcje narzędzia Siatka prostokątna 	Polar Grid Tool Options	Opcje narzędzia Siatka biegunowa 	Fill Line	Wypełnij odcinek+@msg	the EPS Options dialog didn't open	okno Opcje EPS nie otworzyło się+@msg	side is left or right, not `{s}`	side to left lub right, a nie `{s}`diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv--- a/crates/ui-egui/src/i18n/pt-br.tsv+++ b/crates/ui-egui/src/i18n/pt-br.tsv@@ -3356,7 +3356,7 @@ axis	Both	Ambos 	Drag to float the panel group	Arraste para flutuar o grupo de painéis 	Put back in the dock	Devolver ao dock 	Drag to float the toolbar	Arraste para flutuar a barra de ferramentas-	Drag to the window's left edge to dock	Arraste até a borda esquerda da janela para encaixar+	Drag to the window's left or right edge to dock	Arraste até a borda esquerda ou direita da janela para encaixar @msg	no library `{id}`	nenhuma biblioteca `{id}` @msg	select some type first	selecione algum texto primeiro @msg	{_1} has no {_2} style	{_1} não tem o estilo {_2}@@ -3827,6 +3827,7 @@ axis	Both	Ambos @msg	the PDF is password-protected: give its password	o PDF é protegido por senha: informe a senha @msg	the PDF password is wrong	a senha do PDF está incorreta @msg	the Save PDF dialog didn't open	a caixa de diálogo Salvar PDF não abriu+@msg	the EPS Options dialog didn't open	a caixa de diálogo Opções de EPS não abriu @msg	the WMF header is damaged	o cabeçalho WMF está danificado @msg	the art could not be sampled	não foi possível amostrar a arte @msg	the art has no bounds	a arte não tem limites@@ -5450,3 +5451,4 @@ axis	Y Axis	Eixo Y 	Rectangular Grid Tool Options	Opções da Ferramenta Grade Retangular 	Polar Grid Tool Options	Opções da Ferramenta Grade Polar 	Fill Line	Preencher linha+@msg	side is left or right, not `{s}`	side é left ou right, não `{s}`diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -3802,7 +3802,7 @@ axis	Both	Оба 	Drag to float the panel group	Перетащите, чтобы сделать группу панелей плавающей 	Put back in the dock	Вернуть в док 	Drag to float the toolbar	Перетащите, чтобы сделать панель инструментов плавающей-	Drag to the window's left edge to dock	Перетащите к левому краю окна для прикрепления+	Drag to the window's left or right edge to dock	Перетащите к левому или правому краю окна для прикрепления 	Float Tool Group	Сделать группу инструментов плавающей  # Status and error messages (`@msg`), translated where the status bar shows them; the message itself@@ -4290,6 +4290,7 @@ axis	Both	Оба @msg	the PDF is password-protected: give its password	PDF защищён паролем: укажите его пароль @msg	the PDF password is wrong	неверный пароль PDF @msg	the Save PDF dialog didn't open	диалоговое окно «Сохранить PDF» не открылось+@msg	the EPS Options dialog didn't open	диалоговое окно «Параметры EPS» не открылось @msg	the WMF header is damaged	заголовок WMF повреждён @msg	the art could not be sampled	не удалось взять образец рисунка @msg	the art has no bounds	у рисунка нет границ@@ -5145,3 +5146,4 @@ axis	Y Axis	Ось Y 	Rectangular Grid Tool Options	Параметры инструмента «Прямоугольная сетка» 	Polar Grid Tool Options	Параметры инструмента «Полярная сетка» 	Fill Line	Заливка отрезка+@msg	side is left or right, not `{s}`	side — это left или right, а не `{s}`diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv--- a/crates/ui-egui/src/i18n/sv.tsv+++ b/crates/ui-egui/src/i18n/sv.tsv@@ -3693,7 +3693,7 @@ axis	Both	Båda 	Drag to float the panel group	Dra för att göra panelgruppen flytande 	Put back in the dock	Lägg tillbaka i dockan 	Drag to float the toolbar	Dra för att göra verktygsfältet flytande-	Drag to the window's left edge to dock	Dra till fönstrets vänsterkant för att docka+	Drag to the window's left or right edge to dock	Dra till fönstrets vänster- eller högerkant för att docka 	Float Tool Group	Gör verktygsgruppen flytande @msg	no library `{id}`	inget bibliotek `{id}` @msg	select some type first	markera någon text först@@ -5489,3 +5489,5 @@ axis	Y Axis	Y-axel 	Rectangular Grid Tool Options	Alternativ för verktyget för rektangulärt rutnät 	Polar Grid Tool Options	Alternativ för verktyget för polärt rutnät 	Fill Line	Fyll linje+@msg	the EPS Options dialog didn't open	dialogrutan EPS-alternativ öppnades inte+@msg	side is left or right, not `{s}`	side är left eller right, inte `{s}`diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv--- a/crates/ui-egui/src/i18n/uk.tsv+++ b/crates/ui-egui/src/i18n/uk.tsv@@ -3795,7 +3795,7 @@ axis	Both	Обидві 	Drag to float the panel group	Перетягніть, щоб від’єднати групу панелей 	Put back in the dock	Повернути у док 	Drag to float the toolbar	Перетягніть, щоб від’єднати панель інструментів-	Drag to the window's left edge to dock	Перетягніть до лівого краю вікна для прикріплення+	Drag to the window's left or right edge to dock	Перетягніть до лівого або правого краю вікна для прикріплення 	Float Tool Group	Від’єднати групу інструментів @msg	no library `{id}`	немає бібліотеки `{id}` @msg	select some type first	спочатку виділіть текст@@ -4278,6 +4278,7 @@ axis	Both	Обидві @msg	the PDF is password-protected: give its password	PDF захищений паролем: вкажіть його пароль @msg	the PDF password is wrong	неправильний пароль PDF @msg	the Save PDF dialog didn't open	діалогове вікно «Зберегти PDF» не відкрилося+@msg	the EPS Options dialog didn't open	діалогове вікно «Параметри EPS» не відкрилося @msg	the WMF header is damaged	заголовок WMF пошкоджений @msg	the art could not be sampled	не вдалося взяти зразок малюнка @msg	the art has no bounds	малюнок не має меж@@ -5129,3 +5130,4 @@ axis	Y Axis	Вісь Y 	Rectangular Grid Tool Options	Параметри інструменту «Прямокутна сітка» 	Polar Grid Tool Options	Параметри інструменту «Полярна сітка» 	Fill Line	Заливка відрізка+@msg	side is left or right, not `{s}`	side — це left або right, а не `{s}`diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv--- a/crates/ui-egui/src/i18n/vi.tsv+++ b/crates/ui-egui/src/i18n/vi.tsv@@ -986,7 +986,7 @@ font-technology	Variable	Biến thiên 	Drag to float the toolbar	Kéo để thả nổi thanh công cụ 	Drag to move	Kéo để di chuyển 	Drag to move out of the set	Kéo để tách khỏi bộ-	Drag to the window's left edge to dock	Kéo đến cạnh trái cửa sổ để neo+	Drag to the window's left or right edge to dock	Kéo đến cạnh trái hoặc phải cửa sổ để neo 	Step up or down	Tăng hoặc giảm một bước 	Drag values or labels. Double-click a value to type.	Kéo giá trị hoặc nhãn. Bấm đúp vào giá trị để nhập. 	Draw	Vẽ@@ -5129,3 +5129,5 @@ axis	Y Axis	Trục Y 	Rectangular Grid Tool Options	Tùy chọn công cụ Lưới chữ nhật 	Polar Grid Tool Options	Tùy chọn công cụ Lưới cực 	Fill Line	Tô đoạn thẳng+@msg	the EPS Options dialog didn't open	hộp thoại Tùy chọn EPS không mở+@msg	side is left or right, not `{s}`	side là left hoặc right, không phải `{s}`diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv--- a/crates/ui-egui/src/i18n/zh-hans.tsv+++ b/crates/ui-egui/src/i18n/zh-hans.tsv@@ -4764,7 +4764,7 @@ axis	Both	两者 	Drag to float the panel group	拖动以浮动面板组 	Put back in the dock	放回停放区 	Drag to float the toolbar	拖动以浮动工具栏-	Drag to the window's left edge to dock	拖到窗口左边缘以停放+	Drag to the window's left or right edge to dock	拖到窗口左边缘或右边缘以停放 	Float Tool Group	浮动工具组  # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.@@ -5372,3 +5372,5 @@ axis	Y Axis	Y 轴 	Rectangular Grid Tool Options	矩形网格工具选项 	Polar Grid Tool Options	极坐标网格工具选项 	Fill Line	填充线段+@msg	the EPS Options dialog didn't open	“EPS 选项”对话框未打开+@msg	side is left or right, not `{s}`	side 应为 left 或 right,而不是 `{s}`diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv--- a/crates/ui-egui/src/i18n/zh-hant.tsv+++ b/crates/ui-egui/src/i18n/zh-hant.tsv@@ -4568,7 +4568,7 @@ axis	Both	兩者 	Drag to float the panel group	拖曳以浮動面板群組 	Put back in the dock	放回停駐區 	Drag to float the toolbar	拖曳以浮動工具列-	Drag to the window's left edge to dock	拖曳到視窗左側邊緣以停駐+	Drag to the window's left or right edge to dock	拖曳到視窗左側或右側邊緣以停駐 	Float Tool Group	浮動工具組  # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs--- a/crates/ui-egui/src/io.rs+++ b/crates/ui-egui/src/io.rs@@ -640,10 +640,11 @@ fn has_options(p: &Value) -> bool { }  /// Ask for format `f`'s options before a save (`mode`) to `path`: SVG Options for SVG, the Save-/// PDF dialog for PDF, else the save options dialog. OK finishes the save.+/// PDF dialog for PDF, EPS Options for EPS, else the save options dialog. OK finishes the save. pub fn ask_format_options(app: &mut VectorcraftApp, mode: SaveMode, f: &Format, path: &str) -> Result<Value, String> {     match f.id {         "pdf" => ask_pdf_options(app, mode, path),+        "eps" => ask_eps_options(app, mode, path),         "svg" | "svgz" => {             let m = if mode == SaveMode::Copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save };             svg_options::open(app, m, Some(path));@@ -664,6 +665,17 @@ pub fn ask_pdf_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) ->     Ok(json!({ "pending": d.kind })) } +/// A save as EPS asks with EPS Options, filled with the EPS options the document was last saved+/// with (else those used last); its OK finishes the save.+fn ask_eps_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) -> Result<Value, String> {+    let mut p = Value::Object(plan(app, mode, &json!({ "path": path, "format": "eps" }))?.options);+    p["path"] = json!(path);+    dialogs::eps_options::open(app, &p);+    let d = app.ui.dialog.as_mut().ok_or("the EPS Options dialog didn't open")?;+    d.fields.insert("__save".into(), json!(mode.command()));+    Ok(json!({ "pending": d.kind }))+}+ /// The Save PDF dialog's OK during a save (`mode`): `params` are its `document.exportPdf` options /// and `path`. pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) -> Result<Value, String> {diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -1610,13 +1610,19 @@ impl VectorcraftApp {             // Keeps the previous frame's value valid for menus drawn above, before the bars.             widgets::set_menu_floor(&ctx, ctx.content_rect().bottom());         }-        if self.ui.toolbar && self.ui.screen_mode < 3 {+        // Docked right, the Tools panel stands between the canvas and the panel dock: laid out+        // after the dock, which takes the window's right edge first.+        let tools_after_dock = self.ui.toolbar_right && self.ui.toolbar_pos.is_none();+        if self.ui.toolbar && self.ui.screen_mode < 3 && !tools_after_dock {             toolbar::show(self, ui);         }         panels::library_panel::tidy(self);         if self.ui.dock && self.ui.screen_mode < 3 {             dock::show(self, ui);         }+        if self.ui.toolbar && self.ui.screen_mode < 3 && tools_after_dock {+            toolbar::show(self, ui);+        }         egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {             if self.ui.screen_mode < 3 {                 chrome::doc_tabs(self, ui);diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -487,7 +487,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[         "window.panel.dock",         "Dock Panel",         "",-        "{panel: id or \"tools\", group?: bool, column?: true or n, anchor?: id, zone?: \"tab\" | \"top\" | \"bottom\" | \"left\" | \"right\" | \"center\", before?: id, icon?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `anchor`, in the dock group holding that panel instead: `zone` \"tab\" (the default) as a tab of it (before the `before` panel's tab), as dropping it on that group's tabs does, \"top\" / \"bottom\" as a new group above / below it, as dropping it on that group's upper / lower half does (\"left\", \"right\" and \"center\" split or join it likewise); with `icon: true` (no `anchor`), as icons in the icon column, the tabbed group's panels too, as dropping it on the icon column does: it lives there from then on and pops out of its icon; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}, or with `anchor` or `icon: true` (the shared layout) {panel, floating, layout} and no `column`",+        "{panel: id or \"tools\", group?: bool, column?: true or n, anchor?: id, zone?: \"tab\" | \"top\" | \"bottom\" | \"left\" | \"right\" | \"center\", before?: id, icon?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `anchor`, in the dock group holding that panel instead: `zone` \"tab\" (the default) as a tab of it (before the `before` panel's tab), as dropping it on that group's tabs does, \"top\" / \"bottom\" as a new group above / below it, as dropping it on that group's upper / lower half does (\"left\", \"right\" and \"center\" split or join it likewise); with `icon: true` (no `anchor`), as icons in the icon column, the tabbed group's panels too, as dropping it on the icon column does: it lives there from then on and pops out of its icon; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge, or with side: right on the right, left of the panel dock (default: the side it docked at last), as dropping it there or on the window's right edge does. Returns {panel, floating, column, side?}, or with `anchor` or `icon: true` (the shared layout) {panel, floating, layout} and no `column`",     ),     (         "window.collapseDock",diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs--- a/crates/ui-egui/src/panel_docking.rs+++ b/crates/ui-egui/src/panel_docking.rs@@ -829,7 +829,8 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> { fn drop_command(panel: &str, target: crate::floating::Drop, whole_group: bool) -> (&'static str, Value) {     use crate::floating::Drop;     match target {-        Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})),+        // Only the Tools panel docks on the right; a panel dropped there docks as usual.+        Drop::Dock | Drop::DockRight => ("window.panel.dock", json!({"panel": panel, "group": whole_group})),         Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})),         Drop::Below(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "below": onto})),         Drop::Above(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "above": onto})),diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs--- a/crates/ui-egui/src/state.rs+++ b/crates/ui-egui/src/state.rs@@ -388,9 +388,13 @@ pub struct UiState {     #[serde(skip)]     pub dock_width_pending: bool,     /// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`:-    /// docked at the window's left edge.+    /// docked at the window's left edge (or right edge, [`Self::toolbar_right`]).     #[serde(default)]     pub toolbar_pos: Option<[f32; 2]>,+    /// The docked Tools panel stands on the right, left of the panel dock, instead of at the window's+    /// left edge (dropped there or on the window's right edge).+    #[serde(default)]+    pub toolbar_right: bool,     pub status_bar: bool,     pub dock: bool,     /// The bars that Tab (`window.dock`) hid, which the next Tab shows again; none when Tab has not@@ -848,6 +852,7 @@ impl Default for UiState {             dock_width: None,             dock_width_pending: false,             toolbar_pos: None,+            toolbar_right: false,             status_bar: true,             dock: true,             tab_hid: None,diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs--- a/crates/ui-egui/src/toolbar.rs+++ b/crates/ui-egui/src/toolbar.rs@@ -16,7 +16,7 @@ use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, icons, widgets};  const PITCH: f32 = 30.0;-const WIDTH: f32 = 48.0;+pub(crate) const WIDTH: f32 = 48.0; /// Seconds a press on a tool group's button is held before its flyout opens. const LONG_PRESS: f64 = 0.35; /// Width of the grab bar down a flyout's right side: dragging it tears the flyout off.@@ -101,13 +101,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {     // Two 36 pt buttons with 2 pt either side inside the border and separator line (at 76 they     // overran the right border).     let w = if cols == 2 { 80.0 } else { WIDTH };-    // Where the panel docks: along the window's left edge, under the bars.+    // Where the panel docks: along the window's left or right edge, under the bars.     let edge = ui.available_rect_before_wrap();-    ui.ctx().data_mut(|d| d.insert_temp(crate::floating::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height()))));+    // Docked right, it stands left of the panel dock (its icon column or its leftmost column),+    // between the canvas and the panels.+    let right = dock_left(app, ui.ctx()).map_or(edge.max.x, |x| x.min(edge.max.x));+    ui.ctx().data_mut(|d| {+        d.insert_temp(crate::floating::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height())));+        d.insert_temp(crate::floating::tools_zone_right_id(), egui::Rect::from_min_max(pos2(right - WIDTH, edge.min.y), pos2(right, edge.max.y)));+    });     match app.ui.toolbar_pos {         None => {-            let margin = egui::Margin { left: 6, right: 0, top: 6, bottom: 6 };-            egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| {+            // The card's outer gap faces the window edge it docks at.+            let (panel, margin) = if app.ui.toolbar_right {+                (egui::Panel::right("toolbar"), egui::Margin { left: 0, right: 6, top: 6, bottom: 6 })+            } else {+                (egui::Panel::left("toolbar"), egui::Margin { left: 6, right: 0, top: 6, bottom: 6 })+            };+            panel.resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| {                 crate::theme::card(ui, &t, margin, |ui| body(app, ui, cols, None));             });         }@@ -168,7 +179,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu     {         app.ui.status = e;     }-    let grip_tip = if floating.is_some() { tl!("Drag to the window's left edge to dock") } else { tl!("Drag to float the toolbar") };+    let grip_tip = if floating.is_some() { tl!("Drag to the window's left or right edge to dock") } else { tl!("Drag to float the toolbar") };     gresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), grip_tip));     gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(grip_tip);     // The tools and the controls under them scroll in a window too short for them.@@ -572,6 +583,38 @@ fn raise_floating(ctx: &egui::Context, key: &str) { }  /// A flyout's frame: drop shadow and border (the accent while it flashes).+/// The panel dock's left edge as last drawn (its icon column, or the leftmost dock column), while+/// it shows: where a toolbar docked right stands.+pub(crate) fn dock_left(app: &VectorcraftApp, ctx: &egui::Context) -> Option<f32> {+    if !app.ui.dock || app.ui.screen_mode >= 3 {+        return None;+    }+    let (left, dock) = ctx.data(|d| (d.get_temp::<f32>(crate::dock::dock_left_id()), d.get_temp::<egui::Rect>(crate::floating::dock_rect_id())));+    [left, dock.map(|r| r.left())].into_iter().flatten().filter(|x| x.is_finite()).reduce(f32::min)+}++/// Where the toolbar's flyouts and drawer may open: the window, or with the toolbar docked right+/// the part left of the panel dock, so they open toward the canvas instead of over the panels.+pub(crate) fn room(app: &VectorcraftApp, ctx: &egui::Context) -> egui::Rect {+    let mut r = ctx.content_rect();+    if app.ui.toolbar_right+        && app.ui.toolbar_pos.is_none()+        && let Some(x) = dock_left(app, ctx)+    {+        r.max.x = r.max.x.min(x);+    }+    r+}++/// The left edge of something `width` wide opened beside `anchor` (a tool, the toolbar), `gap`+/// from it: on its right when it fits in `screen`, else on its left (a toolbar docked at the+/// window's right edge), kept inside the screen either way.+pub(crate) fn beside(anchor: egui::Rect, width: f32, gap: f32, screen: egui::Rect) -> f32 {+    let right = anchor.right() + gap;+    let x = if right + width <= screen.right() { right } else { anchor.left() - gap - width };+    x.min(screen.right() - width).max(screen.left())+}+ pub(crate) fn flyout_frame(t: &Tokens, flash: bool) -> egui::Frame {     egui::Frame::NONE         .fill(t.panel)@@ -741,12 +784,12 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {     let t = Tokens::get(ctx);     let active = app.session.tool_id();     let mut input = FlyoutInput::default();-    let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show(-        ctx,-        |ui| {-            flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false));-        },-    );+    // Beside the tool, on the side with room for it (left of a toolbar docked at the right edge).+    let size = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size());+    let x = beside(anchor, size.x, 8.0, room(app, ctx));+    let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| {+        flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false));+    });     // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the     // end of the long press that opened the flyout): it floats where the pointer is.     let at = ctx.input(|i| i.pointer.interact_pos());diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs--- a/crates/ui-egui/src/workspaces.rs+++ b/crates/ui-egui/src/workspaces.rs@@ -52,6 +52,8 @@ pub struct Workspace {     /// Main dock column width in screen points (#1753).     #[serde(default, skip_serializing_if = "Option::is_none")]     pub dock_width: Option<f32>,+    /// The docked Tools panel stands on the right, left of the panel dock (built-in workspaces: left).+    pub toolbar_right: bool, }  impl Default for Workspace {@@ -186,6 +188,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {         docking_collapsed: ui.docking_collapsed.clone(),         toolbar_pos: ui.toolbar_pos,         dock_width: ui.dock_width,+        toolbar_right: ui.toolbar_right,     } } @@ -213,6 +216,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {     ui.toolbar_pos = w.toolbar_pos;     ui.dock_width = w.dock_width;     ui.dock_width_pending = true;+    ui.toolbar_right = w.toolbar_right;     FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos);     ui.flyout = None;     ui.workspace = w.name.clone();diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 25 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, Word `.doc`, PS |-| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) |+| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file); EPS saved with EPS Options, carrying the native document (reopens exactly) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -130,6 +130,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri  | Date | Change | Summary | |---|---|---|+| 2026-10-18 | minor | EPS joins Save As and Save a Copy: EPS Options ask first, the file carries the native document, and Save writes it back | | 2026-10-24 | minor | `.ai` import: an image larger than the editing data's reader takes (2^25 pixels, a halftone texture at print resolution) no longer leaves its layer blank: the file comes in as its PDF part, which reads larger images, with a note saying why (#1479). Own-fixture test; scores and hours unchanged | | 2026-10-24 | minor | PDF export: a pattern fill or stroke is written as a PDF tiling pattern (ISO 32000-1, 8.7.3.1) of one period of the tiling, as the canvas repeats it, so grid, brick and hex tilings and overlapping tiles keep their look in a few kilobytes, and PDF import reads it back as one object with a pattern swatch instead of hundreds of tile paths (#3849). Patterns nested more than four deep, or with tiles beyond a billion points, are still written as their tiles, now with a warning. Tests: the issue's hatch round trip, a brick tiling read back against the canvas, the nesting warning; scores and hours unchanged | | 2026-10-23 | minor | SVG: type written as outlines stacks each run's colour glyphs (bitmap images, COLR and SVG art) under the run's paint, as the canvas does, so a plain character set over a colour one stays on top; scores unchanged |diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -373,7 +373,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?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). With `path` the backend (the headless session or the app) writes the PNG there too. | | `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). |+| `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` or EPS; 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; `.eps`: carries it too). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `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 writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (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. | | `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. |