vectorcraft/crates/ui-egui/src/io.rs

265 lines
12 KiB
Rust

//! File I/O through the injected services (pick, read, write, download); the engine's `fileio`
//! decodes and encodes every format.
use serde_json::Value;
use vectorcraft_doc::{ImageObject, Node, NodeKind};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_geom::Affine;
use crate::VectorcraftApp;
/// Open bytes of any readable format as a new document (templates open untitled).
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?;
app.sync_views();
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));
}
Ok(())
}
/// File → Open…
pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> {
if let Some(f) = app.services.open_async.as_mut() {
f();
return Ok(());
}
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
open_path(app, &path)
}
pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
let read = app.services.read.as_ref().ok_or("no file reader")?;
let bytes = read(path)?;
open_bytes(app, path, &bytes, Some(path.to_string()))?;
note_recent(app, path);
Ok(())
}
fn write_out(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> {
if let Some(dl) = app.services.download.as_mut() {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string());
dl(&name, bytes);
return Ok(());
}
let w = app.services.write.as_mut().ok_or("no file writer")?;
w(path, bytes)
}
fn suggested(app: &VectorcraftApp, ext: &str) -> String {
let t = app.session.active().map(|d| d.title()).unwrap_or_else(|| "Untitled".into());
let stem = std::path::Path::new(&t).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(t);
format!("{stem}.{ext}")
}
/// `path`, else a suggested name (web download) or one picked in a save dialog.
fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
match path {
Some(p) => Ok(p),
None if app.services.download.is_some() => Ok(suggested(app, ext)),
None => {
let s = suggested(app, ext);
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or_else(|| "cancelled".into())
}
}
}
/// File → Save / Save As (native format).
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Result<String, String> {
let st = app.session.active().ok_or("no document")?;
let existing = if save_as { None } else { st.path.clone() };
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
let doc = app.session.active().ok_or("no document")?.doc.clone();
let bytes = fileio::encode(&doc, "vectorcraft", &Value::Null).map_err(|e| e.to_string())?;
write_out(app, &path, &bytes)?;
if let Some(st) = app.session.active_mut() {
st.path = Some(path.clone());
st.mark_saved();
}
app.status(format!("Saved {path}"));
note_recent(app, &path);
Ok(path)
}
/// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files).
pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
let r = &mut app.ui.recent_files;
r.retain(|p| p != path);
r.insert(0, path.to_string());
r.truncate((app.session.prefs.recent_files_count as usize).clamp(1, 10));
}
/// Export the active document in `format` (default: the path's extension, else PNG) with the
/// `document.export` options in `params` (artboard, range, scale…). The document keeps its path.
pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<String>, params: &Value) -> Result<String, String> {
let f = fileio::writable_format(format, path.as_deref())?;
let doc = app.session.active().ok_or("no document")?.doc.clone();
let path = target_path(app, path, f.extensions[0])?;
let bytes = fileio::encode(&doc, f.id, params).map_err(|e| e.to_string())?;
write_out(app, &path, &bytes)?;
app.status(format!("Exported {path}"));
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: Value) -> Result<String, String> {
let path = target_path(app, params.get("path").and_then(Value::as_str).map(str::to_string), ext)?;
if let Some(o) = params.as_object_mut() {
o.remove("path");
if o.get("format").is_none() && id == "document.exportSelection" {
let e = Some(fileio::extension(&path)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into());
o.insert("format".into(), serde_json::json!(e));
}
}
let v = app.session.execute(id, &params).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 f = fileio::detect(name, bytes).ok_or_else(|| format!("can't place `{name}`: not a format VectorCraft reads"))?;
if !f.raster {
if !matches!(f.id, "svg" | "svgz") {
return Err(format!("Place doesn't take {} files yet", f.label));
}
let src = fileio::load(name, bytes).map_err(|e| e.to_string())?.doc;
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
app.session.clipboard = nodes;
// Straight to the engine: `app.run` would let the system clipboard replace these nodes.
app.session.execute("edit.pasteInPlace", &serde_json::json!({})).map_err(|e| e.to_string())?;
app.sync_views();
return Ok(());
}
let fileio::RasterImage { key, blob, width: w, height: h } = fileio::raster_image(bytes).map_err(|e| e.to_string())?;
let st = app.session.active().ok_or("no document")?;
let ab = st.doc.artboards.first().map(|a| a.rect).unwrap_or_default();
let s = (ab.width() / w as f64).min(ab.height() / h as f64).min(1.0);
let xf = Affine::translate((ab.center().x - w as f64 * s / 2.0, ab.center().y - h as f64 * s / 2.0)) * Affine::scale(s);
let parent = st.insertion_parent();
let name = name.to_string();
app.session
.edit("Place", |d, sel| {
d.images.insert(key.clone(), blob);
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
n.name = Some(name);
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(())
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use crate::Services;
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app whose writer records what it is given.
fn app() -> (VectorcraftApp, Written) {
let written = Written::default();
let w = written.clone();
let services = Services {
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
(VectorcraftApp::new(Session::new(), services), written)
}
fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
let v = app.session.execute(cmd, &p).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
fn image_size(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> (u32, u32) {
match &app.session.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Image(im) => (im.width, im.height),
other => panic!("{other:?}"),
}
}
#[test]
fn webp_opens_and_places_at_its_pixel_size() {
let (mut app, _) = app();
app.session.execute("file.new", &json!({"width": 3, "height": 2})).unwrap();
let webp = bytes_of(&mut app, "document.serialize", json!({"format": "webp"}));
open_bytes(&mut app, "tiny.webp", &webp, None).unwrap();
assert_eq!(app.session.documents().len(), 2);
assert_eq!(app.views.len(), 2, "views follow the documents");
let first = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
assert_eq!(image_size(&app, first), (3, 2));
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
place_bytes(&mut app, "tiny.webp", &webp).unwrap();
let placed = app.session.doc().unwrap().selection.objects[0];
assert_eq!(image_size(&app, placed), (3, 2));
assert!(place_bytes(&mut app, "x.txt", b"hello").is_err());
}
#[test]
fn templates_open_untitled() {
let (mut app, _) = app();
app.session.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
let pdf = bytes_of(&mut app, "document.serialize", json!({"format": "pdf"}));
open_bytes(&mut app, "brochure.ait", &pdf, Some("/tmp/brochure.ait".into())).unwrap();
let st = app.session.active().unwrap();
assert!(st.title().starts_with("Untitled-"), "{}", st.title());
assert_eq!(st.path, None);
}
#[test]
fn export_uses_the_engine_options_and_keeps_the_path() {
let (mut app, written) = app();
app.session.execute("file.new", &json!({"width": 100, "height": 50, "artboards": 2})).unwrap();
app.session.execute("artboard.setProps", &json!({"index": 1, "width": 40})).unwrap();
app.session.doc_mut().unwrap().path = Some("/tmp/doc.vectorcraft".into());
export(&mut app, None, Some("/tmp/b.png".into()), &json!({"artboard": 1, "scale": 2})).unwrap();
export(&mut app, None, Some("/tmp/copy.vectorcraft".into()), &Value::Null).unwrap();
let w = written.borrow();
assert_eq!(&w[0].1[16..20], 80u32.to_be_bytes(), "artboard 1 (40 pt) at scale 2");
assert!(vectorcraft_format::sniff(&w[1].1));
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft"));
drop(w);
assert!(export(&mut app, None, Some("/tmp/x.bmp".into()), &Value::Null).is_err(), "BMP is read-only");
}
#[test]
fn export_for_screens_with_params_runs_the_engine() {
let (mut app, _) = app();
app.session.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
let r = app.run("file.exportForScreens", json!({"range": "2-3", "formats": [{"format": "pdf"}]})).unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0]["name"], "Artboard-2.pdf");
assert!(app.ui.dialog.is_none());
app.run("file.exportForScreens", Value::Null).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("exportForScreens"));
}
#[test]
fn control_export_without_path_returns_bytes() {
let (mut app, written) = app();
app.session.execute("file.new", &json!({"width": 30, "height": 20})).unwrap();
let (req, _) = crate::control::ControlRequest::new("app.export", json!({"format": "png", "scale": 2}));
let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") };
assert_eq!(r["ok"], true, "{r}");
let png = vectorcraft_format::base64_decode(r["result"]["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(&png[16..20], 60u32.to_be_bytes(), "30 pt at scale 2");
assert!(written.borrow().is_empty(), "no file and no save dialog");
}
}