diff --git a/Cargo.lock b/Cargo.lock index 445484e4..821f7a47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4130,6 +4130,7 @@ dependencies = [ name = "vectorcraft-engine" version = "0.1.1" dependencies = [ + "flate2", "image", "kurbo 0.13.1", "log", diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 3bf8c766..c3e92758 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] } diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 064dfb95..d3f966d7 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -1,14 +1,21 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. +use std::cell::Cell; +use std::rc::Rc; use std::sync::{Arc, Mutex}; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; +use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::{Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; +/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and +/// whether Shift was held: where dropped files land. +type DragPos = Rc>>; + const CANVAS_ID: &str = "vectorcraft_canvas"; const LOADING_ID: &str = "vectorcraft_loading"; @@ -23,6 +30,7 @@ pub fn start() { log::error!("missing "); return; }; + let drag = track_drag(&canvas); let mut options = eframe::WebOptions::default(); if query().contains("webgl") && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup @@ -38,8 +46,9 @@ pub fn start() { log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); } let inbox: Inbox = Arc::default(); - let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); - Ok(Box::new(WebShell { app, inbox })) + let place_inbox: PlaceInbox = Arc::default(); + let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), place_inbox.clone(), cc.egui_ctx.clone())); + Ok(Box::new(WebShell { app, inbox, place_inbox, drag })) }), ) .await; @@ -56,29 +65,58 @@ fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } +/// Follow file drags over the canvas: drag events carry the pointer position, which egui doesn't +/// get during a drag. +fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos { + let pos = DragPos::default(); + let p = pos.clone(); + let on_drag = wasm_bindgen::closure::Closure::::new(move |e: web_sys::DragEvent| { + p.set(Some((e.offset_x() as f32, e.offset_y() as f32, e.shift_key()))); + }); + for kind in ["dragover", "drop"] { + if let Err(e) = canvas.add_event_listener_with_callback(kind, on_drag.as_ref().unchecked_ref()) { + log::error!("couldn't follow {kind} events: {e:?}"); + } + } + // The listener lives as long as the page. + on_drag.forget(); + pos +} + /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously) -/// and feed them through the inbox. +/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened. struct WebShell { app: VectorcraftApp, inbox: Inbox, + place_inbox: PlaceInbox, + drag: DragPos, } impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - let inbox = self.inbox.clone(); - let ctx = ctx.clone(); - wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); - ctx.request_repaint(); + if !dropped.is_empty() { + let at = self.drag.take(); + let z = ctx.zoom_factor(); + let target = self.app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2)); + for f in dropped { + let (inbox, place_inbox, ctx) = (self.inbox.clone(), self.place_inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + match f.bytes_async().await { + Ok(bytes) => { + match target { + DropTarget::Place { at, embed } => { + place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) }) + } + DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), + } + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), - } - }); + }); + } } self.app.logic(ctx); } @@ -92,9 +130,29 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox, ctx: egui::Context) -> Services { +fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); + let picked = place_inbox.clone(); + let place_ctx = ctx.clone(); Services { + // File → Place…: the picked files go to the Place dialog. + place_async: Some(Box::new(move || { + let inbox = picked.clone(); + let ctx = place_ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)); + let Some(files) = dialog.pick_files().await else { + return; + }; + let mut arrived = vec![]; + for f in files { + arrived.push(PlaceArrival { name: f.file_name(), bytes: f.read().await, drop: None }); + } + inbox.lock().unwrap_or_else(|e| e.into_inner()).extend(arrived); + ctx.request_repaint(); + }); + })), + place_inbox: Some(place_inbox), open_async: Some(Box::new(move || { let inbox = open_inbox.clone(); let ctx = ctx.clone(); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index ae8fbcc0..1aa4dc7d 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -104,6 +104,15 @@ fn services() -> Services { .pick_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_open_multi: Some(Box::new(|| { + fileio::place_filters() + .fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e4f0f055..06901e21 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,6 +22,7 @@ vectorcraft-svg = { workspace = true } vectorcraft-format = { workspace = true } vectorcraft-pdf = { workspace = true } image = { workspace = true } +flate2 = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 50313a25..c629771c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -26,6 +26,8 @@ pub struct RasterImage { pub blob: ImageBlob, pub width: u32, pub height: u32, + /// The resolution the file declares (see [`super::ppi::resolution`]). + pub ppi: Option<(f64, f64)>, } fn err(e: impl std::fmt::Display) -> EngineError { @@ -33,7 +35,7 @@ fn err(e: impl std::fmt::Display) -> EngineError { } /// The last component of a path or file name. -fn file_name(name: &str) -> String { +pub fn file_name(name: &str) -> String { std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()) } @@ -116,25 +118,37 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option Result { +/// A file named by a command's params: `{path}` (read from disk) or `{name, dataBase64}`. +pub(crate) struct Source<'a> { + /// The path, or the given name (default "Untitled"): for the extension and the title. + pub name: &'a str, + pub bytes: Vec, + pub path: Option<&'a str>, +} + +/// The file `p` names for command `cmd` (see [`Source`]). +pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result> { match (str_param(p, "path"), str_param(p, "dataBase64")) { - (Some(path), _) => { - let bytes = read_file(path)?; - open_bytes(s, path, &bytes, Some(path.to_string())) - } + (Some(path), _) => Ok(Source { name: path, bytes: read_file(path)?, path: Some(path) }), (None, Some(b64)) => { - let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?; - open_bytes(s, str_param(p, "name").unwrap_or("Untitled"), &bytes, None) + let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad base64"))?; + Ok(Source { name: str_param(p, "name").unwrap_or("Untitled"), bytes, path: None }) } - _ => Err(bad("document.open", "give path, or name and dataBase64")), + _ => Err(bad(cmd, "give path, or name and dataBase64")), } } +pub(super) fn open(s: &mut Session, p: &Value) -> Result { + let src = source(p, "document.open")?; + open_bytes(s, src.name, &src.bytes, src.path.map(str::to_string)) +} + /// Decode an image's header (and, for formats stored as PNG, its pixels). pub fn raster_image(bytes: &[u8]) -> Result { let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?; let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; + let ppi = super::ppi::resolution(bytes); let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") { (bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?) } else { @@ -142,18 +156,23 @@ pub fn raster_image(bytes: &[u8]) -> Result { let size = img.dimensions(); let mut png = Vec::new(); img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?; + // The stored PNG keeps the file's resolution. + let png = match ppi { + Some(r) => super::ppi::with_png_resolution(&png, r), + None => png, + }; (png, "image/png", size) }; if width == 0 || height == 0 { return Err(err("the image is empty")); } let blob = ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) }; - Ok(RasterImage { key: blob.content_key(), blob, width, height }) + Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi }) } /// An image as a document of its pixel size (1 px = 1 pt), the image named after the file. fn raster_doc(name: &str, bytes: &[u8]) -> Result { - let RasterImage { key, blob, width, height } = raster_image(bytes)?; + let RasterImage { key, blob, width, height, .. } = raster_image(bytes)?; let mut d = Document::new(width as f64, height as f64); let layer = d.layers[0].id; let id = d.alloc_id(); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 6c5a412d..aff8c913 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -16,13 +16,15 @@ mod encode; mod export; mod load; pub mod pdf; +pub mod ppi; mod save; mod svg; use serde_json::{Value, json}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; -pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image}; +pub(crate) use load::source; +pub use load::{Loaded, RasterImage, detect, file_name, load, open_bytes, raster_image}; pub use save::{save_encoding, save_format}; pub use svg::options_map as svg_options; @@ -240,12 +242,16 @@ pub const OPEN_EXTS: &[&str] = /// Open-dialog filters: "All readable files" first, then one per readable format, then swatch /// libraries (which open in the library panel) and flattener presets (imported). pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", OPEN_EXTS)) - .chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions))) + place_filters() .chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS))) } +/// File → Place dialog filters: "All readable files", then one per readable format. +pub fn place_filters() -> impl Iterator { + std::iter::once(("All readable files", OPEN_EXTS)).chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions))) +} + /// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`). pub fn format(id_or_ext: &str) -> Option<&'static Format> { let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase(); diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs new file mode 100644 index 00000000..6f704b9d --- /dev/null +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -0,0 +1,271 @@ +//! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and +//! WebP EXIF resolution tags, BMP pixels per metre. Place sizes an image by it (72 ppi when the file +//! has none); images converted to PNG on import keep it as a `pHYs` chunk. + +const INCH_M: f64 = 0.0254; +const INCH_CM: f64 = 2.54; + +/// The resolution a file declares as (horizontal, vertical) ppi; `None` when it declares none (or +/// only an aspect ratio). +pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { + let r = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + png(bytes) + } else if bytes.starts_with(&[0xff, 0xd8]) { + jpeg(bytes) + } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { + tiff(bytes) + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + webp(bytes) + } else if bytes.starts_with(b"BM") { + bmp(bytes) + } else { + None + }; + r.filter(|(x, y)| [*x, *y].iter().all(|v| v.is_finite() && (1.0..=100_000.0).contains(v))) +} + +/// `n` bytes at `at` (`None` past the end; offsets come from the file, so no overflow either). +fn slice(b: &[u8], at: usize, n: usize) -> Option<&[u8]> { + b.get(at..at.checked_add(n)?) +} +fn bytes(b: &[u8], at: usize) -> Option<[u8; N]> { + slice(b, at, N)?.try_into().ok() +} +fn be16(b: &[u8], at: usize) -> Option { + bytes(b, at).map(u16::from_be_bytes) +} +fn be32(b: &[u8], at: usize) -> Option { + bytes(b, at).map(u32::from_be_bytes) +} +fn le32(b: &[u8], at: usize) -> Option { + bytes(b, at).map(u32::from_le_bytes) +} + +/// Pixels per inch from the whole pixels per metre PNG and BMP store: a whole number of ppi when +/// the metre value is that rounded (300 ppi is stored as 11811 per metre, 299.9994 ppi). +fn per_metre(ppm: u32) -> f64 { + let v = ppm as f64 * INCH_M; + let whole = v.round(); + if (v - whole).abs() <= INCH_M / 2.0 + 1e-9 { whole } else { v } +} + +/// PNG chunks after the signature: (type, data). +fn png_chunks(b: &[u8]) -> impl Iterator { + let mut at = 8; + std::iter::from_fn(move || { + let len = be32(b, at)? as usize; + let ty = slice(b, at + 4, 4)?; + let data = slice(b, at + 8, len)?; + at = at.checked_add(12 + len)?; + Some((ty, data)) + }) +} + +fn png(b: &[u8]) -> Option<(f64, f64)> { + let (_, d) = png_chunks(b).take_while(|(ty, _)| *ty != b"IDAT").find(|(ty, _)| *ty == b"pHYs")?; + // Unit 1 = metre; 0 only gives the pixel aspect ratio. + (d.get(8) == Some(&1)).then(|| (per_metre(be32(d, 0).unwrap_or(0)), per_metre(be32(d, 4).unwrap_or(0)))) +} + +fn jpeg(b: &[u8]) -> Option<(f64, f64)> { + let (mut jfif, mut exif) = (None, None); + let mut at = 2; + while at + 4 <= b.len() && b[at] == 0xff { + let marker = b[at + 1]; + // 0xff fill bytes pad between segments. + if marker == 0xff { + at += 1; + continue; + } + if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + at += 2; + continue; + } + // Start of scan or end of image: no more metadata. + if marker == 0xda || marker == 0xd9 { + break; + } + let len = be16(b, at + 2)? as usize; + let seg = slice(b, at + 4, len.checked_sub(2)?)?; + match marker { + 0xe0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => { + let (x, y) = (be16(seg, 8)? as f64, be16(seg, 10)? as f64); + jfif = match seg[7] { + 1 => Some((x, y)), + 2 => Some((x * INCH_CM, y * INCH_CM)), + _ => None, + }; + } + 0xe1 if seg.starts_with(b"Exif\0\0") => exif = tiff(&seg[6..]), + _ => {} + } + at += 2 + len; + } + jfif.or(exif) +} + +/// TIFF (or EXIF) IFD0 XResolution / YResolution / ResolutionUnit. +fn tiff(b: &[u8]) -> Option<(f64, f64)> { + let le = b.starts_with(b"II"); + let u16_at = |at: usize| bytes(b, at).map(if le { u16::from_le_bytes } else { u16::from_be_bytes }); + let u32_at = |at: usize| bytes(b, at).map(if le { u32::from_le_bytes } else { u32::from_be_bytes }); + let ifd = u32_at(4)? as usize; + let (mut x, mut y, mut unit) = (None, None, 2); + for i in 0..u16_at(ifd)? as usize { + let e = ifd.checked_add(2 + i * 12)?; + let rational = || -> Option { + let at = u32_at(e + 8)? as usize; + let (n, d) = (u32_at(at)?, u32_at(at.checked_add(4)?)?); + (d != 0).then(|| n as f64 / d as f64) + }; + match u16_at(e)? { + 282 => x = rational(), + 283 => y = rational(), + 296 => unit = u16_at(e + 8)?, + _ => {} + } + } + let k = match unit { + 2 => 1.0, + 3 => INCH_CM, + _ => return None, + }; + let x = x?; + Some((x * k, y.unwrap_or(x) * k)) +} + +fn webp(b: &[u8]) -> Option<(f64, f64)> { + let mut at = 12; + while at + 8 <= b.len() { + let len = le32(b, at + 4)? as usize; + let data = b.get(at + 8..at + 8 + len)?; + if &b[at..at + 4] == b"EXIF" { + return tiff(data.strip_prefix(b"Exif\0\0").unwrap_or(data)); + } + at += 8 + len + (len & 1); + } + None +} + +fn bmp(b: &[u8]) -> Option<(f64, f64)> { + // BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28. + if le32(b, 14)? < 40 { + return None; + } + let ppm = |at| le32(b, at).filter(|v| (1..=i32::MAX as u32).contains(v)).map(per_metre); + Some((ppm(38)?, ppm(42)?)) +} + +/// `png` with a `pHYs` chunk declaring `ppi` right after the header (any existing one is replaced). +pub fn with_png_resolution(png: &[u8], (x, y): (f64, f64)) -> Vec { + let Some(head) = be32(png, 8).and_then(|len| slice(png, 0, 20usize.checked_add(len as usize)?)) else { return png.to_vec() }; + let ppm = |v: f64| ((v / INCH_M).round() as u32).to_be_bytes(); + let mut chunk = Vec::with_capacity(21); + chunk.extend_from_slice(&9u32.to_be_bytes()); + chunk.extend_from_slice(b"pHYs"); + chunk.extend_from_slice(&ppm(x)); + chunk.extend_from_slice(&ppm(y)); + chunk.push(1); + let mut crc = flate2::Crc::new(); + crc.update(&chunk[4..]); + chunk.extend_from_slice(&crc.sum().to_be_bytes()); + let mut out = Vec::with_capacity(png.len() + chunk.len()); + out.extend_from_slice(head); + out.extend_from_slice(&chunk); + let mut at = head.len(); + while let Some(chunk) = be32(png, at).and_then(|len| slice(png, at, 12usize.checked_add(len as usize)?)) { + if chunk.get(4..8) != Some(b"pHYs") { + out.extend_from_slice(chunk); + } + at += chunk.len(); + } + out.extend_from_slice(png.get(at..).unwrap_or_default()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png_of(w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + image::RgbaImage::new(w, h).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + fn close(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01 + } + + #[test] + fn png_phys_round_trips_and_still_decodes() { + let png = png_of(4, 3); + assert_eq!(resolution(&png), None); + let tagged = with_png_resolution(&png, (300.0, 150.0)); + assert!(close(resolution(&tagged).unwrap(), (300.0, 150.0)), "{:?}", resolution(&tagged)); + let again = with_png_resolution(&tagged, (72.0, 72.0)); + assert!(close(resolution(&again).unwrap(), (72.0, 72.0)), "replaced, not duplicated"); + assert_eq!(again.len(), tagged.len()); + let img = image::load_from_memory(&again).unwrap(); + assert_eq!((img.width(), img.height()), (4, 3)); + } + + #[test] + fn jpeg_jfif_density_in_dpi_and_dpcm() { + let mut jpg = Vec::new(); + image::RgbImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + // The encoder writes JFIF with units 0 (aspect only). + assert_eq!(resolution(&jpg), None); + let jfif = jpg.windows(5).position(|w| w == b"JFIF\0").unwrap(); + jpg[jfif + 7] = 1; + jpg[jfif + 8..jfif + 10].copy_from_slice(&240u16.to_be_bytes()); + jpg[jfif + 10..jfif + 12].copy_from_slice(&240u16.to_be_bytes()); + assert_eq!(resolution(&jpg), Some((240.0, 240.0))); + jpg[jfif + 7] = 2; + jpg[jfif + 8..jfif + 10].copy_from_slice(&100u16.to_be_bytes()); + assert!(close(resolution(&jpg).unwrap(), (254.0, 240.0 * 2.54))); + } + + /// A little-endian TIFF/EXIF block declaring `x`/`y` resolution in `unit`. + fn exif_block(x: u32, y: u32, unit: u16) -> Vec { + let mut b = b"II*\0".to_vec(); + b.extend_from_slice(&8u32.to_le_bytes()); + b.extend_from_slice(&3u16.to_le_bytes()); + let data = 8 + 2 + 3 * 12 + 4; + for (tag, ty, val) in [(282u16, 5u16, data as u32), (283, 5, data as u32 + 8), (296, 3, unit as u32)] { + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&ty.to_le_bytes()); + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&val.to_le_bytes()); + } + b.extend_from_slice(&0u32.to_le_bytes()); + for v in [x, 1, y, 1] { + b.extend_from_slice(&v.to_le_bytes()); + } + b + } + + #[test] + fn tiff_and_exif_resolution_tags() { + assert_eq!(resolution(&exif_block(300, 200, 2)), Some((300.0, 200.0))); + assert!(close(resolution(&exif_block(100, 100, 3)).unwrap(), (254.0, 254.0))); + assert_eq!(resolution(&exif_block(300, 300, 1)), None, "no absolute unit"); + // WebP carries EXIF in its own chunk. + let exif = exif_block(150, 150, 2); + let mut webp = b"RIFF\0\0\0\0WEBP".to_vec(); + webp.extend_from_slice(b"EXIF"); + webp.extend_from_slice(&(exif.len() as u32).to_le_bytes()); + webp.extend_from_slice(&exif); + assert_eq!(resolution(&webp), Some((150.0, 150.0))); + } + + #[test] + fn bmp_pixels_per_metre() { + let mut bmp = Vec::new(); + image::RgbImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut bmp), image::ImageFormat::Bmp).unwrap(); + bmp[38..42].copy_from_slice(&11811u32.to_le_bytes()); + bmp[42..46].copy_from_slice(&11811u32.to_le_bytes()); + assert!(close(resolution(&bmp).unwrap(), (300.0, 300.0))); + assert_eq!(resolution(b"GIF89a"), None); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2657064c..12e110b3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ mod panelcmds; mod path; mod pathops; mod patterncmds; +mod place; pub mod prefscmds; pub mod rasterfx; mod recolor; @@ -215,6 +216,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(fileio::pdf::specs()); v.extend(docsetup::specs()); v.extend(newdoc::specs()); + v.extend(place::specs()); v }) } diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs new file mode 100644 index 00000000..ee4205d6 --- /dev/null +++ b/crates/engine/src/cmd/place/adopt.rs @@ -0,0 +1,231 @@ +//! Resources placed art brings along: the images, symbols, pattern swatches and colour swatches it +//! uses (also through symbol and pattern art and through tint swatches) join the document. A name +//! the document already uses for something different gets a free variant (`Star 2`, +//! `pdf-image-1 2`…) and the art's references follow; a colour linked to a swatch the document +//! defines differently keeps its colour, unlinked. Text styles are left as they are: the art carries +//! its own formatting. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use vectorcraft_color::Paint; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; + +use super::unique_name; + +/// Visit `n` and every descendant (children and opacity-mask art) mutably. +pub(super) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { + f(n); + if let Some(m) = &mut n.mask { + each_mut(Arc::make_mut(&mut m.art), f); + } + if let Some(ch) = n.children_mut() { + for c in ch { + each_mut(Arc::make_mut(c), f); + } + } +} + +/// A reference from art to a document resource. +enum Ref<'a> { + Image(&'a mut String), + Symbol(&'a mut String), + Pattern(&'a mut String), + /// A colour's (or gradient's) swatch link; renaming it to `None` unlinks it. + Swatch(&'a mut Option), +} + +/// Every resource reference held by paint `p`. +fn paint_refs(p: &mut Paint, f: &mut impl FnMut(Ref)) { + match p { + Paint::Pattern { pattern, .. } => f(Ref::Pattern(pattern)), + Paint::Gradient(g) if g.swatch.is_some() => f(Ref::Swatch(&mut g.swatch)), + _ => {} + } + p.map_links(&mut |_, link, _| { + if link.is_some() { + f(Ref::Swatch(link)); + } + false + }); +} + +/// Every resource reference in `n` and its descendants. +fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) { + each_mut(n, &mut |n| { + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(fl) => paint_refs(&mut fl.paint, f), + AppearanceItem::Stroke(st) => paint_refs(&mut st.paint, f), + } + } + match &mut n.kind { + NodeKind::Text(t) => { + for r in &mut t.runs { + paint_refs(&mut r.style.fill, f); + paint_refs(&mut r.style.stroke, f); + } + } + NodeKind::Image(im) => f(Ref::Image(&mut im.key)), + NodeKind::SymbolInstance { symbol, .. } => f(Ref::Symbol(symbol)), + _ => {} + } + }); +} + +/// Resource names in first-use order. +#[derive(Default)] +struct Used { + images: Vec, + symbols: Vec, + patterns: Vec, + swatches: Vec, +} + +impl Used { + fn note(&mut self, r: Ref) { + let (list, name) = match r { + Ref::Image(k) => (&mut self.images, &*k), + Ref::Symbol(k) => (&mut self.symbols, &*k), + Ref::Pattern(k) => (&mut self.patterns, &*k), + Ref::Swatch(Some(k)) => (&mut self.swatches, &*k), + Ref::Swatch(None) => return, + }; + if !list.contains(name) { + list.push(name.clone()); + } + } +} + +/// What each resource the art uses is called in the document (`None`: a swatch link to drop) and +/// whether its definition is copied in. +type Renames = BTreeMap, bool)>; + +/// Names in `dst` for the `used` definitions of `src` (`def`): the same name when `dst` has none +/// (copied) or an equal one (shared), else a free variant (`taken`, copied). +fn names( + used: &[String], + def: impl Fn(&str) -> Option, + existing: impl Fn(&str) -> Option, + taken: impl Fn(&str) -> bool, +) -> Renames { + let mut out = Renames::new(); + for name in used { + let Some(d) = def(name) else { continue }; + let entry = match existing(name) { + None => (Some(name.clone()), true), + Some(e) if e == d => (Some(name.clone()), false), + Some(_) => (Some(unique_name(name, |c| taken(c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true), + }; + out.insert(name.clone(), entry); + } + out +} + +/// The renamed resources whose definitions are copied: (name in `src`, name in `dst`). +fn copies(m: &Renames) -> Vec<(&str, &str)> { + m.iter().filter(|(_, (_, copy))| *copy).filter_map(|(old, (new, _))| Some((old.as_str(), new.as_deref()?))).collect() +} + +/// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point +/// `nodes` at the names they got. +pub(super) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { + let mut used = Used::default(); + for n in nodes.iter_mut() { + refs(n, &mut |r| used.note(r)); + } + // Symbol and pattern art and tint swatches can use more resources (the lists grow while they + // are walked). + let (mut si, mut pi, mut wi) = (0, 0, 0); + loop { + let mut art: Vec = vec![]; + if let Some(name) = used.symbols.get(si) { + si += 1; + art.extend(src.symbols.iter().find(|s| s.name == *name).map(|s| (*s.art).clone())); + } else if let Some(name) = used.patterns.get(pi) { + pi += 1; + art.extend(src.pattern(name).into_iter().flat_map(|p| p.art.iter().map(|a| (**a).clone()))); + } else if let Some(name) = used.swatches.get(wi) { + wi += 1; + if let Some(mut paint) = src.swatch(name).map(|w| w.paint.clone()) { + paint_refs(&mut paint, &mut |r| used.note(r)); + } + } else { + break; + } + for mut a in art { + refs(&mut a, &mut |r| used.note(r)); + } + } + + let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |c| dst.images.contains_key(c)); + let symbols = names( + &used.symbols, + |n| src.symbols.iter().find(|s| s.name == n), + |n| dst.symbols.iter().find(|s| s.name == n), + |c| dst.symbols.iter().any(|s| s.name == c), + ); + let patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |c| dst.pattern(c).is_some()); + // A swatch the document defines differently isn't renamed (that would add look-alike swatches): + // the colours linked to it keep their look, unlinked. + let swatches: Renames = used + .swatches + .iter() + .filter_map(|n| { + let w = src.swatch(n)?; + let entry = match dst.swatch(n) { + None => (Some(n.clone()), true), + Some(e) if e == w => (Some(n.clone()), false), + Some(_) => (None, false), + }; + Some((n.clone(), entry)) + }) + .collect(); + + let mut rename = |r: Ref| { + let (map, k) = match r { + Ref::Image(k) => (&images, k), + Ref::Symbol(k) => (&symbols, k), + Ref::Pattern(k) => (&patterns, k), + Ref::Swatch(link) => { + if let Some((new, _)) = link.as_deref().and_then(|k| swatches.get(k)) { + *link = new.clone(); + } + return; + } + }; + if let Some((Some(new), _)) = map.get(k.as_str()) { + *k = new.clone(); + } + }; + for n in nodes.iter_mut() { + refs(n, &mut rename); + } + for (old, new) in copies(&images) { + if let Some(blob) = src.images.get(old) { + dst.images.insert(new.to_string(), blob.clone()); + } + } + for (old, new) in copies(&symbols) { + if let Some(mut def) = src.symbols.iter().find(|s| s.name == old).cloned() { + def.name = new.to_string(); + refs(Arc::make_mut(&mut def.art), &mut rename); + dst.symbols.push(def); + } + } + for (old, new) in copies(&patterns) { + if let Some(mut def) = src.pattern(old).cloned() { + def.name = new.to_string(); + for a in &mut def.art { + refs(Arc::make_mut(a), &mut rename); + } + dst.patterns.push(def); + } + } + for (old, _) in copies(&swatches) { + if let Some(mut w) = src.swatch(old).cloned() { + paint_refs(&mut w.paint, &mut rename); + dst.swatches.push(w); + } + } +} diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs new file mode 100644 index 00000000..02785957 --- /dev/null +++ b/crates/engine/src/cmd/place/mod.rs @@ -0,0 +1,465 @@ +//! File → Place: another file's art into the active document as one undo step that never touches +//! the clipboard. +//! +//! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution +//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its path with +//! `link`. +//! - An SVG becomes one group of its art. +//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page +//! (`crop: "crop"`) or bounded by its art (`crop: "bounding"`). +//! +//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was, +//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and +//! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the +//! `place` tool) with several files, which emits one `file.place` per click or drag. + +mod adopt; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; + +use super::fileio::{self, Format, RasterImage}; +use super::*; +use crate::{EngineError, MAX_COORD}; + +const PLACE: &str = "file.place"; +const QUEUE: &str = "file.place.queue"; +/// The most files one `file.place.queue` loads. +const MAX_QUEUE: usize = 100; +/// The longest thumbnail side `thumbnail` may ask for (px). +const MAX_THUMBNAIL: f64 = 512.0; + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.place", + "Place…", + ["File"], + Some("Cmd+Shift+P"), + "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", + has_doc, + place + ), + cmd!( + query "file.place.info", + "Placed File Info", + [], + None, + "{path | name+dataBase64, page?, crop?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", + always, + info + ), + cmd!( + query "image.info", + "Image Info", + [], + None, + "{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", + has_doc, + image_info + ), + cmd!( + query "file.place.queue", + "Load Place Cursor", + [], + None, + "{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}", + has_doc, + queue + ), + ] +} + +// ---------- reading the file ---------- + +/// A file's art at its natural size, before it joins a document. +enum Art { + Image(RasterImage), + Vector { + /// The loaded document: the source of the art's resources. + src: Box, + nodes: Vec, + /// Crop: the page or artboard the art is clipped to. + clip: Option, + }, +} + +struct Loaded { + /// The file name (the placed object's name). + name: String, + format: &'static Format, + art: Art, + /// The art's natural box (what `at` centres and `rect` fits). + natural: Rect, + warnings: Vec, + /// The file's path (for Link). + path: Option, +} + +/// Points per pixel of a raster image at the resolution it declares (72 ppi when none). +fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) { + let (x, y) = ppi.unwrap_or((72.0, 72.0)); + (72.0 / x, 72.0 / y) +} + +/// The file `p` names, read into placeable art. +fn load(p: &Value, cmd: &str) -> Result { + let src = fileio::source(p, cmd)?; + let name = fileio::file_name(src.name); + let format = fileio::detect(src.name, &src.bytes) + .ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?; + let page = match p.get("page") { + None => 1, + Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))? as usize, + }; + let crop = match str_param(p, "crop").unwrap_or("crop") { + "crop" => true, + "bounding" => false, + other => return Err(bad(cmd, format!("crop `{other}`: use \"crop\" or \"bounding\""))), + }; + let path = src.path.map(str::to_string); + if format.raster { + let img = fileio::raster_image(&src.bytes)?; + let (sx, sy) = pt_per_px(img.ppi); + let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy); + return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], path }); + } + let (mut doc, warnings) = match format.id { + // Only the pages up to the one placed are read. + "pdf" | "ai" | "ait" => { + let opts = vectorcraft_pdf::ImportOptions { max_pages: Some(page), ..Default::default() }; + let r = vectorcraft_pdf::import_with_report(&src.bytes, &opts).map_err(|e| bad(cmd, format!("{name}: {e}")))?; + (r.document, r.warnings) + } + _ => { + let l = fileio::load(src.name, &src.bytes)?; + (l.doc, l.warnings) + } + }; + doc.drop_edit_modes(); + let layers = std::mem::take(&mut doc.layers); + let (nodes, clip) = if matches!(format.id, "svg" | "svgz") { + (art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None) + } else { + let n = doc.artboards.len(); + let board = doc.artboards.get(page - 1).map(|a| a.rect).ok_or_else(|| { + let what = if format.id == "vectorcraft" { "artboard" } else { "page" }; + bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)")) + })?; + // A PDF import has one layer per page; a native document's art is whatever lies on the + // artboard. + let pages: Vec<&Arc> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(page - 1).into_iter().collect() }; + let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); + let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)); + (nodes, crop.then_some(board)) + }; + let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds()))); + let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; + Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, path }) +} + +/// A visible, non-template layer. +fn placeable(l: &Node) -> bool { + l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. }) +} + +/// The placeable art among `nodes`: guides left out, visible sublayers as groups. +fn art_of<'a>(nodes: impl Iterator>) -> Vec { + nodes + .filter_map(|c| match &c.kind { + NodeKind::Path { guide: true, .. } => None, + NodeKind::Layer { children, .. } => placeable(c).then(|| { + let mut g = (**c).clone(); + g.kind = NodeKind::Group { children: art_of(children.iter()).into_iter().map(Arc::new).collect(), clip: false }; + g + }), + _ => Some((**c).clone()), + }) + .collect() +} + +/// The art as one object of `d` (its resources added), at its natural size and position. +fn build(d: &mut Document, l: Loaded, link: Option) -> Node { + let mut node = match l.art { + Art::Image(img) => { + let (sx, sy) = pt_per_px(img.ppi); + d.images.entry(img.key.clone()).or_insert(img.blob); + let im = ImageObject { key: img.key, width: img.width, height: img.height, xf: Affine::scale_non_uniform(sx, sy), link }; + Node::new(d.alloc_id(), NodeKind::Image(im)) + } + Art::Vector { src, mut nodes, clip } => { + adopt::adopt(d, &src, &mut nodes); + let mut children: Vec> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect(); + if let Some(r) = clip { + let mut c = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut c.kind { + *clipping = true; + } + children.insert(0, Arc::new(c)); + } + Node::new(d.alloc_id(), NodeKind::Group { children, clip: clip.is_some() }) + } + }; + node.name = Some(l.name); + node +} + +/// `node` moved and scaled by `m` as a whole: strokes, effects and pattern tiles go along. +fn transformed(mut node: Node, m: Affine) -> Node { + node.transform(m, Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), ..Default::default() }); + adopt::each_mut(&mut node, &mut |n| vectorcraft_doc::pattern::transform_pattern_paints(n, m)); + node +} + +// ---------- file.place ---------- + +/// `[x, y, width, height]` with a positive size. +fn rect_param(p: &Value) -> Result> { + let Some(v) = p.get("rect") else { return Ok(None) }; + let n: Vec = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + match n[..] { + [x, y, w, h] if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))), + _ => Err(bad(PLACE, "rect must be [x, y, width, height] with a positive width and height")), + } +} + +/// The transform that puts new art with the natural box `natural` where `old` is: an image's scale +/// and rotation relative to its 100% size (its resolution's) and its centre are kept; other objects +/// keep their centre. +fn replace_xf(d: &Document, old: &Node, natural: Rect) -> Affine { + if let NodeKind::Image(im) = &old.kind { + let (sx, sy) = pt_per_px(d.images.get(&im.key).and_then(|b| fileio::ppi::resolution(&b.bytes))); + let full = Affine::scale_non_uniform(sx, sy); + let centre = full * Point::new(im.width as f64 / 2.0, im.height as f64 / 2.0); + if im.xf.determinant().abs() > 1e-12 { + return im.xf * full.inverse() * Affine::translate(centre - natural.center()); + } + } + let c = old.geometric_bounds().map_or(natural.center(), |b| b.center()); + Affine::translate(c - natural.center()) +} + +/// `rect` fitted with `natural`'s aspect, centred. +fn fit(natural: Rect, r: Rect) -> Affine { + let ratio = |a: f64, b: f64| (b > 1e-9).then(|| a / b); + let k = [ratio(r.width(), natural.width()), ratio(r.height(), natural.height())].into_iter().flatten().reduce(f64::min).unwrap_or(1.0); + Affine::translate(r.center().to_vec2()) * Affine::scale(k) * Affine::translate(-natural.center().to_vec2()) +} + +fn place(s: &mut Session, p: &Value) -> Result { + let replace = bool_or(p, "replace", false); + let template = bool_or(p, "template", false); + let at = point_param(p, "at"); + let rect = rect_param(p)?; + if replace && (template || at.is_some() || rect.is_some()) { + return Err(bad(PLACE, "replace keeps the replaced object's place and transform: drop template, at and rect")); + } + let corners = [at, rect.map(|r| r.origin()), rect.map(|r| Point::new(r.x1, r.y1))]; + if corners.iter().flatten().any(|c| !(c.x.abs() <= MAX_COORD && c.y.abs() <= MAX_COORD)) { + return Err(bad(PLACE, "at/rect lie outside the canvas")); + } + let st = s.doc()?; + let old = match (replace, &st.selection.objects[..]) { + (false, _) => None, + (true, [id]) => Some(*id), + (true, _) => return Err(bad(PLACE, "replace: select the one object to replace")), + }; + let mut loaded = load(p, PLACE)?; + let natural = loaded.natural; + let m = match (old.and_then(|id| st.doc.node(id)), rect) { + (Some(o), _) => replace_xf(&st.doc, o, natural), + (None, Some(r)) => fit(natural, r), + (None, None) => { + let c = at.or_else(|| st.doc.artboards.first().map(|a| a.rect.center())).unwrap_or_default(); + Affine::translate(c - natural.center()) + } + }; + let parent = st.insertion_parent(); + let below = st.current_layer().or_else(|| parent.and_then(|p| st.doc.layer_of(p))); + let link = loaded.path.take().filter(|_| bool_or(p, "link", true) && loaded.format.raster); + let linked = link.is_some(); + let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings)); + let (id, size) = s.edit("Place", |d, sel| { + let node = transformed(build(d, loaded, link), m); + let size = node.geometric_bounds().unwrap_or_default(); + let (into, index) = match old { + Some(old) => { + let (par, idx, _) = d.position(old).ok_or(EngineError::NoNode(old))?; + d.remove(old)?; + (par, idx) + } + None if template => (Some(template_layer(d, below, &name)?), usize::MAX), + None => (parent, usize::MAX), + }; + let id = d.insert(into, index, node)?; + // Template art is locked: nothing to select. + if template { + sel.clear() + } else { + sel.set([id]) + } + Ok((id, size)) + })?; + Ok( + json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }), + ) +} + +/// A new locked template layer named after the file, right below the layer `below` (else at the +/// bottom). +fn template_layer(d: &mut Document, below: Option, name: &str) -> Result { + let id = d.add_layer(Some(&format!("Template {name}"))); + let at = below.and_then(|b| d.layers.iter().position(|l| l.id == b)).unwrap_or(0); + d.move_node(id, None, at)?; + if let Some(n) = d.node_mut(id) { + n.locked = true; + if let NodeKind::Layer { template, .. } = &mut n.kind { + *template = true; + } + } + Ok(id) +} + +// ---------- file.place.queue ---------- + +/// The files `file.place.queue` names, as `file.place` params. +fn queued_files(p: &Value) -> Result> { + let list = |k: &str| p.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut out = vec![]; + for v in list("paths") { + let path = v.as_str().ok_or_else(|| bad(QUEUE, "paths must be strings"))?; + out.push(json!({ "path": path })); + } + for f in list("files") { + let (Some(name), Some(data)) = (str_param(f, "name"), str_param(f, "dataBase64")) else { + return Err(bad(QUEUE, "files must be {name, dataBase64}")); + }; + out.push(json!({ "name": name, "dataBase64": data })); + } + match out.len() { + 0 => Err(bad(QUEUE, "give paths or files")), + n if n > MAX_QUEUE => Err(bad(QUEUE, format!("at most {MAX_QUEUE} files"))), + _ => Ok(out), + } +} + +fn queue(s: &mut Session, p: &Value) -> Result { + let thumbnail = p.get("thumbnail").and_then(Value::as_f64); + let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]); + for mut q in queued_files(p)? { + for k in ["link", "template", "page", "crop"] { + if let Some(v) = p.get(k) { + q[k] = v.clone(); + } + } + match load(&q, QUEUE) { + Ok(l) => { + let (w, h) = (l.natural.width(), l.natural.height()); + let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h }); + entries.push(json!({ "params": q, "name": l.name, "width": w, "height": h })); + if let Some(px) = thumbnail { + f["thumbnailBase64"] = json!(thumbnail_png(l, px)?); + } + files.push(f); + } + Err(e) => { + let name = str_param(&q, "path").or(str_param(&q, "name")).map(fileio::file_name).unwrap_or_default(); + skipped.push(json!({ "name": name, "error": e.to_string() })); + } + } + } + if entries.is_empty() { + let why = skipped.first().and_then(|e| e["error"].as_str()).unwrap_or("nothing to place"); + return Err(bad(QUEUE, why)); + } + // Reloading the cursor keeps the tool to return to. + let prev = Some(s.tool_id()).filter(|t| *t != "place"); + let view = s.last_view; + s.select_tool("place", view)?; + s.set_tool_option("queue", &json!({ "entries": entries, "prev": prev })); + Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })) +} + +// ---------- queries ---------- + +/// The colour mode an image file stores its pixels in. +fn color_mode(bytes: &[u8]) -> &'static str { + use image::{ExtendedColorType as E, ImageDecoder as _}; + let kind = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_decoder().ok()) + .map(|d| d.original_color_type()); + match kind { + Some(E::L1 | E::La1 | E::L2 | E::La2 | E::L4 | E::La4 | E::L8 | E::La8 | E::L16 | E::La16) => "Grayscale", + Some(E::Cmyk8 | E::Cmyk16) => "CMYK", + _ => "RGB", + } +} + +/// `l` rendered as a PNG at most `px` (≤ [`MAX_THUMBNAIL`]) pixels on its longer side, base64. +fn thumbnail_png(l: Loaded, px: f64) -> Result { + let natural = l.natural; + let mut d = Document::new(natural.width().max(1.0), natural.height().max(1.0)); + let layer = d.layers.first().map(|l| l.id); + let node = build(&mut d, l, None); + d.insert(layer, 0, node)?; + let k = px.clamp(1.0, MAX_THUMBNAIL) / natural.width().max(natural.height()).max(1e-9); + let png = vectorcraft_render::Renderer::new().render_region(&d, natural, k, false).to_png().map_err(EngineError::Other)?; + Ok(vectorcraft_format::base64_encode(&png)) +} + +fn info(_: &mut Session, p: &Value) -> Result { + let mut l = load(p, "file.place.info")?; + let natural = l.natural; + let mut out = json!({ + "name": l.name, + "format": l.format.id, + "width": natural.width(), + "height": natural.height(), + "warnings": std::mem::take(&mut l.warnings), + }); + if let Art::Image(img) = &l.art { + let (x, y) = img.ppi.unwrap_or((72.0, 72.0)); + out["pixelWidth"] = json!(img.width); + out["pixelHeight"] = json!(img.height); + out["ppi"] = json!([x, y]); + out["colorMode"] = json!(color_mode(&img.blob.bytes)); + } + if let Some(px) = p.get("thumbnail").and_then(Value::as_f64) { + out["thumbnailBase64"] = json!(thumbnail_png(l, px)?); + } + Ok(out) +} + +fn image_info(s: &mut Session, p: &Value) -> Result { + const C: &str = "image.info"; + let st = s.doc()?; + let id = match (id_param(p, "id"), &st.selection.objects[..]) { + (Some(id), _) => id, + (None, [id]) => *id, + _ => return Err(bad(C, "select one image or pass id")), + }; + let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) }; + // Effective resolution: 72 pt per inch over the points one pixel spans along each axis. + let [a, b, c, d, ..] = im.xf.as_coeffs(); + let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l); + let size = n.geometric_bounds().unwrap_or_default(); + Ok(json!({ + "id": id.0, + "name": n.display_name(), + "linked": im.link.is_some(), + "link": im.link, + "colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)), + "pixelWidth": im.width, + "pixelHeight": im.height, + "ppi": [ppi(a, b), ppi(c, d)], + "width": size.width(), + "height": size.height(), + })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25a934c3..5fec0b5d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -984,6 +984,8 @@ mod tests_pathops; #[cfg(test)] mod tests_pattern; #[cfg(test)] +mod tests_place; +#[cfg(test)] mod tests_prefs; #[cfg(test)] mod tests_previewbounds; diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests_place.rs new file mode 100644 index 00000000..dfa85e46 --- /dev/null +++ b/crates/engine/src/tests_place.rs @@ -0,0 +1,317 @@ +//! File → Place (`file.place`, `file.place.info`, `image.info`) and the place cursor +//! (`file.place.queue` and the `place` tool). + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; +use vectorcraft_geom::Rect; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +/// A `w`×`h` PNG of one colour (`rgba`), declaring `ppi` when given. +fn png(w: u32, h: u32, rgba: [u8; 4], ppi: Option) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba(rgba)).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + match ppi { + Some(r) => cmd::fileio::ppi::with_png_resolution(&out, (r, r)), + None => out, + } +} + +fn file(name: &str, bytes: &[u8]) -> Value { + json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }) +} + +/// `file.place` of `bytes` named `name`, with `extra` params. +fn place(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> Value { + let mut p = file(name, bytes); + if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) { + o.extend(e); + } + s.execute("file.place", &p).unwrap() +} + +fn node(s: &Session, id: &Value) -> Node { + s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone() +} + +fn top_children(s: &Session) -> Vec> { + s.doc().unwrap().doc.layers.last().unwrap().children().unwrap().clone() +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn serialize(s: &mut Session, format: &str) -> Vec { + let v = s.execute("document.serialize", &json!({"format": format})).unwrap(); + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +#[test] +fn a_300_px_png_at_300_ppi_places_as_72_pt_centred_selected_and_undoable() { + let mut s = session(); + let r = place(&mut s, "photo.png", &png(300, 150, [255, 0, 0, 255], Some(300.0)), json!({})); + assert_eq!((r["format"].as_str(), r["linked"].as_bool()), (Some("png"), Some(false)), "bytes have no path to link"); + assert!(close(r["width"].as_f64().unwrap(), 72.0) && close(r["height"].as_f64().unwrap(), 36.0), "{r}"); + let id = &r["ids"][0]; + let n = node(&s, id); + assert_eq!(n.name.as_deref(), Some("photo.png")); + let b = n.geometric_bounds().unwrap(); + assert!(close(b.center().x, 200.0) && close(b.center().y, 150.0), "centred on the artboard: {b:?}"); + let st = s.doc().unwrap(); + assert_eq!(st.selection.objects, [NodeId(id.as_u64().unwrap())]); + assert_eq!((st.history.undo.len(), st.history.undo[0].label.as_str()), (1, "Place")); + // No resolution: 72 ppi, one point per pixel; `at` centres it there. + let r = place(&mut s, "plain.png", &png(30, 20, [0, 0, 255, 255], None), json!({"at": [50, 60]})); + let b = node(&s, &r["ids"][0]).geometric_bounds().unwrap(); + assert!(close(b.width(), 30.0) && close(b.center().x, 50.0) && close(b.center().y, 60.0), "{b:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(top_children(&s).is_empty(), "each Place is one undo step"); +} + +#[test] +fn rect_fits_the_art_inside_keeping_its_aspect() { + let mut s = session(); + let r = place(&mut s, "wide.png", &png(40, 20, [0, 0, 0, 255], None), json!({"rect": [10, 10, 100, 100]})); + let b = node(&s, &r["ids"][0]).geometric_bounds().unwrap(); + assert!(close(b.width(), 100.0) && close(b.height(), 50.0) && close(b.center().y, 60.0), "{b:?}"); +} + +#[test] +fn svg_with_a_data_png_renders_and_leaves_the_clipboard_alone() { + let mut s = session(); + let rect = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let clipboard = s.clipboard.clone(); + let red = vectorcraft_format::base64_encode(&png(4, 2, [255, 0, 0, 255], None)); + let svg = format!( + r##""## + ); + let r = place(&mut s, "art.svg", svg.as_bytes(), json!({"at": [100, 100]})); + assert_eq!(r["format"], "svg"); + let g = node(&s, &r["ids"][0]); + let NodeKind::Group { children, clip: false } = &g.kind else { panic!("one group: {:?}", g.kind) }; + assert_eq!(children.len(), 2); + assert_eq!(g.name.as_deref(), Some("art.svg")); + assert_eq!(s.clipboard, clipboard, "Place never touches the clipboard"); + let doc = &s.doc().unwrap().doc; + assert!(doc.images.len() == 1, "the embedded image joined the document"); + // The group spans 80..120 × 90..110: blue square top left, red image elsewhere. + let img = vectorcraft_testkit::raster::render_region(doc, Rect::new(80.0, 90.0, 120.0, 110.0), 1.0); + assert_eq!(img.over_white(30, 15), [255, 0, 0]); + assert_eq!(img.over_white(4, 4), [0, 0, 255]); +} + +#[test] +fn replace_keeps_the_transform_and_the_stacking_place() { + let mut s = session(); + let first = place(&mut s, "a.png", &png(100, 50, [255, 0, 0, 255], None), json!({"at": [150, 150]}))["ids"][0].clone(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("object.scale", &json!({"sx": 50})).unwrap(); + let centre = node(&s, &first).geometric_bounds().unwrap().center(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("select.set", &json!({"ids": [first]})).unwrap(); + assert!(s.execute("file.place", &json!({"replace": true, "at": [0, 0], "name": "b.png", "dataBase64": ""})).is_err()); + let r = place(&mut s, "b.png", &png(20, 20, [0, 255, 0, 255], Some(144.0)), json!({"replace": true})); + let id = &r["ids"][0]; + assert_eq!(top_children(&s)[0].id.0, id.as_u64().unwrap(), "same place in the stacking order"); + assert_eq!(top_children(&s).len(), 2); + let c = node(&s, id).geometric_bounds().unwrap().center(); + assert!(close(c.x, centre.x) && close(c.y, centre.y), "{c:?} vs {centre:?}"); + // 144 ppi at the old image's 50%: 288 ppi, rotated as it was. + let info = s.execute("image.info", &json!({})).unwrap(); + assert!(close(info["ppi"][0].as_f64().unwrap(), 288.0) && close(info["ppi"][1].as_f64().unwrap(), 288.0), "{info}"); + let NodeKind::Image(im) = &node(&s, id).kind else { panic!() }; + let [a, b, ..] = im.xf.as_coeffs(); + assert!(close(b.atan2(a).to_degrees(), -30.0), "{:?}", im.xf); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(top_children(&s)[0].id.0, first.as_u64().unwrap(), "one undo step brings the old image back"); +} + +#[test] +fn replace_needs_exactly_one_selected_object() { + let mut s = session(); + let r = s + .execute("file.place", &json!({"replace": true, "name": "b.png", "dataBase64": vectorcraft_format::base64_encode(&png(2, 2, [0; 4], None))})); + assert!(r.unwrap_err().to_string().contains("select the one object")); +} + +#[test] +fn template_puts_the_art_on_a_locked_template_layer_below_the_current_one() { + let mut s = session(); + s.execute("layer.new", &json!({})).unwrap(); + let current = s.doc().unwrap().current_layer().unwrap(); + let r = place(&mut s, "sketch.png", &png(10, 10, [0, 0, 0, 255], None), json!({"template": true})); + let st = s.doc().unwrap(); + let layers = &st.doc.layers; + assert_eq!(layers.len(), 3); + let t = layers.iter().position(|l| l.name.as_deref() == Some("Template sketch.png")).unwrap(); + assert_eq!(layers[t + 1].id, current, "right below the current layer"); + assert!(layers[t].locked && matches!(layers[t].kind, NodeKind::Layer { template: true, .. })); + assert_eq!(st.doc.layer_of(NodeId(r["ids"][0].as_u64().unwrap())), Some(layers[t].id)); + assert!(st.selection.is_empty(), "locked template art isn't selected"); + assert_eq!(st.history.undo.len(), 2, "layer.new, then Place as one step"); +} + +#[test] +fn pdf_pages_and_native_artboards_place_as_clipped_groups() { + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 100, "height": 50, "artboards": 2})).unwrap(); + src.execute("artboard.setProps", &json!({"index": 1, "width": 40})).unwrap(); + let b1 = src.doc().unwrap().doc.artboards[1].rect; + src.execute("shape.rectangle", &json!({"x": b1.x0 + 5.0, "y": 5, "width": 20, "height": 10})).unwrap(); + let pdf = serialize(&mut src, "pdf"); + let native = serialize(&mut src, "vectorcraft"); + let mut s = session(); + for (name, bytes) in [("two.pdf", &pdf), ("two.vectorcraft", &native)] { + let r = place(&mut s, name, bytes, json!({"page": 2})); + assert!(close(r["width"].as_f64().unwrap(), 40.0) && close(r["height"].as_f64().unwrap(), 50.0), "{name}: {r}"); + let g = node(&s, &r["ids"][0]); + assert!(matches!(g.kind, NodeKind::Group { clip: true, .. }), "{name}: clipped to the page"); + let r = place(&mut s, name, bytes, json!({"page": 2, "crop": "bounding"})); + assert!((r["width"].as_f64().unwrap() - 20.0).abs() < 1.5, "{name}: the art's bounds: {r}"); + let err = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes), "page": 3})).unwrap_err(); + assert!(err.to_string().contains("has 2"), "{name}: {err}"); + } +} + +#[test] +fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() { + // The file: a global "Brand" red rectangle and a "Star" symbol instance. + let mut src = session(); + src.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + // The document: its own, different "Brand" and "Star". + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#00ff00", "global": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + let r = place(&mut s, "logo.vectorcraft", &bytes, json!({"crop": "bounding"})); + let g = node(&s, &r["ids"][0]); + let (mut fill, mut symbol) = (None, None); + g.walk(&mut |n| match &n.kind { + NodeKind::SymbolInstance { symbol: name, .. } => symbol = Some(name.clone()), + NodeKind::Path { .. } => { + fill = n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None }) + } + _ => {} + }); + assert_eq!(symbol.as_deref(), Some("Star 2")); + let doc = &s.doc().unwrap().doc; + assert!(doc.symbols.iter().any(|x| x.name == "Star 2") && doc.symbols.iter().any(|x| x.name == "Star")); + let Some(Paint::Solid { color, swatch: None, .. }) = fill else { panic!("unlinked: {fill:?}") }; + assert_eq!(color.to_hex(), "#ff0000", "keeps its look"); + assert_eq!(doc.swatch("Brand").and_then(|w| w.paint.color()).map(|c| c.to_hex()).as_deref(), Some("#00ff00")); + // A document without them gets them as they are. + let mut fresh = session(); + let r = place(&mut fresh, "logo.vectorcraft", &bytes, json!({})); + let doc = &fresh.doc().unwrap().doc; + assert!(doc.symbols.iter().any(|x| x.name == "Star") && doc.swatch("Brand").is_some()); + let mut linked = false; + node(&fresh, &r["ids"][0]).walk(&mut |n| { + linked |= n + .appearance + .items + .iter() + .any(|i| matches!(i, AppearanceItem::Fill(f) if matches!(&f.paint, Paint::Solid { swatch: Some(w), .. } if w == "Brand"))); + }); + assert!(linked, "still linked to the swatch it brought"); +} + +#[test] +fn info_reports_size_resolution_colour_mode_and_link() { + let mut s = session(); + let mut gray = vec![]; + image::GrayImage::from_pixel(300, 150, image::Luma([128])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap(); + let gray = cmd::fileio::ppi::with_png_resolution(&gray, (300.0, 300.0)); + let mut p = file("scan.png", &gray); + p["thumbnail"] = json!(16); + let info = s.execute("file.place.info", &p).unwrap(); + assert_eq!((info["colorMode"].as_str(), info["pixelWidth"].as_u64()), (Some("Grayscale"), Some(300))); + assert_eq!(info["ppi"], json!([300.0, 300.0])); + assert!(close(info["width"].as_f64().unwrap(), 72.0)); + let thumb = image::load_from_memory(&vectorcraft_format::base64_decode(info["thumbnailBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!((thumb.width(), thumb.height()), (16, 8)); + assert!(top_children(&s).is_empty(), "info places nothing"); + // A path is linked by default. + let path = std::env::temp_dir().join(format!("vectorcraft-place-{}.png", std::process::id())); + std::fs::write(&path, &gray).unwrap(); + let path = path.to_string_lossy().to_string(); + let r = s.execute("file.place", &json!({"path": path})).unwrap(); + assert_eq!(r["linked"], true); + let im = s.execute("image.info", &json!({})).unwrap(); + assert_eq!((im["linked"].as_bool(), im["link"].as_str(), im["colorMode"].as_str()), (Some(true), Some(path.as_str()), Some("Grayscale"))); + assert!(close(im["ppi"][0].as_f64().unwrap(), 300.0), "{im}"); + let r = s.execute("file.place", &json!({"path": path, "link": false})).unwrap(); + assert_eq!(r["linked"], false); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_queue_of_3_with_click_drag_and_esc_places_2() { + let mut s = session(); + let view = ViewInfo::default(); + let files: Vec = ["a.png", "b.png", "c.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect(); + let r = s.execute("file.place.queue", &json!({"files": files, "thumbnail": 8})).unwrap(); + assert_eq!(r["count"], 3); + assert!(r["files"][0]["thumbnailBase64"].is_string()); + assert_eq!(s.tool_id(), "place"); + assert_eq!(s.tool_options()["name"], "a.png"); + let ev = |kind, x, y| PointerEvent::new(kind, x, y); + // Click: 100% with its top-left corner at the click. + s.pointer(&ev(PointerKind::Down, 10.0, 10.0), view).unwrap(); + s.pointer(&ev(PointerKind::Up, 10.0, 10.0), view).unwrap(); + let a = s.doc().unwrap().selection.objects[0]; + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds(), Some(Rect::new(10.0, 10.0, 50.0, 30.0))); + // Drag: the dragged size, aspect kept. + s.pointer(&ev(PointerKind::Down, 100.0, 100.0), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, 180.0, 110.0), view).unwrap(); + s.pointer(&ev(PointerKind::Up, 180.0, 110.0), view).unwrap(); + let b = s.doc().unwrap().selection.objects[0]; + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().name.as_deref(), Some("b.png")); + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds(), Some(Rect::new(100.0, 100.0, 180.0, 140.0))); + // Esc discards the last one and asks for the previous tool back. + assert!(s.tool_claims_key(ToolKey::Escape, view)); + let reqs = s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap(); + assert_eq!(reqs, vec![UiRequest::SwitchTool("selection".into())]); + assert_eq!(top_children(&s).len(), 2); + assert_eq!(s.doc().unwrap().history.undo.len(), 2, "one undo step per placement"); +} + +#[test] +fn bad_place_params_are_errors() { + let mut s = session(); + let ok = vectorcraft_format::base64_encode(&png(2, 2, [0; 4], None)); + for p in [ + json!({}), + json!({"name": "x.png", "dataBase64": "%%%"}), + json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(b"hello")}), + json!({"name": "x.png", "dataBase64": ok, "rect": [0, 0, 0, 10]}), + json!({"name": "x.png", "dataBase64": ok, "at": [1e12, 0]}), + json!({"name": "x.png", "dataBase64": ok, "page": 0}), + json!({"name": "x.png", "dataBase64": ok, "crop": "trim"}), + json!({"name": "x.png", "dataBase64": ok, "template": true, "replace": true}), + ] { + assert!(s.execute("file.place", &p).is_err(), "{p}"); + } + assert!(s.execute("file.place.queue", &json!({})).is_err()); + assert!(s.execute("file.place.queue", &json!({"files": [{"name": "notes.txt", "dataBase64": ""}]})).is_err()); + assert_eq!(s.tool_id(), "selection", "a failed queue leaves the tool alone"); + assert!(top_children(&s).is_empty()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 738c3013..dd1287a2 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,5 +1,5 @@ //! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG and PDF input, -//! and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset +//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset //! (`.vcflattener`) libraries, must load as an error or as a document that then renders and //! exports, without a panic. //! @@ -403,3 +403,48 @@ proptest! { flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?; } } + +/// A small image of each raster format Place reads, with resolution metadata where it has some. +fn raster_samples() -> Vec<(&'static str, Vec)> { + let img = image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10])); + let mut out = vec![]; + for (name, f) in [ + ("a.png", image::ImageFormat::Png), + ("a.jpg", image::ImageFormat::Jpeg), + ("a.tif", image::ImageFormat::Tiff), + ("a.bmp", image::ImageFormat::Bmp), + ] { + let mut b = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut b), f).unwrap(); + out.push((name, b)); + } + out[0].1 = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out[0].1, (300.0, 150.0)); + out +} + +proptest! { + #![proptest_config(config())] + + /// Mutated image headers (resolution metadata, chunk and segment lengths) never crash reading + /// their resolution or placing them. + #[test] + fn mutated_images_place_without_panics(which in 0usize..4, cut in 0usize..400, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { + let (name, mut bytes) = raster_samples().swap_remove(which); + for (at, b) in edits { + if let Some(x) = bytes.get_mut(at) { + *x = b; + } + } + bytes.truncate(cut.max(8)); + let r = catch_quiet(|| { + let _ = vectorcraft_engine::cmd::fileio::ppi::resolution(&bytes); + let _ = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&bytes, (72.0, 72.0)); + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8}); + let _ = s.execute("file.place.info", &p); + let _ = s.execute("file.place", &p); + }); + prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index f0b737ee..c12adbe3 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -36,4 +36,6 @@ mod tests_freeform; #[cfg(test)] mod tests_gradient; #[cfg(test)] +mod tests_place; +#[cfg(test)] mod tests_svg; diff --git a/crates/mcp/src/tests_place.rs b/crates/mcp/src/tests_place.rs new file mode 100644 index 00000000..367ceaab --- /dev/null +++ b/crates/mcp/src/tests_place.rs @@ -0,0 +1,58 @@ +//! File → Place over MCP, headless: `file.place` through `run_command`, and the place cursor driven +//! by pointer gestures and keys. + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, call_tool}; + +fn text(r: &crate::ToolResult) -> Value { + assert!(!r.is_error, "{r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap() +} + +/// A headless session with a 400×300 document, and a 30×20 PNG as base64. +fn setup() -> (Headless, String) { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 30, "height": 20}})).unwrap(); + let png = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "png"}})).unwrap(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 400, "height": 300}})).unwrap(); + (h, png["dataBase64"].as_str().unwrap().to_string()) +} + +fn images(h: &Headless) -> usize { + let mut n = 0; + h.session.doc().unwrap().doc.walk(|x| n += usize::from(matches!(x.kind, vectorcraft_doc::NodeKind::Image(_)))); + n +} + +#[test] +fn headless_run_command_file_place() { + let (mut h, png) = setup(); + let r = text(&call_tool( + &mut h, + "run_command", + &json!({"command": "file.place", "params": {"name": "tile.png", "dataBase64": png, "at": [100, 50]}}), + )); + assert_eq!((r["format"].as_str(), r["width"].as_f64(), r["height"].as_f64()), (Some("png"), Some(30.0), Some(20.0)), "{r}"); + assert_eq!(images(&h), 1); + let sel = h.session.doc().unwrap().selection.objects.clone(); + assert_eq!(sel.len(), 1); + assert_eq!(sel[0].0, r["ids"][0].as_u64().unwrap()); +} + +#[test] +fn headless_place_cursor_places_on_clicks_and_esc_ends_it() { + let (mut h, png) = setup(); + let files = json!([{"name": "a.png", "dataBase64": png}, {"name": "b.png", "dataBase64": png}, {"name": "c.png", "dataBase64": png}]); + let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.place.queue", "params": {"files": files}}))); + assert_eq!(r["count"], 3); + assert_eq!(h.session.tool_id(), "place"); + let click = |x: f64| json!({"events": [{"kind": "down", "x": x, "y": 10}, {"kind": "up", "x": x, "y": 10}]}); + text(&call_tool(&mut h, "pointer_gesture", &click(10.0))); + text(&call_tool(&mut h, "pointer_gesture", &click(60.0))); + assert_eq!(images(&h), 2); + let r = text(&call_tool(&mut h, "press_key", &json!({"key": "Escape"}))); + assert_eq!(r["handledBy"], "tool"); + assert_eq!(h.session.tool_id(), "selection", "the last file discarded: back to the previous tool"); + assert_eq!(images(&h), 2); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c3d7ace9..19de5eea 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -19,6 +19,7 @@ pub mod guides; pub mod meshblend; pub mod params; pub mod pen; +pub mod place; pub mod select; pub mod shape; pub mod symbolism; @@ -334,6 +335,8 @@ pub fn create(id: &str) -> Box { "pen" => Box::new(pen::PenTool::default()), "type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), + // Not in the toolbar: `file.place.queue` loads it. + "place" => Box::new(place::PlaceTool::default()), other => symbolism::create(other) .or_else(|| builder::create(other)) .or_else(|| draw2::create(other)) diff --git a/crates/tools/src/place.rs b/crates/tools/src/place.rs new file mode 100644 index 00000000..a885b0ca --- /dev/null +++ b/crates/tools/src/place.rs @@ -0,0 +1,245 @@ +//! The place cursor (`file.place.queue` loads it with files): a click places the current file at +//! 100% with its top-left corner at the click, a drag places it at the dragged size (its aspect +//! kept), ←/→ and ↑/↓ cycle the files and Esc discards the current one. Each placement is one +//! `file.place`; after the last file the previous tool comes back. + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Rect}; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// One loaded file. +#[derive(Clone, Debug, PartialEq)] +struct Entry { + /// `file.place` params (the file and its options). + params: Value, + name: String, + /// Its size at 100% (pt). + width: f64, + height: f64, +} + +#[derive(Default)] +pub struct PlaceTool { + entries: Vec, + current: usize, + /// The tool to return to when the last file is placed or discarded. + prev: Option, + /// Pressed at, and the current drag point. + drag: Option<(Point, Point)>, +} + +impl PlaceTool { + fn entry(&self) -> Option<&Entry> { + self.entries.get(self.current) + } + + /// The box the current file goes into for a press at `a` released at `b`: its 100% size with + /// its top-left corner at `a` for a click (under `tol`), else grown from `a` towards `b` with + /// its aspect kept. + fn target(&self, a: Point, b: Point, tol: f64) -> Option { + let e = self.entry()?; + let (dx, dy) = (b.x - a.x, b.y - a.y); + if dx.abs().max(dy.abs()) < tol || e.width <= 0.0 || e.height <= 0.0 { + return Some(Rect::new(a.x, a.y, a.x + e.width, a.y + e.height)); + } + let k = (dx.abs() / e.width).max(dy.abs() / e.height); + let (w, h) = (e.width * k, e.height * k); + let x = if dx < 0.0 { a.x - w } else { a.x }; + let y = if dy < 0.0 { a.y - h } else { a.y }; + Some(Rect::new(x, y, x + w, y + h)) + } + + /// Drop the current file; after the last one, back to the previous tool. + fn discard(&mut self) -> Vec { + if self.current < self.entries.len() { + self.entries.remove(self.current); + } + if self.entries.is_empty() { + self.current = 0; + return vec![Action::SwitchTool(self.prev.clone().unwrap_or_else(|| "selection".into()))]; + } + self.current %= self.entries.len(); + vec![] + } + + fn cycle(&mut self, forward: bool) { + let n = self.entries.len(); + if n > 0 { + self.current = if forward { (self.current + 1) % n } else { (self.current + n - 1) % n }; + } + } +} + +impl Tool for PlaceTool { + fn id(&self) -> &'static str { + "place" + } + + fn busy(&self) -> bool { + self.drag.is_some() + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down if self.entry().is_some() => self.drag = Some((ev.pos, ev.pos)), + PointerKind::Drag => { + if let Some((a, _)) = self.drag { + self.drag = Some((a, ev.pos)); + } + } + PointerKind::Up => { + let Some((a, _)) = self.drag.take() else { return vec![] }; + let (Some(r), Some(e)) = (self.target(a, ev.pos, cx.tol(3.0)), self.entry()) else { return vec![] }; + let mut p = e.params.clone(); + p["rect"] = json!([r.x0, r.y0, r.width(), r.height()]); + let mut acts = vec![Action::Exec("file.place".into(), p)]; + acts.extend(self.discard()); + return acts; + } + _ => {} + } + vec![] + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + match key { + ToolKey::Escape if self.drag.take().is_some() => vec![], + ToolKey::Escape => self.discard(), + ToolKey::Left | ToolKey::Up => { + self.cycle(false); + vec![] + } + ToolKey::Right | ToolKey::Down => { + self.cycle(true); + vec![] + } + _ => vec![], + } + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + !self.entries.is_empty() && matches!(key, ToolKey::Escape | ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down) + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some((a, b)) = self.drag else { return vec![] }; + self.target(a, b, cx.tol(3.0)).map(Overlay::Marquee).into_iter().collect() + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Crosshair + } + + /// `{count, current, name, width, height, names}`: what the cursor holds (no file data). + fn options(&self) -> Value { + let e = self.entry(); + json!({ + "count": self.entries.len(), + "current": self.current, + "name": e.map(|e| &e.name), + "width": e.map(|e| e.width), + "height": e.map(|e| e.height), + "names": self.entries.iter().map(|e| &e.name).collect::>(), + }) + } + + /// `queue`: `{entries: [{params, name, width, height}], prev?}` loads the cursor (replacing what + /// it held); `current`: index of the file to place next. + fn set_option(&mut self, key: &str, value: &Value) { + match key { + "queue" => { + let entries: Vec = value["entries"] + .as_array() + .into_iter() + .flatten() + .filter_map(|e| { + Some(Entry { + params: e.get("params").filter(|p| p.is_object())?.clone(), + name: e["name"].as_str().unwrap_or_default().to_string(), + width: e["width"].as_f64().filter(|v| v.is_finite())?, + height: e["height"].as_f64().filter(|v| v.is_finite())?, + }) + }) + .collect(); + self.entries = entries; + self.current = 0; + self.drag = None; + if let Some(prev) = value["prev"].as_str() { + self.prev = Some(prev.to_string()); + } + } + "current" => { + if let Some(i) = value.as_u64().map(|i| i as usize).filter(|i| *i < self.entries.len()) { + self.current = i; + } + } + _ => {} + } + } + + /// Switching tools discards what the cursor holds. + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.entries.clear(); + self.drag = None; + vec![] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::{cx, doc_with_rect, paint}; + + fn loaded(n: usize) -> PlaceTool { + let mut t = PlaceTool::default(); + let entries: Vec = + (0..n).map(|i| json!({"params": {"path": format!("f{i}.png")}, "name": format!("f{i}.png"), "width": 40.0, "height": 20.0})).collect(); + t.set_option("queue", &json!({"entries": entries, "prev": "pen"})); + t + } + + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) + } + + #[test] + fn click_places_at_100_percent_and_drag_keeps_the_aspect() { + let (d, _) = doc_with_rect(); + let (s, p) = (Default::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = loaded(3); + t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 11.0, 10.0)); + assert_eq!(acts, vec![Action::Exec("file.place".into(), json!({"path": "f0.png", "rect": [10.0, 10.0, 40.0, 20.0]}))]); + // A drag up and to the left grows the box from the press point, aspect kept. + t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); + t.pointer(&c, &ev(PointerKind::Drag, 20.0, 90.0)); + assert_eq!(t.overlays(&c), vec![Overlay::Marquee(Rect::new(20.0, 60.0, 100.0, 100.0))]); + let acts = t.pointer(&c, &ev(PointerKind::Up, 20.0, 90.0)); + assert_eq!(acts[0], Action::Exec("file.place".into(), json!({"path": "f1.png", "rect": [20.0, 60.0, 80.0, 40.0]}))); + assert_eq!(t.options()["count"], 1); + // Placing the last file returns to the previous tool. + t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 0.0, 0.0)); + assert_eq!(acts.last(), Some(&Action::SwitchTool("pen".into()))); + assert!(!t.claims_key(&c, ToolKey::Escape), "an empty cursor takes no keys"); + } + + #[test] + fn arrows_cycle_and_escape_discards() { + let (d, _) = doc_with_rect(); + let (s, p) = (Default::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = loaded(3); + assert!(t.claims_key(&c, ToolKey::Left) && t.claims_key(&c, ToolKey::Escape)); + t.key(&c, ToolKey::Left, Mods::default()); + assert_eq!(t.options()["name"], "f2.png"); + t.key(&c, ToolKey::Down, Mods::default()); + assert_eq!(t.options()["name"], "f0.png"); + assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty()); + assert_eq!(t.options()["names"], json!(["f1.png", "f2.png"])); + t.key(&c, ToolKey::Escape, Mods::default()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("pen".into())]); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 621c0b5f..435ebced 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -303,6 +303,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let overlays = app.session.overlays(view_info); draw_overlays(&painter, &xf, &overlays, &t); } + crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect); if app.ui.view.rulers && app.ui.screen_mode < 3 { rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t); @@ -324,6 +325,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else if let Some(p) = app.hover_doc { let c = app.session.cursor(p, mods(m, space), view_info); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor"))); + // The loaded place cursor carries the file's thumbnail. + if app.session.tool_id() == "place" + && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) + { + crate::place::paint_cursor(app, ui.ctx(), &painter, hp); + } // Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors. let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none(); match ui.input(|i| i.pointer.hover_pos()) { @@ -530,7 +537,13 @@ fn drag_art_out(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { - match app.session.pointer(ev, view) { + let r = app.session.pointer(ev, view); + apply_requests(app, r); +} + +/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. +pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result>) { + match r { Ok(reqs) => { for r in reqs { match r { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e2f8164d..2fce4876 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -122,11 +122,18 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let anchor_mode = !st.selection.anchors.is_empty(); let label = match &first { Some(_) if sel.len() == 1 && anchor_mode => "Anchor Point", - Some(n) if sel.len() == 1 && matches!(n.kind, NodeKind::Image(_)) => "Embedded", + Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => { + if im.link.is_some() { + "Linked File" + } else { + "Embedded" + } + } _ => crate::panels::appearance::object_label(app), }; ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.add_space(6.0); + crate::place::control_bar_details(app, ui); let shown_stroke = crate::panels::current_stroke(app); let mixed = crate::panels::stroke_mixed(app, ui.ctx()); let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 00a97b5e..c3953801 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -28,6 +28,7 @@ pub mod new_color_group; mod new_document; pub mod new_swatch; mod path_ops; +pub mod place; pub mod recolor; pub mod saturate; mod save_changes; @@ -178,6 +179,7 @@ registry! { SavePdf: [save_pdf::KIND] => save_pdf::SPEC, SvgOptions: [svg_options::KIND] => svg_options::SPEC, NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC, + Place: [place::KIND] => place::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/place.rs b/crates/ui-egui/src/dialogs/place.rs new file mode 100644 index 00000000..20878e72 --- /dev/null +++ b/crates/ui-egui/src/dialogs/place.rs @@ -0,0 +1,83 @@ +//! File → Place: the files picked, one line each about what they are, and the Place options row +//! (Link, Template, Replace). OK places one file centred in the view (or in place of the selected +//! object) or loads the place cursor with several ([`crate::place::confirm`]). +//! +//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file +//! and one selected object: Replace applies), `__info` (a line per file). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Place. +pub const KIND: &str = "place"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Place".into(), + body, + confirm: crate::place::confirm, + ok: Some("Place"), + min_width: 340.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// The options row: (field, label, tooltip). +const OPTIONS: [(&str, &str, &str); 3] = [ + ("link", "Link", "Keep a link to an image file instead of only embedding it"), + ("template", "Template", "Place onto a new locked, dimmed template layer below the current layer"), + ("replace", "Replace", "Swap the selected object for the file, keeping its place and transform"), +]; + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let files: Vec = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default(); + let info = d.fields.get("__info").cloned().unwrap_or_default(); + widgets::subheader(ui, if files.len() == 1 { "File" } else { "Files" }); + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().max_height(180.0).auto_shrink([false, true]).show(ui, |ui| { + ui.set_width(ui.available_width()); + for (i, f) in files.iter().enumerate() { + let path = f.get("path").and_then(Value::as_str); + let name = path + .map(vectorcraft_engine::cmd::fileio::file_name) + .or_else(|| f.get("name").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_default(); + ui.horizontal(|ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + let label = ui.label(egui::RichText::new(name).font(theme::semibold(12.0)).color(t.text_strong)); + if let Some(p) = path { + label.on_hover_text(p); + } + ui.label(egui::RichText::new(info[i].as_str().unwrap_or_default()).size(11.5).color(t.text_dim)); + ui.add_space(3.0); + }); + }); + } + }); + }); + if files.len() > 1 { + ui.add_space(4.0); + widgets::dim_label(ui, "Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one."); + } + ui.add_space(10.0); + // The options row. + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 18.0; + for (key, label, tip) in OPTIONS { + let on = d.bool(key); + let enabled = key != "replace" || d.bool("__replace"); + let resp = ui.scope(|ui| widgets::check(ui, label, on && enabled, enabled)); + if resp.inner { + d.fields.insert(key.into(), json!(!on)); + } + resp.response.on_hover_text(tip); + } + }); + false +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d1d07512..3a259024 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -2,9 +2,7 @@ //! decodes and encodes every format. use serde_json::{Value, json}; -use vectorcraft_doc::{ImageObject, Node, NodeKind}; use vectorcraft_engine::cmd::fileio; -use vectorcraft_geom::Affine; use crate::VectorcraftApp; use crate::dialogs::svg_options; @@ -56,8 +54,7 @@ pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { 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); + dl(&fileio::file_name(path), bytes); return Ok(()); } let w = app.services.write.as_mut().ok_or("no file writer")?; @@ -237,39 +234,9 @@ fn file_url(path: &str) -> String { url } -/// File → Place… (embed an image or SVG into the active document). +/// Place a file's bytes (no path, so embedded) centred in the view: `file.place`. 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 = 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", &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()) + crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) } #[cfg(test)] @@ -282,6 +249,7 @@ mod tests { use super::*; use crate::Services; + use vectorcraft_doc::NodeKind; type Written = Rc)>>>; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ee9853dc..8f6c1d39 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ pub mod io; pub mod menus; pub mod palette; pub mod panels; +pub mod place; pub mod prefs_dialog; pub mod render_worker; pub mod shortcut_editor; @@ -41,6 +42,8 @@ mod tests_overprint; #[cfg(test)] mod tests_paintchips; #[cfg(test)] +mod tests_place; +#[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_svg; @@ -85,6 +88,13 @@ pub struct Services { pub clipboard_read: Option Option>>, /// Open a URL in the system browser (desktop). Without it, egui opens it (a new tab on the web). pub open_url: Option, + /// Show an open dialog for several files (File → Place…); returns their paths (none: cancelled). + pub pick_open_multi: Option Vec>>, + /// Web: start an async pick of files to place (they arrive via `place_inbox`). + pub place_async: Option>, + /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the + /// canvas). + pub place_inbox: Option, } /// Cached canvas raster. @@ -160,6 +170,8 @@ pub struct VectorcraftApp { /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, + /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. + pub place: place::PlaceState, } impl VectorcraftApp { @@ -199,6 +211,7 @@ impl VectorcraftApp { canvas_rect: None, hover_doc: None, custom_titlebar: false, + place: Default::default(), } } @@ -412,6 +425,7 @@ impl VectorcraftApp { self.status(format!("Couldn't open {name}: {e}")); } } + place::drain(self); } } @@ -480,21 +494,31 @@ impl VectorcraftApp { if self.fonts_ready { shortcuts::handle(self, ctx); } - // Native only: the web host reads dropped files asynchronously and feeds `Services::inbox`. + // Native only: the web host reads dropped files asynchronously and feeds the inboxes. #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { - let path = f.path().to_path_buf(); - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes() { - Ok(b) => { - let p = Some(path.to_string_lossy().to_string()).filter(|s| !s.is_empty()); - if let Err(e) = io::open_bytes(self, &name, &b, p) { - self.status(format!("Couldn't open {name}: {e}")); - } - } + self.take_dropped_files(ctx); + } + + /// Files dropped on the window: placed where they were dropped on the canvas, else opened. + #[cfg(not(target_arch = "wasm32"))] + fn take_dropped_files(&mut self, ctx: &egui::Context) { + let (dropped, pos, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos(), i.modifiers.shift)); + if dropped.is_empty() { + return; + } + let target = self.drop_target(pos, shift); + let mut files = vec![]; + for f in dropped { + let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty()); + let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name); + // A file placed by its path is read by the engine. + let bytes = if path.is_some() && target != place::DropTarget::Open { Ok(vec![]) } else { f.bytes() }; + match bytes { + Ok(b) => files.push((name, path, b)), Err(e) => self.status(format!("Couldn't read {name}: {e}")), } } + place::drop_files(self, files, target); } /// Inject synthetic events (one press/release step per frame). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 78e0a420..95c4826e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -58,7 +58,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?, svg?: {…SVG options}} like Save As, but the document keeps its 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.place", + "Place…", + "Cmd+Shift+P", + "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view", + ), ("file.openRecent1", "Open Recent File 1", "", "{}"), ("file.openRecent2", "Open Recent File 2", "", "{}"), ("file.openRecent3", "Open Recent File 3", "", "{}"), @@ -434,20 +439,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(json!(app.ui.recent_files)), - "file.place" => { - let path = match s("path") { - Some(p) => Some(p), - None => app.services.pick_open.as_mut().and_then(|f| f()), - }; - match path { - Some(path) => { - let bytes = app.services.read.as_ref().ok_or("no reader".to_string()).and_then(|r| r(&path)); - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - bytes.and_then(|b| io::place_bytes(app, &name, &b)).map(|_| Value::Null) - } - None => Err("cancelled".into()), - } - } + "file.place" => crate::place::run(app, p), + "file.place.queue" => crate::place::queue(app, p), "file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => { crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None); Ok(Value::Null) diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs new file mode 100644 index 00000000..24910d8f --- /dev/null +++ b/crates/ui-egui/src/place.rs @@ -0,0 +1,350 @@ +//! File → Place in the app: picking the files, the Place dialog's options, files dropped on the +//! window, the loaded place cursor and the Control bar's image details. The engine places +//! (`file.place`, `file.place.queue`); this gathers the files and options. + +use std::sync::{Arc, Mutex}; + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_geom::Point; + +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, io}; + +/// The loaded place cursor's thumbnail size (px on the longer side). +const THUMB: u32 = 64; +/// Where the thumbnail sits from the pointer (points). +const THUMB_OFFSET: egui::Vec2 = egui::vec2(14.0, 14.0); + +/// A file to place that arrived asynchronously (web): picked for File → Place (`drop: None`), or +/// dropped on the canvas at a point, embedded or not. +pub struct PlaceArrival { + pub name: String, + pub bytes: Vec, + pub drop: Option<(Point, bool)>, +} + +pub type PlaceInbox = Arc>>; + +/// Where files dropped on the window go. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DropTarget { + /// The active document's canvas at `at` (document coordinates); `embed` when Shift is held. + Place { at: Point, embed: bool }, + /// Opened as documents: no document is open, or the drop missed the canvas (the tab bar). + Open, +} + +/// The app's Place state (not saved with the preferences). +#[derive(Default)] +pub struct PlaceState { + /// Web: the bytes of the files picked for the Place dialog, by name. + picked: Vec<(String, Vec)>, + /// The loaded cursor's thumbnails by file name: decoded when the cursor is loaded, uploaded + /// when first drawn. + thumbs: Vec<(String, Thumb)>, + /// The Control bar's `image.info`, for (document uid, revision, image id). + info: Option<((u64, u64, u64), Value)>, +} + +enum Thumb { + Decoded(egui::ColorImage), + Uploaded(egui::TextureHandle), +} + +/// Run engine command `id` itself (past the UI's handling of the same id); an error shows in the +/// status bar. +fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result { + let r = app.session.execute(id, p).map_err(|e| e.to_string()); + app.sync_views(); + if let Err(e) = &r { + app.status(e.clone()); + } + r +} + +// ---------- File → Place… ---------- + +/// File → Place… (`file.place`): with a file (`path` or `name` + `dataBase64`), place it, centred +/// in the view unless `at`, `rect` or `replace` say otherwise; with `paths`, the Place dialog for +/// them; with neither, pick the files first. +pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { + if p.get("path").is_none() && p.get("dataBase64").is_none() { + let files: Vec = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(|path| json!({ "path": path })).collect(), + None => return pick(app).map(|_| Value::Null), + }; + if files.is_empty() { + return Err("paths: give at least one file".into()); + } + open_dialog(app, files); + return Ok(Value::Null); + } + let mut p = p.clone(); + if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none()) + && let Some(v) = app.view() + { + p["at"] = json!([v.center.x, v.center.y]); + } + engine(app, "file.place", &p) +} + +/// Pick files to place: the Place dialog opens with them (on the web once they arrive). +fn pick(app: &mut VectorcraftApp) -> Result<(), String> { + if let Some(f) = app.services.place_async.as_mut() { + f(); + return Ok(()); + } + let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) { + (Some(f), _) => f(), + (None, Some(f)) => f().into_iter().collect(), + (None, None) => vec![], + }; + if paths.is_empty() { + return Err("cancelled".into()); + } + open_dialog(app, paths.into_iter().map(|path| json!({ "path": path })).collect()); + Ok(()) +} + +/// The Place dialog for `files` (`{path}`, or `{name}` with the bytes in [`PlaceState`]). +fn open_dialog(app: &mut VectorcraftApp, files: Vec) { + let units = app.session.active().map(|d| d.doc.units).unwrap_or_default(); + let info: Vec = files.iter().map(|f| summary(app, f, units)).collect(); + let one_object = app.session.active().is_some_and(|d| d.selection.objects.len() == 1); + app.ui.dialog = Some(crate::state::Dialog::new( + crate::dialogs::place::KIND, + json!({ + "files": files, + "link": app.ui.place_link, + "template": false, + "replace": false, + "__replace": files.len() == 1 && one_object, + "__info": info, + }), + )); +} + +/// The engine params naming `file` (a dialog file entry). +fn source(app: &VectorcraftApp, file: &Value) -> Option { + if let Some(path) = file.get("path").and_then(Value::as_str) { + return Some(json!({ "path": path })); + } + let name = file.get("name").and_then(Value::as_str)?; + let bytes = app.place.picked.iter().find(|(n, _)| n == name).map(|(_, b)| b)?; + Some(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })) +} + +/// One line about `file` for the Place dialog ("300 × 150 px, 300 ppi, RGB (1 in × 0.5 in)"), or +/// why it can't be placed. +fn summary(app: &mut VectorcraftApp, file: &Value, units: vectorcraft_doc::Unit) -> String { + let Some(p) = source(app, file) else { return "not found".into() }; + let i = match app.session.execute("file.place.info", &p) { + Ok(i) => i, + Err(e) => return e.to_string(), + }; + let size = format!("{} × {}", units.format(i["width"].as_f64().unwrap_or(0.0)), units.format(i["height"].as_f64().unwrap_or(0.0))); + match (i["pixelWidth"].as_u64(), i["pixelHeight"].as_u64()) { + (Some(w), Some(h)) => { + let ppi = i["ppi"][0].as_f64().unwrap_or(72.0); + format!("{w} × {h} px, {} ppi, {} ({size})", fmt_ppi(ppi), i["colorMode"].as_str().unwrap_or("RGB")) + } + _ => format!("{} ({size})", i["format"].as_str().unwrap_or_default().to_uppercase()), + } +} + +/// A resolution as the Control bar and the Place dialog show it ("300", "299.5"). +fn fmt_ppi(v: f64) -> String { + let s = format!("{v:.1}"); + s.strip_suffix(".0").map(str::to_string).unwrap_or(s) +} + +/// Place (the dialog's OK): one file is placed centred in the view (or replaces the selection); +/// several load the place cursor. +pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result { + let files: Vec = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default(); + let sources: Vec = files.iter().filter_map(|f| source(app, f)).collect(); + if sources.is_empty() { + return Err("no files to place".into()); + } + let (link, template) = (d.bool("link"), d.bool("template")); + app.ui.place_link = link; + app.ui.dialog = None; + let r = match &sources[..] { + [one] => { + let mut p = one.clone(); + p["link"] = json!(link); + p["template"] = json!(template); + if d.bool("replace") && d.bool("__replace") { + p["replace"] = json!(true); + } + run(app, &p) + } + many => { + let paths: Vec<&Value> = many.iter().filter_map(|s| s.get("path")).collect(); + let files: Vec<&Value> = many.iter().filter(|s| s.get("path").is_none()).collect(); + queue(app, &json!({ "paths": paths, "files": files, "link": link, "template": template })) + } + }; + app.place.picked.clear(); + r +} + +// ---------- the place cursor ---------- + +/// `file.place.queue` with thumbnails for the cursor (unless `thumbnail` is given). +pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result { + let mut p = p.clone(); + if p.get("thumbnail").is_none() { + p["thumbnail"] = json!(THUMB); + } + let r = engine(app, "file.place.queue", &p)?; + app.place.thumbs = r["files"] + .as_array() + .into_iter() + .flatten() + .filter_map(|f| { + let png = vectorcraft_format::base64_decode(f["thumbnailBase64"].as_str()?)?; + let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()?.to_rgba8(); + let color = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw()); + Some((f["name"].as_str()?.to_string(), Thumb::Decoded(color))) + }) + .collect(); + Ok(r) +} + +/// The loaded place cursor at `pos`: the current file's thumbnail with the number of files loaded. +pub fn paint_cursor(app: &mut VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, pos: egui::Pos2) { + let opts = app.session.tool_options(); + let count = opts["count"].as_u64().unwrap_or(0); + let name = opts["name"].as_str().unwrap_or_default(); + let t = Tokens::get(ctx); + let tex = app.place.thumbs.iter_mut().find(|(n, _)| n == name).map(|(n, th)| { + if let Thumb::Decoded(img) = th { + *th = Thumb::Uploaded(ctx.load_texture(format!("place-thumb-{n}"), std::mem::take(img), egui::TextureOptions::LINEAR)); + } + match th { + Thumb::Uploaded(tex) => Some((tex.id(), tex.size_vec2())), + Thumb::Decoded(_) => None, + } + }); + let (id, size) = tex.flatten().unzip(); + let size = size.unwrap_or(egui::vec2(THUMB as f32, THUMB as f32 * 0.75)); + let k = THUMB as f32 / size.x.max(size.y).max(1.0); + let r = egui::Rect::from_min_size(pos + THUMB_OFFSET, size * k); + painter.rect_filled(r.expand(1.0), 0.0, t.panel); + match id { + Some(id) => { + painter.image(id, r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::from_white_alpha(200)); + } + None => { + painter.text(r.center(), egui::Align2::CENTER_CENTER, name, theme::semibold(10.0), t.text_dim); + } + } + painter.rect_stroke(r.expand(1.0), 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + if count > 1 { + // The badge: how many files are loaded. + let c = r.right_top() + egui::vec2(-2.0, 2.0); + painter.circle_filled(c, 9.0, t.accent); + painter.text(c, egui::Align2::CENTER_CENTER, count.to_string(), theme::semibold(10.5), egui::Color32::WHITE); + } +} + +// ---------- drops ---------- + +impl VectorcraftApp { + /// Where files dropped with the pointer at `pos` (screen points; `None` when the platform + /// doesn't tell) go: onto the canvas of the open document (Shift embeds them), else opened. An + /// unknown position counts as the middle of the view. + pub fn drop_target(&self, pos: Option, shift: bool) -> DropTarget { + let (Some(rect), Some(v)) = (self.canvas_rect.filter(|_| self.session.active().is_some()), self.view()) else { return DropTarget::Open }; + match pos { + Some(p) if !rect.contains(p) => DropTarget::Open, + Some(p) => DropTarget::Place { at: crate::canvas::Xf::new(rect, v).to_doc(p), embed: shift }, + None => DropTarget::Place { at: v.center, embed: shift }, + } + } +} + +/// Files dropped on the window (`(name, path, bytes)`): placed on the canvas or opened (and added +/// to Open Recent Files), as `target` says. +pub fn drop_files(app: &mut VectorcraftApp, files: Vec<(String, Option, Vec)>, target: DropTarget) { + for (name, path, bytes) in files { + let r = match target { + DropTarget::Place { at, embed } => { + let mut p = match &path { + Some(path) => json!({ "path": path }), + None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes) }), + }; + p["at"] = json!([at.x, at.y]); + p["link"] = json!(!embed); + run(app, &p).map(|_| ()) + } + DropTarget::Open => io::open_bytes(app, &name, &bytes, path.clone()).map(|_| { + if let Some(path) = &path { + io::note_recent(app, path); + } + }), + }; + if let Err(e) = r { + app.status(format!("Couldn't {} {name}: {e}", if target == DropTarget::Open { "open" } else { "place" })); + } + } +} + +/// Files that arrived to be placed (web): drops go where they were dropped, picked files to the +/// Place dialog. +pub fn drain(app: &mut VectorcraftApp) { + let arrived: Vec = + app.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + let mut picked = vec![]; + for a in arrived { + match a.drop { + Some((at, embed)) => drop_files(app, vec![(a.name, None, a.bytes)], DropTarget::Place { at, embed }), + None => { + picked.push(json!({ "name": a.name })); + app.place.picked.retain(|(n, _)| *n != a.name); + app.place.picked.push((a.name, a.bytes)); + } + } + } + if !picked.is_empty() { + open_dialog(app, picked); + } +} + +/// Files dragged over the window: the canvas is outlined where they would be placed. +pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) { + let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty()); + if !hovering || matches!(app.drop_target(ctx.input(|i| i.pointer.latest_pos()), false), DropTarget::Open) { + return; + } + let t = Tokens::get(ctx); + painter.rect_filled(rect, 0.0, t.accent.gamma_multiply(0.08)); + painter.rect_stroke(rect.shrink(1.5), 0.0, egui::Stroke::new(3.0, t.accent), egui::StrokeKind::Inside); +} + +// ---------- the Control bar ---------- + +/// The Control bar's details for the one selected image: Linked File or Embedded, its file name, +/// colour mode and effective resolution (`image.info`, fetched again only when it changes). +pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + let Some(st) = app.session.active() else { return }; + let key = match &st.selection.objects[..] { + [id] if st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Image(_))) => (st.uid, st.revision, id.0), + _ => return, + }; + if app.place.info.as_ref().is_none_or(|(k, _)| *k != key) { + let info = app.session.execute("image.info", &json!({ "id": key.2 })).unwrap_or_default(); + app.place.info = Some((key, info)); + } + let Some((_, i)) = &app.place.info else { return }; + let t = Tokens::get(ui.ctx()); + let name = i["link"].as_str().map(fileio::file_name).or_else(|| i["name"].as_str().map(str::to_string)).unwrap_or_default(); + ui.label(egui::RichText::new(name).size(12.0).color(t.text_strong)); + ui.separator(); + let ppi = i["ppi"].as_array().and_then(|a| a.first()).and_then(Value::as_f64).unwrap_or(0.0); + ui.label(egui::RichText::new(format!("{} PPI: {}", i["colorMode"].as_str().unwrap_or("RGB"), fmt_ppi(ppi))).size(12.0).color(t.text_dim)); + ui.separator(); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index eaf5f988..fbe2e235 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -86,8 +86,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let busy = app.session.tool_busy(); for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] { if !typing && ctx.input(|i| i.key_pressed(k)) { - let _ = app.session.tool_key(tk, Mods::default(), view); - if k == Key::Escape && !busy { + // A key the tool claims (Esc with a loaded place cursor) is only the tool's. + let claimed = app.session.tool_claims_key(tk, view); + let r = app.session.tool_key(tk, Mods::default(), view); + if claimed { + crate::canvas::apply_requests(app, r); + } + if k == Key::Escape && !busy && !claimed { if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { @@ -147,13 +152,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } // Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop: - // Delete/Backspace remove it, ←/→ nudge it). + // Delete/Backspace remove it, ←/→ nudge it; the loaded place cursor: the arrows cycle files). let m = ctx.input(|i| i.modifiers); - for (k, tk) in - [(Key::Delete, ToolKey::Delete), (Key::Backspace, ToolKey::Backspace), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] - { + for (k, tk) in [ + (Key::Delete, ToolKey::Delete), + (Key::Backspace, ToolKey::Backspace), + (Key::ArrowLeft, ToolKey::Left), + (Key::ArrowRight, ToolKey::Right), + (Key::ArrowUp, ToolKey::Up), + (Key::ArrowDown, ToolKey::Down), + ] { if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) { - let _ = app.session.tool_key(tk, crate::canvas::mods(m, false), view); + let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view); + crate::canvas::apply_requests(app, r); return; } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6d4b54b3..ea6abe14 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -263,6 +263,9 @@ pub struct UiState { /// The SVG Options chosen last (`svg` object of the export/save commands; null: never used). #[serde(default)] pub svg_options: Value, + /// File → Place: Link is on (the Place dialog remembers it). + #[serde(default = "yes")] + pub place_link: bool, } impl UiState { @@ -319,6 +322,7 @@ impl Default for UiState { flattener_preview: Default::default(), color_guide_limit: String::new(), svg_options: Value::Null, + place_link: true, } } } diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs new file mode 100644 index 00000000..f5864163 --- /dev/null +++ b/crates/ui-egui/src/tests_place.rs @@ -0,0 +1,200 @@ +//! File → Place in the app: the Place dialog, files dropped on the window, the loaded place +//! cursor and the Control bar's image details. + +use std::sync::Arc; + +use egui::{Pos2, Shape, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; +use crate::canvas::Xf; + +/// A `w`×`h` red PNG declaring `ppi`. +fn png(w: u32, h: u32, ppi: f64) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out, (ppi, ppi)) +} + +/// `bytes` written to a fresh temporary file named `name` → its path. +fn temp_file(name: &str, bytes: &[u8]) -> String { + let dir = std::env::temp_dir().join(format!("vectorcraft-ui-place-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().to_string() +} + +/// An app reading files from disk, with a 400×300 document. +fn app() -> VectorcraftApp { + let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app +} + +/// File → Place… with `path` chosen in the file picker. +fn place_picked(app: &mut VectorcraftApp, path: &str) { + let path = path.to_string(); + app.services.pick_open = Some(Box::new(move || Some(path.clone()))); + app.run("file.place", json!({})).unwrap(); +} + +#[derive(Debug)] +struct Dropped(std::path::PathBuf); + +impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } +} + +/// One headless frame of the whole window (800×600) with `events` and `dropped` files. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, mut events: Vec, dropped: &[&str], shift: bool) { + events.push(egui::Event::ModifiersChanged(egui::Modifiers { shift, ..Default::default() })); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events, + dropped_files: dropped.iter().map(|p| Arc::new(Dropped(p.into())) as egui::DroppedFileHandle).collect(), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); +} + +fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node { + let st = app.session.active().unwrap(); + let n = st.doc.node(st.selection.objects[0]).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Image(_)), "{:?}", n.kind); + n +} + +#[test] +fn a_dropped_png_is_centred_at_the_pointer() { + let mut app = app(); + let ctx = egui::Context::default(); + // Two frames: fonts, then the canvas lays out. + frame(&mut app, &ctx, vec![], &[], false); + frame(&mut app, &ctx, vec![], &[], false); + let rect = app.canvas_rect.expect("the canvas is laid out"); + let pos = rect.center() + vec2(60.0, -40.0); + let path = temp_file("drop.png", &png(20, 10, 72.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], false); + let n = selected_image(&app); + let want = Xf::new(rect, app.view().unwrap()).to_doc(pos); + let c = n.geometric_bounds().unwrap().center(); + assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "{c:?} vs {want:?}"); + assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_deref() == Some(path.as_str())), "linked by default"); + assert_eq!(app.session.documents().len(), 1, "placed, not opened"); + // Shift embeds. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], true); + assert!(matches!(&selected_image(&app).kind, NodeKind::Image(im) if im.link.is_none())); +} + +#[test] +fn files_dropped_off_the_canvas_or_with_no_document_open_and_are_recent() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let path = temp_file("open-me.png", &png(8, 8, 72.0)); + frame(&mut app, &ctx, vec![], &[], false); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))], &[&path], false); + assert_eq!(app.session.documents().len(), 1, "no document: the drop opens"); + assert_eq!(app.ui.recent_files.first(), Some(&path)); + // Over the tab bar (above the canvas): opened too. + frame(&mut app, &ctx, vec![], &[], false); + let top = app.canvas_rect.unwrap().top(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(300.0, top - 10.0))], &[&path], false); + assert_eq!(app.session.documents().len(), 2); +} + +#[test] +fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() { + let mut app = app(); + let a = temp_file("a.png", &png(300, 150, 300.0)); + let b = temp_file("b.png", &png(10, 10, 72.0)); + app.run("file.place", json!({"paths": [a, b]})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the Place dialog"); + assert_eq!(d.kind, crate::dialogs::place::KIND); + assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one file and one selected object"); + assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)"); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for label in ["Place", "a.png", "b.png", "Link", "Template", "Replace", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.tool_id(), "place"); + assert_eq!(app.session.tool_options()["count"], 2); + // The cursor carries the current file's thumbnail and the number of files. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut shapes = vec![]; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::place::paint_cursor(&mut app, ui.ctx(), ui.painter(), Pos2::new(50.0, 50.0))); + out.textures_delta.clear(); + shapes = out.shapes; + } + let mut text = String::new(); + let mut textured = false; + for s in shapes { + match s.shape { + Shape::Text(t) => text.push_str(t.galley.text()), + Shape::Mesh(m) => textured |= m.texture_id != egui::TextureId::default(), + _ => {} + } + } + assert!(textured, "the thumbnail"); + assert_eq!(text, "2", "the badge"); +} + +#[test] +fn one_file_places_centred_in_the_view_and_replace_swaps_the_selection() { + let mut app = app(); + app.view_mut().unwrap().center = vectorcraft_geom::Point::new(120.0, 80.0); + place_picked(&mut app, &temp_file("one.png", &png(300, 150, 300.0))); + let d = app.ui.dialog.as_ref().expect("the Place dialog"); + assert_eq!(d.kind, crate::dialogs::place::KIND); + assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one selected object"); + assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)"); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for label in ["Place", "one.png", "Link", "Template", "Replace", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let first = selected_image(&app); + assert_eq!(first.geometric_bounds().unwrap().center(), vectorcraft_geom::Point::new(120.0, 80.0), "centred in the view"); + // With one object selected, Replace applies. + place_picked(&mut app, &temp_file("two.png", &png(40, 40, 144.0))); + assert!(app.ui.dialog.as_ref().unwrap().bool("__replace")); + app.ui.dialog.as_mut().unwrap().fields.insert("replace".into(), json!(true)); + app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + let second = selected_image(&app); + assert_eq!(second.name.as_deref(), Some("two.png")); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1, "replaced"); + assert!(!app.ui.place_link, "Link is remembered"); +} + +#[test] +fn the_control_bar_shows_the_image_file_link_colour_mode_and_ppi() { + let mut app = app(); + let path = temp_file("photo.png", &png(30, 30, 300.0)); + app.run("file.place", json!({"path": path})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar); + for s in ["Linked File", "photo.png", "RGB PPI: 300"] { + assert!(text.contains(s), "{s} in {text}"); + } + let v: Value = app.run("file.place", json!({"path": path, "link": false})).unwrap(); + assert_eq!(v["linked"], false); + let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar); + assert!(text.contains("Embedded"), "{text}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0f6d016a..ad54da24 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,6 +22,7 @@ | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | +| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch (fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, diff --git a/docs/mcp.md b/docs/mcp.md index 896334e4..1aec39ac 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -806,3 +806,22 @@ passed straight back to `file.new`. Print presets and sizes without `units` star ```json {"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}} ``` + +## Place + +`file.place` puts another file's art into the active document as one undo step without touching the clipboard: +a raster image at 100% of its physical size (the resolution its file declares, else 72 ppi; linked to its `path` +unless `link: false`), an SVG as one group, a PDF/.ai page or a native document's artboard (`page`, `crop`) as one +clipped group, with the images, symbols, patterns and swatches it uses. `at` centres it, `rect` fits it, `replace` +swaps the selected object (keeping its place and transform), `template` puts it on a new template layer. +`file.place.info` describes a file without placing it and `image.info` reports a placed image's link, colour mode and +effective ppi. `file.place.queue` loads the place cursor (the `place` tool) with several files: headless, drive it +with `pointer_gesture` (a click places at 100% with the top-left corner there, a drag at the dragged size) and +`press_key` (Left/Right/Up/Down cycle, Escape discards the current file). + +```json +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/photo.jpg","at":[300,200]}}} +{"name":"run_command","arguments":{"command":"file.place","params":{"name":"logo.svg","dataBase64":"PHN2Zy…","rect":[0,0,100,100]}}} +{"name":"run_command","arguments":{"command":"file.place.queue","params":{"paths":["/tmp/a.png","/tmp/b.pdf"]}}} +{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":40,"y":40},{"kind":"up","x":40,"y":40}]}} +```