M4.4: File → Export Selection… (selected art cropped to its bounds as PNG/JPG/WebP/SVG/PDF), Save as Template… and New from Template… (templates open as untitled documents)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b48776607
commit
69be79bbf1
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<Value> {
|
|||
),
|
||||
_ => 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<Value> {
|
|||
}
|
||||
|
||||
pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Value> {
|
|||
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<Value> {
|
||||
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<std::sync::Arc<vectorcraft_doc::Node>> = 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<Value> {
|
||||
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<Value> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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("<path").count(), 1, "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_opens_as_untitled() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vc-tpl-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("letterhead.vectorcraft");
|
||||
s.execute("file.saveAsTemplate", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.path.is_none() && !st.doc.template);
|
||||
assert!(st.doc.title.starts_with("Untitled-"), "{}", st.doc.title);
|
||||
assert_eq!(st.doc.layers[0].children().unwrap().len(), 1);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,10 +82,17 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
|
|||
let e = ext(name);
|
||||
let (doc, keep_path) = if vectorcraft_format::is_native_ext(&e) || vectorcraft_format::sniff(bytes) {
|
||||
let mut d = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?;
|
||||
if d.title.is_empty() {
|
||||
d.title = name.to_string();
|
||||
if d.template {
|
||||
// Templates open as a new untitled document.
|
||||
d.template = false;
|
||||
d.title = app.session.next_untitled();
|
||||
(d, false)
|
||||
} else {
|
||||
if d.title.is_empty() {
|
||||
d.title = name.to_string();
|
||||
}
|
||||
(d, true)
|
||||
}
|
||||
(d, true)
|
||||
} else if e == "svg" || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
|
||||
let (mut d, warnings) = vectorcraft_svg::import_with_report(s).map_err(|e| e.to_string())?;
|
||||
|
|
@ -209,6 +216,31 @@ pub fn export(app: &mut VectorcraftApp, format: &str, path: Option<String>, 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<String, String> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<R
|
|||
},
|
||||
"file.save" => 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<Item>)> {
|
|||
"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<Item> = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect();
|
||||
|
|
@ -551,7 +562,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
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<Item>)> {
|
|||
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")]),
|
||||
|
|
|
|||
Loading…
Reference in New Issue