From 69be79bbf1cca2ee112b0fd9f580149a2607ac92 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:46:20 -0400 Subject: [PATCH] =?UTF-8?q?M4.4:=20File=20=E2=86=92=20Export=20Selection?= =?UTF-8?q?=E2=80=A6=20(selected=20art=20cropped=20to=20its=20bounds=20as?= =?UTF-8?q?=20PNG/JPG/WebP/SVG/PDF),=20Save=20as=20Template=E2=80=A6=20and?= =?UTF-8?q?=20New=20from=20Template=E2=80=A6=20(templates=20open=20as=20un?= =?UTF-8?q?titled=20documents)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 4 ++ crates/engine/src/cmd/file.rs | 80 +++++++++++++++++++++++++++++++-- crates/engine/src/tests_file.rs | 34 ++++++++++++++ crates/ui-egui/src/io.rs | 38 ++++++++++++++-- crates/ui-egui/src/menus.rs | 17 +++++-- 5 files changed, 164 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 95e4c472..e78be50d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -257,6 +257,9 @@ pub struct Document { pub version: u32, #[serde(default)] pub title: String, + /// Saved with File → Save as Template: opening it starts a new untitled document. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub template: bool, #[serde(default)] pub units: Unit, #[serde(default)] @@ -314,6 +317,7 @@ impl Document { let mut d = Self { version: FORMAT_VERSION, title: "Untitled-1".into(), + template: false, units: Unit::Points, color_mode: ColorMode::Rgb, artboards: vec![Artboard { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2ece33ad..50665599 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -22,6 +22,24 @@ pub fn specs() -> Vec { cmd!("document.save", "Save Document", [], None, "{path?} native .vectorcraft (default: the document's path) → {path}", has_doc, save), cmd!(query "document.serialize", "Serialize Document", [], None, "{format: vectorcraft|svg|pdf|png} → {dataBase64 | text}", has_doc, serialize), cmd!("document.export", "Export Document", [], None, "{format: svg|png|pdf|vectorcraft, path, scale?: 1, artboard?: 0}", has_doc, export), + cmd!( + "document.exportSelection", + "Export Selection…", + ["File"], + None, + "{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1} the selected objects cropped to their bounds → {path, bytes, bounds} (no path → {dataBase64, bounds})", + has_selection, + export_selection + ), + cmd!( + "file.saveAsTemplate", + "Save as Template…", + ["File"], + None, + "{path?} a native copy that opens as a new untitled document (no path → {dataBase64})", + has_doc, + save_template + ), cmd!( "document.exportForScreens", "Export for Screens", @@ -66,8 +84,14 @@ fn open(s: &mut Session, p: &Value) -> Result { ), _ => return Err(bad("document.open", "give path or dataBase64")), }; - let doc = load(&name, &bytes)?; - let native = vectorcraft_format::is_native_name(&name); + let mut doc = load(&name, &bytes)?; + let mut native = vectorcraft_format::is_native_name(&name); + if doc.template { + // A template opens as a new untitled document (saving asks for a new name). + doc.template = false; + doc.title = s.next_untitled(); + native = false; + } let i = s.add_document(doc, if native { path } else { None }); Ok(json!({ "index": i })) } @@ -86,7 +110,10 @@ fn save(s: &mut Session, p: &Value) -> Result { } pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result> { - let doc = &s.doc()?.doc; + encode_doc(&s.doc()?.doc, format, scale, artboard) +} + +fn encode_doc(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize) -> Result> { Ok(match format { "vectorcraft" => vectorcraft_format::save_file(doc), "svg" => vectorcraft_svg::export(doc, &vectorcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(), @@ -123,6 +150,53 @@ fn export(s: &mut Session, p: &Value) -> Result { Ok(json!({ "path": path, "bytes": bytes.len() })) } +/// File → Export Selection: the selected objects alone, cropped to their visual bounds. +fn export_selection(s: &mut Session, p: &Value) -> Result { + const C: &str = "document.exportSelection"; + let path = str_param(p, "path"); + let f = str_param(p, "format") + .map(str::to_string) + .or_else(|| path.and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase())) + .unwrap_or_else(|| "png".into()); + let ids = super::edit::selected_roots(s)?; + let st = s.doc()?; + let nodes: Vec> = ids.iter().filter_map(|id| st.doc.node(*id).cloned().map(std::sync::Arc::new)).collect(); + let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?; + let mut d = (*st.doc).clone(); + let mut layer = vectorcraft_doc::Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = nodes; + } + d.layers = vec![std::sync::Arc::new(layer)]; + let mut ab = d.artboards.first().cloned().ok_or_else(|| bad(C, "the document has no artboard"))?; + ab.rect = bounds; + ab.name = "Selection".into(); + d.artboards = vec![ab]; + let bytes = encode_doc(&d, &f, f64_or(p, "scale", 1.0), 0)?; + let b = [bounds.x0, bounds.y0, bounds.width(), bounds.height()]; + match path { + Some(path) => { + std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({ "path": path, "bytes": bytes.len(), "bounds": b })) + } + None => Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes), "bounds": b })), + } +} + +/// File → Save as Template: a native copy flagged so opening it starts a new untitled document. +fn save_template(s: &mut Session, p: &Value) -> Result { + let mut d = (*s.doc()?.doc).clone(); + d.template = true; + let bytes = vectorcraft_format::save_file(&d); + match str_param(p, "path") { + Some(path) => { + std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({ "path": path })) + } + None => Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) })), + } +} + fn batch(s: &mut Session, p: &Value) -> Result { let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone(); let label = str_param(p, "label").unwrap_or("Batch").to_string(); diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index 694ed9c1..c287e0dd 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -149,3 +149,37 @@ fn recolor_maps_fills_strokes_gradients() { other => panic!("{other:?}"), } } + +#[test] +fn export_selection_crops_to_the_selection() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 200, "y": 100, "width": 80, "height": 40})).unwrap(); + let v = s.execute("document.exportSelection", &json!({"format": "png", "scale": 2})).unwrap(); + let b = v["bounds"].as_array().unwrap(); + // Only the selected (last) rectangle, with its stroke. + let w = b[2].as_f64().unwrap(); + assert!((81.0..90.0).contains(&w) && b[0].as_f64().unwrap() > 190.0, "{v}"); + let png = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(&png[..4], b"\x89PNG"); + assert_eq!(u32::from_be_bytes([png[16], png[17], png[18], png[19]]), (w * 2.0).round() as u32); + let svg = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap(); + let text = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(text.matches(", scal Ok(path) } +/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path +/// (Export Selection, Save as Template). +pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: serde_json::Value) -> Result { + let path = match params.get("path").and_then(serde_json::Value::as_str) { + Some(p) => p.to_string(), + None if app.services.download.is_some() => suggested(app, ext), + None => { + let s = suggested(app, ext); + app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")? + } + }; + if let Some(o) = params.as_object_mut() { + o.remove("path"); + if o.get("format").is_none() && id == "document.exportSelection" { + let e = std::path::Path::new(&path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| ext.into()); + o.insert("format".into(), serde_json::json!(e)); + } + } + let v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; + let bytes = v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode).ok_or("no data")?; + write_out(app, &path, &bytes)?; + app.status(format!("Saved {path}")); + Ok(path) +} + /// File → Place… (embed an image or SVG into the active document). pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { let e = ext(name); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d2aec1dd..1256f085 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -47,6 +47,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("file.save", "Save", "Cmd+S", "{path?}"), ("file.saveAs", "Save As…", "Cmd+Shift+S", "{path?}"), ("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?}"), + ("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"), ("file.revert", "Revert", "F12", "{}"), ("file.place", "Place…", "Cmd+Shift+P", "{path?}"), ("file.openRecent1", "Open Recent File 1", "", "{}"), @@ -153,6 +154,16 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::save(app, s("path"), false).map(|p| json!({"path": p})), "file.saveAs" | "file.saveCopy" => io::save(app, s("path"), true).map(|p| json!({"path": p})), + "document.exportSelection" => { + io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) + } + "file.saveAsTemplate" => { + io::save_command_output(app, id, "vectorcraft", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) + } + "file.newFromTemplate" => match s("path") { + Some(path) => io::open_path(app, &path).map(|_| Value::Null), + None => io::open_dialog(app).map(|_| Value::Null), + }, "file.revert" => { let path = app.session.active().and_then(|d| d.path.clone()); match path { @@ -537,7 +548,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "File", vec![ c("New…", "file.newDialog"), - todos("New from Template…", "Cmd+Shift+N"), + c("New from Template…", "file.newFromTemplate"), c("Open…", "file.open"), sub("Open Recent Files", { let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); @@ -551,7 +562,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Save", "file.save"), c("Save As…", "file.saveAs"), c("Save a Copy…", "file.saveCopy"), - todo("Save as Template…"), + c("Save as Template…", "file.saveAsTemplate"), c("Revert", "file.revert"), Sep, c("Place…", "file.place"), @@ -565,7 +576,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S"), ], ), - todo("Export Selection…"), + c("Export Selection…", "document.exportSelection"), Sep, todos("Package…", "Cmd+Alt+Shift+P"), sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]),