From 20e09351169e768b5c4962c891d89b519ba02536 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:22:34 +0700 Subject: [PATCH 1/2] M4.24: File > Place as an engine command: images at their physical size, SVG/PDF/native as one group file.place {path | name+dataBase64, link, template, replace, at, rect, page, crop} places a file as one undo step without touching the clipboard. Raster images come in at 100% of the resolution their file declares (PNG pHYs, JFIF/EXIF, TIFF, BMP; 72 ppi otherwise), linked to their path by default; SVG becomes one group with its embedded images; a PDF/.ai page or a native document's artboard becomes one clipped group. The images, symbols, patterns and swatches the art uses join the document (renamed on conflict). Replace keeps the replaced object's stacking place and transform; Template puts the art on a new locked template layer below the current one. file.place.info and image.info report size, ppi and colour mode. File > Place picks a file and opens the Place dialog (Link, Template, Replace); the web picks through the inbox. The Control bar shows Linked File/Embedded, the file name, colour mode and ppi of the selected image. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/vectorcraft-web/src/web.rs | 22 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/fileio/load.rs | 41 ++- crates/engine/src/cmd/fileio/mod.rs | 12 +- crates/engine/src/cmd/fileio/ppi.rs | 271 ++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/place/adopt.rs | 231 ++++++++++++++++ crates/engine/src/cmd/place/mod.rs | 392 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_place.rs | 281 +++++++++++++++++++ crates/engine/tests/import_fuzz.rs | 47 +++- crates/mcp/src/lib.rs | 2 + crates/mcp/src/tests_place.rs | 40 +++ crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/place.rs | 79 ++++++ crates/ui-egui/src/io.rs | 40 +-- crates/ui-egui/src/lib.rs | 11 + crates/ui-egui/src/menus.rs | 22 +- crates/ui-egui/src/place.rs | 175 ++++++++++++ crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/tests_place.rs | 89 ++++++ docs/control-protocol.md | 1 + docs/mcp.md | 15 + 25 files changed, 1723 insertions(+), 69 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/ppi.rs create mode 100644 crates/engine/src/cmd/place/adopt.rs create mode 100644 crates/engine/src/cmd/place/mod.rs create mode 100644 crates/engine/src/tests_place.rs create mode 100644 crates/mcp/src/tests_place.rs create mode 100644 crates/ui-egui/src/dialogs/place.rs create mode 100644 crates/ui-egui/src/place.rs create mode 100644 crates/ui-egui/src/tests_place.rs 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/src/web.rs b/apps/vectorcraft-web/src/web.rs index 064dfb95..6015bcd1 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex}; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; +use vectorcraft_ui_egui::place::{PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::{Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; @@ -38,7 +39,7 @@ 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())); + let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), Arc::default(), cc.egui_ctx.clone())); Ok(Box::new(WebShell { app, inbox })) }), ) @@ -92,9 +93,26 @@ 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 file goes 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(file) = dialog.pick_file().await else { + return; + }; + let bytes = file.read().await; + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name: file.file_name(), bytes }); + 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/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..8be6d3e7 --- /dev/null +++ b/crates/engine/src/cmd/place/mod.rs @@ -0,0 +1,392 @@ +//! 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`]). + +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"; +/// 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 + ), + ] +} + +// ---------- 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) +} + +// ---------- 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..eb4b935f --- /dev/null +++ b/crates/engine/src/tests_place.rs @@ -0,0 +1,281 @@ +//! File → Place (`file.place`, `file.place.info`, `image.info`). + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; +use vectorcraft_geom::Rect; + +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 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!(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..84ee3d6b --- /dev/null +++ b/crates/mcp/src/tests_place.rs @@ -0,0 +1,40 @@ +//! File → Place over MCP, headless: `file.place` through `run_command`. + +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()); +} 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..96a284da --- /dev/null +++ b/crates/ui-egui/src/dialogs/place.rs @@ -0,0 +1,79 @@ +//! File → Place: the file picked, a line about what it is, and the Place options row (Link, +//! Template, Replace). OK places it centred in the view, or in place of the selected object +//! ([`crate::place::confirm`]). +//! +//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (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); + }); + }); + } + }); + }); + 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..1dfa60a2 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,10 @@ 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, + /// Web: start an async pick of a file to place (it arrives via `place_inbox`). + pub place_async: Option>, + /// Files that arrived asynchronously to be placed (web: picked for Place). + pub place_inbox: Option, } /// Cached canvas raster. @@ -160,6 +167,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 Control bar's image details. + pub place: place::PlaceState, } impl VectorcraftApp { @@ -199,6 +208,7 @@ impl VectorcraftApp { canvas_rect: None, hover_doc: None, custom_titlebar: false, + place: Default::default(), } } @@ -412,6 +422,7 @@ impl VectorcraftApp { self.status(format!("Couldn't open {name}: {e}")); } } + place::drain(self); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 78e0a420..44b85615 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?} no file: pick one, then the Place dialog (Link, Template, Replace); 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,7 @@ 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.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..f35e867d --- /dev/null +++ b/crates/ui-egui/src/place.rs @@ -0,0 +1,175 @@ +//! File → Place in the app: picking the file, the Place dialog's options and the Control bar's +//! image details. The engine places (`file.place`); this gathers the file and options. + +use std::sync::{Arc, Mutex}; + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_engine::cmd::fileio; + +use crate::VectorcraftApp; +use crate::theme::Tokens; + +/// A file to place that arrived asynchronously (web): picked for File → Place. +pub struct PlaceArrival { + pub name: String, + pub bytes: Vec, +} + +pub type PlaceInbox = Arc>>; + +/// 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 Control bar's `image.info`, for (document uid, revision, image id). + info: Option<((u64, u64, u64), Value)>, +} + +/// 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; without, pick the file first. +pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { + if p.get("path").is_none() && p.get("dataBase64").is_none() { + return pick(app).map(|_| 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 the file to place: the Place dialog opens with it (on the web once it arrives). +fn pick(app: &mut VectorcraftApp) -> Result<(), String> { + if let Some(f) = app.services.place_async.as_mut() { + f(); + return Ok(()); + } + let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + open_dialog(app, vec![json!({ "path": path })]); + 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): the file is placed centred in the view (or replaces the selection). +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 Some(mut p) = files.first().and_then(|f| source(app, f)) else { return Err("no file to place".into()) }; + let link = d.bool("link"); + app.ui.place_link = link; + app.ui.dialog = None; + p["link"] = json!(link); + p["template"] = json!(d.bool("template")); + if d.bool("replace") && d.bool("__replace") { + p["replace"] = json!(true); + } + let r = run(app, &p); + app.place.picked.clear(); + r +} + +/// Files picked for File → Place that arrived (web): the Place dialog for them. +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 { + 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); + } +} + +// ---------- 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/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..5e394aa6 --- /dev/null +++ b/crates/ui-egui/src/tests_place.rs @@ -0,0 +1,89 @@ +//! File → Place in the app: the Place dialog and the Control bar's image details. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; + +/// 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(); +} + +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 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..a60f5ac2 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` | `{}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks one and opens the `place` dialog (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes the file); `ui.dialog.confirm` places it centred in the view, or in place of the selected object. With a file, it runs `file.place` centred in the view | 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..1d5baeab 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -806,3 +806,18 @@ 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. + +```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]}}} +``` From 76b7d960f2761369b754939e49c302d4c7de305b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:28:11 +0700 Subject: [PATCH 2/2] M4.25: Drop files on the canvas to place them; several placed files load the place cursor Files dropped on the canvas of an open document are placed at the drop point (linked; Shift embeds); dropped on the tab bar or with no document open they open and join Open Recent Files. The canvas is outlined while files are dragged over it. On the web, drag events give the drop point and the files arrive through the inbox. File > Place picks several files. file.place.queue loads the place cursor (the `place` tool): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size with its aspect kept, the arrow keys cycle the files and Esc discards one; the previous tool returns after the last. The cursor shows the file's thumbnail and a count badge. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-web/Cargo.toml | 2 +- apps/vectorcraft-web/src/web.rs | 80 ++++++--- apps/vectorcraft/src/main.rs | 9 + crates/engine/src/cmd/place/mod.rs | 75 ++++++++- crates/engine/src/tests_place.rs | 38 ++++- crates/mcp/src/tests_place.rs | 20 ++- crates/tools/src/lib.rs | 3 + crates/tools/src/place.rs | 245 ++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 15 +- crates/ui-egui/src/dialogs/place.rs | 14 +- crates/ui-egui/src/lib.rs | 41 +++-- crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/place.rs | 221 ++++++++++++++++++++++--- crates/ui-egui/src/shortcuts.rs | 25 ++- crates/ui-egui/src/tests_place.rs | 113 ++++++++++++- docs/control-protocol.md | 2 +- docs/mcp.md | 6 +- 17 files changed, 834 insertions(+), 78 deletions(-) create mode 100644 crates/tools/src/place.rs 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 6015bcd1..d3f966d7 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -1,15 +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::{PlaceArrival, PlaceInbox}; +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"; @@ -24,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 @@ -39,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(), Arc::default(), 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; @@ -57,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); } @@ -98,17 +135,20 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic let picked = place_inbox.clone(); let place_ctx = ctx.clone(); Services { - // File → Place…: the picked file goes to the Place dialog. + // 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(file) = dialog.pick_file().await else { + let Some(files) = dialog.pick_files().await else { return; }; - let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name: file.file_name(), bytes }); + 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(); }); })), 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/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 8be6d3e7..02785957 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -10,7 +10,8 @@ //! //! 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`]). +//! 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; @@ -26,6 +27,9 @@ 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; @@ -58,6 +62,15 @@ pub fn specs() -> Vec { 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 + ), ] } @@ -311,6 +324,66 @@ fn template_layer(d: &mut Document, below: Option, name: &str) -> Result 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. diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests_place.rs index eb4b935f..dfa85e46 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests_place.rs @@ -1,4 +1,5 @@ -//! File → Place (`file.place`, `file.place.info`, `image.info`). +//! File → Place (`file.place`, `file.place.info`, `image.info`) and the place cursor +//! (`file.place.queue` and the `place` tool). use std::sync::Arc; @@ -6,6 +7,7 @@ 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::*; @@ -261,6 +263,37 @@ fn info_reports_size_resolution_colour_mode_and_link() { 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(); @@ -277,5 +310,8 @@ fn bad_place_params_are_errors() { ] { 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/mcp/src/tests_place.rs b/crates/mcp/src/tests_place.rs index 84ee3d6b..367ceaab 100644 --- a/crates/mcp/src/tests_place.rs +++ b/crates/mcp/src/tests_place.rs @@ -1,4 +1,5 @@ -//! File → Place over MCP, headless: `file.place` through `run_command`. +//! 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}; @@ -38,3 +39,20 @@ fn headless_run_command_file_place() { 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/dialogs/place.rs b/crates/ui-egui/src/dialogs/place.rs index 96a284da..20878e72 100644 --- a/crates/ui-egui/src/dialogs/place.rs +++ b/crates/ui-egui/src/dialogs/place.rs @@ -1,9 +1,9 @@ -//! File → Place: the file picked, a line about what it is, and the Place options row (Link, -//! Template, Replace). OK places it centred in the view, or in place of the selected object -//! ([`crate::place::confirm`]). +//! 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 -//! selected object: Replace applies), `__info` (a line per file). +//! 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}; @@ -61,6 +61,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); }); + 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| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1dfa60a2..8f6c1d39 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,9 +88,12 @@ 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, - /// Web: start an async pick of a file to place (it arrives via `place_inbox`). + /// 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). + /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the + /// canvas). pub place_inbox: Option, } @@ -167,7 +170,7 @@ 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 Control bar's image details. + /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. pub place: place::PlaceState, } @@ -491,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 44b85615..95c4826e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -62,7 +62,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.place", "Place…", "Cmd+Shift+P", - "{path?} no file: pick one, then the Place dialog (Link, Template, Replace); path (or name+dataBase64, …file.place params): file.place, centred in the view", + "{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", "", "{}"), @@ -440,6 +440,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(json!(app.ui.recent_files)), "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 index f35e867d..24910d8f 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -1,32 +1,58 @@ -//! File → Place in the app: picking the file, the Place dialog's options and the Control bar's -//! image details. The engine places (`file.place`); this gathers the file and options. +//! 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::VectorcraftApp; -use crate::theme::Tokens; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, io}; -/// A file to place that arrived asynchronously (web): picked for File → Place. +/// 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 { @@ -41,10 +67,19 @@ fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result Result { if p.get("path").is_none() && p.get("dataBase64").is_none() { - return pick(app).map(|_| Value::Null); + 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()) @@ -55,14 +90,21 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { engine(app, "file.place", &p) } -/// Pick the file to place: the Place dialog opens with it (on the web once it arrives). +/// 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 path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; - open_dialog(app, vec![json!({ "path": path })]); + 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(()) } @@ -118,38 +160,171 @@ fn fmt_ppi(v: f64) -> String { s.strip_suffix(".0").map(str::to_string).unwrap_or(s) } -/// Place (the dialog's OK): the file is placed centred in the view (or replaces the selection). +/// 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 Some(mut p) = files.first().and_then(|f| source(app, f)) else { return Err("no file to place".into()) }; - let link = d.bool("link"); + 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; - p["link"] = json!(link); - p["template"] = json!(d.bool("template")); - if d.bool("replace") && d.bool("__replace") { - p["replace"] = json!(true); - } - let r = run(app, &p); + 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 } -/// Files picked for File → Place that arrived (web): the Place dialog for them. +// ---------- 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 { - picked.push(json!({ "name": a.name })); - app.place.picked.retain(|(n, _)| *n != a.name); - app.place.picked.push((a.name, a.bytes)); + 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, 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/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 5e394aa6..f5864163 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -1,10 +1,15 @@ -//! File → Place in the app: the Place dialog and the Control bar's image details. +//! 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 { @@ -37,6 +42,34 @@ fn place_picked(app: &mut VectorcraftApp, path: &str) { 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(); @@ -44,6 +77,84 @@ fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node { 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(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a60f5ac2..ad54da24 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,7 +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` | `{}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks one and opens the `place` dialog (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes the file); `ui.dialog.confirm` places it centred in the view, or in place of the selected object. With a file, it runs `file.place` centred in the view | +| `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 1d5baeab..1aec39ac 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -815,9 +815,13 @@ unless `link: false`), an SVG as one group, a PDF/.ai page or a native document' 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. +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}]}} ```