//! File I/O through the injected services: open, save, export, place. use std::sync::Arc; use drawcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use drawcraft_geom::Affine; use crate::DrawcraftApp; fn ext(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } /// Decode an image and make a new document (or place into the active one when `place`). fn image_doc(name: &str, bytes: &[u8]) -> Result { let (w, h) = image_size(bytes)?; let mut d = Document::new(w as f64, h as f64); d.title = name.to_string(); let key = format!("img{:016x}", hash(bytes)); d.images.insert(key.clone(), ImageBlob { mime: mime_of(name), bytes: Arc::new(bytes.to_vec()) }); let l = d.layers[0].id; let id = d.alloc_id(); let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf: Affine::IDENTITY, link: None })); n.name = Some(name.to_string()); d.insert(Some(l), 0, n).map_err(|e| e.to_string())?; Ok(d) } pub fn image_size(bytes: &[u8]) -> Result<(u32, u32), String> { // PNG / JPEG / GIF / WebP headers — enough to size a placed image without decoding. if bytes.len() > 24 && &bytes[..8] == b"\x89PNG\r\n\x1a\n" { let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); return Ok((w, h)); } if bytes.len() > 10 && &bytes[..6] == b"GIF89a" || bytes.len() > 10 && &bytes[..6] == b"GIF87a" { return Ok((u16::from_le_bytes([bytes[6], bytes[7]]) as u32, u16::from_le_bytes([bytes[8], bytes[9]]) as u32)); } if bytes.len() > 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 { let mut i = 2; while i + 9 < bytes.len() { if bytes[i] != 0xFF { i += 1; continue; } let marker = bytes[i + 1]; let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32; let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32; return Ok((w, h)); } i += 2 + len; } } Err("unsupported image format".into()) } fn mime_of(name: &str) -> String { match ext(name).as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", _ => "application/octet-stream", } .to_string() } fn hash(b: &[u8]) -> u64 { // FNV-1a: stable content key for embedded images. let mut h: u64 = 0xcbf29ce484222325; for x in b { h ^= *x as u64; h = h.wrapping_mul(0x100000001b3); } h } /// Open bytes as a new document. pub fn open_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { let e = ext(name); let (doc, keep_path) = if e == "drawcraft" || drawcraft_format::sniff(bytes) { let mut d = drawcraft_format::load(bytes).map_err(|e| e.to_string())?; if d.title.is_empty() { d.title = name.to_string(); } (d, true) } else if e == "svg" || bytes.starts_with(b" 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 DrawcraftApp, path: &str) -> Result<(), String> { let read = app.services.read.as_ref().ok_or("no file reader")?; let bytes = read(path)?; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); open_bytes(app, &name, &bytes, Some(path.to_string())) } fn write_out(app: &mut DrawcraftApp, 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: &DrawcraftApp, 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}") } /// File → Save / Save As (native format). pub fn save(app: &mut DrawcraftApp, path: Option, save_as: bool) -> Result { let st = app.session.active().ok_or("no document")?; let existing = if save_as { None } else { st.path.clone() }; let path = match path.or(existing) { Some(p) => p, None if app.services.download.is_some() => suggested(app, "drawcraft"), None => { let s = suggested(app, "drawcraft"); app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")? } }; let bytes = drawcraft_format::save(&app.session.active().unwrap().doc, true); write_out(app, &path, &bytes)?; if let Some(st) = app.session.active_mut() { st.path = Some(path.clone()); st.saved_revision = st.revision; } app.status(format!("Saved {path}")); Ok(path) } /// Export the document as SVG / PNG / JPEG. pub fn export(app: &mut DrawcraftApp, format: &str, path: Option, scale: f64) -> Result { let st = app.session.active().ok_or("no document")?; let doc = st.doc.clone(); let path = match path { Some(p) => p, None if app.services.download.is_some() => suggested(app, format), None => { let s = suggested(app, format); app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")? } }; let bytes = match format { "svg" => drawcraft_svg::export(&doc, &drawcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(), "png" | "jpg" | "jpeg" => { let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?; let img = app.canvas.renderer.render_region(&doc, r, scale, format != "png"); if format == "png" { img.to_png() } else { encode_jpeg(&img)? } } other => return Err(format!("unknown export format `{other}`")), }; write_out(app, &path, &bytes)?; app.status(format!("Exported {path}")); Ok(path) } fn encode_jpeg(img: &drawcraft_render::Rendered) -> Result, String> { let _ = img; Err("JPEG export lands with M4.6".into()) } /// File → Place… (embed an image or SVG into the active document). pub fn place_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { let e = ext(name); if e == "svg" { let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?; let src = drawcraft_svg::import(s).map_err(|e| e.to_string())?; let nodes: Vec = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect(); app.session.clipboard = nodes; app.run("edit.pasteInPlace", serde_json::json!({}))?; return Ok(()); } let (w, h) = image_size(bytes)?; let key = format!("img{:016x}", hash(bytes)); 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 mime = mime_of(name); let bytes = Arc::new(bytes.to_vec()); let parent = st.insertion_parent(); let name = name.to_string(); app.session .edit("Place", |d, sel| { d.images.insert(key.clone(), ImageBlob { mime, bytes }); 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()) }