Merge pull request #70 from storytold/pr/m4-place

M4: Place as an engine command, drag and drop, the loaded place cursor (M4.24, M4.25)
This commit is contained in:
BFlat 2026-10-05 08:50:42 +07:00 committed by GitHub
commit a31f212021
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2513 additions and 103 deletions

1
Cargo.lock generated
View File

@ -4130,6 +4130,7 @@ dependencies = [
name = "vectorcraft-engine"
version = "0.1.1"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"log",

View File

@ -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"] }

View File

@ -1,14 +1,21 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::cell::Cell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// 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<Cell<Option<(f32, f32, bool)>>>;
const CANVAS_ID: &str = "vectorcraft_canvas";
const LOADING_ID: &str = "vectorcraft_loading";
@ -23,6 +30,7 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let drag = track_drag(&canvas);
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
@ -38,8 +46,9 @@ pub fn start() {
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let inbox: Inbox = Arc::default();
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
Ok(Box::new(WebShell { app, inbox }))
let place_inbox: PlaceInbox = Arc::default();
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), place_inbox.clone(), cc.egui_ctx.clone()));
Ok(Box::new(WebShell { app, inbox, place_inbox, drag }))
}),
)
.await;
@ -56,29 +65,58 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Follow file drags over the canvas: drag events carry the pointer position, which egui doesn't
/// get during a drag.
fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos {
let pos = DragPos::default();
let p = pos.clone();
let on_drag = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::DragEvent)>::new(move |e: web_sys::DragEvent| {
p.set(Some((e.offset_x() as f32, e.offset_y() as f32, e.shift_key())));
});
for kind in ["dragover", "drop"] {
if let Err(e) = canvas.add_event_listener_with_callback(kind, on_drag.as_ref().unchecked_ref()) {
log::error!("couldn't follow {kind} events: {e:?}");
}
}
// The listener lives as long as the page.
on_drag.forget();
pos
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
struct WebShell {
app: VectorcraftApp,
inbox: Inbox,
place_inbox: PlaceInbox,
drag: DragPos,
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
if !dropped.is_empty() {
let at = self.drag.take();
let z = ctx.zoom_factor();
let target = self.app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let (inbox, place_inbox, ctx) = (self.inbox.clone(), self.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
match target {
DropTarget::Place { at, embed } => {
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) })
}
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
}
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
});
}
}
self.app.logic(ctx);
}
@ -92,9 +130,29 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
Services {
// File → Place…: the picked files go to the Place dialog.
place_async: Some(Box::new(move || {
let inbox = picked.clone();
let ctx = place_ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts));
let Some(files) = dialog.pick_files().await else {
return;
};
let mut arrived = vec![];
for f in files {
arrived.push(PlaceArrival { name: f.file_name(), bytes: f.read().await, drop: None });
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).extend(arrived);
ctx.request_repaint();
});
})),
place_inbox: Some(place_inbox),
open_async: Some(Box::new(move || {
let inbox = open_inbox.clone();
let ctx = ctx.clone();

View File

@ -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()))),

View File

@ -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 }

View File

@ -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<String
Ok(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }))
}
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<u8>,
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<Source<'a>> {
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<Value> {
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<RasterImage> {
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<RasterImage> {
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<Document> {
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();

View File

@ -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<Item = (&'static str, &'static [&'static str])> {
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<Item = (&'static str, &'static [&'static str])> {
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();

View File

@ -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<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
slice(b, at, N)?.try_into().ok()
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
bytes(b, at).map(u16::from_be_bytes)
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
bytes(b, at).map(u32::from_be_bytes)
}
fn le32(b: &[u8], at: usize) -> Option<u32> {
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<Item = (&[u8], &[u8])> {
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<f64> {
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<u8> {
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<u8> {
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<u8> {
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);
}
}

View File

@ -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
})
}

View File

@ -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<String>),
}
/// 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<String>,
symbols: Vec<String>,
patterns: Vec<String>,
swatches: Vec<String>,
}
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<String, (Option<String>, 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<T: PartialEq>(
used: &[String],
def: impl Fn(&str) -> Option<T>,
existing: impl Fn(&str) -> Option<T>,
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<Node> = 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);
}
}
}

View File

@ -0,0 +1,465 @@
//! File → Place: another file's art into the active document as one undo step that never touches
//! the clipboard.
//!
//! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its path with
//! `link`.
//! - An SVG becomes one group of its art.
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
//! (`crop: "crop"`) or bounded by its art (`crop: "bounding"`).
//!
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
//! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the
//! `place` tool) with several files, which emits one `file.place` per click or drag.
mod adopt;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use super::fileio::{self, Format, RasterImage};
use super::*;
use crate::{EngineError, MAX_COORD};
const PLACE: &str = "file.place";
const QUEUE: &str = "file.place.queue";
/// The most files one `file.place.queue` loads.
const MAX_QUEUE: usize = 100;
/// The longest thumbnail side `thumbnail` may ask for (px).
const MAX_THUMBNAIL: f64 = 512.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.place",
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc,
place
),
cmd!(
query "file.place.info",
"Placed File Info",
[],
None,
"{path | name+dataBase64, page?, crop?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}",
always,
info
),
cmd!(
query "image.info",
"Image Info",
[],
None,
"{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
has_doc,
image_info
),
cmd!(
query "file.place.queue",
"Load Place Cursor",
[],
None,
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
has_doc,
queue
),
]
}
// ---------- reading the file ----------
/// A file's art at its natural size, before it joins a document.
enum Art {
Image(RasterImage),
Vector {
/// The loaded document: the source of the art's resources.
src: Box<Document>,
nodes: Vec<Node>,
/// Crop: the page or artboard the art is clipped to.
clip: Option<Rect>,
},
}
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<String>,
/// The file's path (for Link).
path: Option<String>,
}
/// 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<Loaded> {
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<Node>> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(page - 1).into_iter().collect() };
let on_board = |c: &&Arc<Node>| 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<Item = &'a Arc<Node>>) -> Vec<Node> {
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<String>) -> 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<Arc<Node>> = 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<Option<Rect>> {
let Some(v) = p.get("rect") else { return Ok(None) };
let n: Vec<f64> = 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<Value> {
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<NodeId>, name: &str) -> Result<NodeId> {
let id = d.add_layer(Some(&format!("Template {name}")));
let at = below.and_then(|b| d.layers.iter().position(|l| l.id == b)).unwrap_or(0);
d.move_node(id, None, at)?;
if let Some(n) = d.node_mut(id) {
n.locked = true;
if let NodeKind::Layer { template, .. } = &mut n.kind {
*template = true;
}
}
Ok(id)
}
// ---------- file.place.queue ----------
/// The files `file.place.queue` names, as `file.place` params.
fn queued_files(p: &Value) -> Result<Vec<Value>> {
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<Value> {
let thumbnail = p.get("thumbnail").and_then(Value::as_f64);
let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]);
for mut q in queued_files(p)? {
for k in ["link", "template", "page", "crop"] {
if let Some(v) = p.get(k) {
q[k] = v.clone();
}
}
match load(&q, QUEUE) {
Ok(l) => {
let (w, h) = (l.natural.width(), l.natural.height());
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
entries.push(json!({ "params": q, "name": l.name, "width": w, "height": h }));
if let Some(px) = thumbnail {
f["thumbnailBase64"] = json!(thumbnail_png(l, px)?);
}
files.push(f);
}
Err(e) => {
let name = str_param(&q, "path").or(str_param(&q, "name")).map(fileio::file_name).unwrap_or_default();
skipped.push(json!({ "name": name, "error": e.to_string() }));
}
}
}
if entries.is_empty() {
let why = skipped.first().and_then(|e| e["error"].as_str()).unwrap_or("nothing to place");
return Err(bad(QUEUE, why));
}
// Reloading the cursor keeps the tool to return to.
let prev = Some(s.tool_id()).filter(|t| *t != "place");
let view = s.last_view;
s.select_tool("place", view)?;
s.set_tool_option("queue", &json!({ "entries": entries, "prev": prev }));
Ok(json!({ "count": files.len(), "files": files, "skipped": skipped }))
}
// ---------- queries ----------
/// The colour mode an image file stores its pixels in.
fn color_mode(bytes: &[u8]) -> &'static str {
use image::{ExtendedColorType as E, ImageDecoder as _};
let kind = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()
.and_then(|r| r.into_decoder().ok())
.map(|d| d.original_color_type());
match kind {
Some(E::L1 | E::La1 | E::L2 | E::La2 | E::L4 | E::La4 | E::L8 | E::La8 | E::L16 | E::La16) => "Grayscale",
Some(E::Cmyk8 | E::Cmyk16) => "CMYK",
_ => "RGB",
}
}
/// `l` rendered as a PNG at most `px` (≤ [`MAX_THUMBNAIL`]) pixels on its longer side, base64.
fn thumbnail_png(l: Loaded, px: f64) -> Result<String> {
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<Value> {
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<Value> {
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(),
}))
}

View File

@ -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;

View File

@ -0,0 +1,317 @@
//! File → Place (`file.place`, `file.place.info`, `image.info`) and the place cursor
//! (`file.place.queue` and the `place` tool).
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// A `w`×`h` PNG of one colour (`rgba`), declaring `ppi` when given.
fn png(w: u32, h: u32, rgba: [u8; 4], ppi: Option<f64>) -> Vec<u8> {
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<Arc<Node>> {
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<u8> {
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##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><image width="40" height="20" href="data:image/png;base64,{red}"/><rect width="10" height="10" fill="#0000ff"/></svg>"##
);
let r = place(&mut s, "art.svg", svg.as_bytes(), json!({"at": [100, 100]}));
assert_eq!(r["format"], "svg");
let g = node(&s, &r["ids"][0]);
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("one group: {:?}", g.kind) };
assert_eq!(children.len(), 2);
assert_eq!(g.name.as_deref(), Some("art.svg"));
assert_eq!(s.clipboard, clipboard, "Place never touches the clipboard");
let doc = &s.doc().unwrap().doc;
assert!(doc.images.len() == 1, "the embedded image joined the document");
// The group spans 80..120 × 90..110: blue square top left, red image elsewhere.
let img = vectorcraft_testkit::raster::render_region(doc, Rect::new(80.0, 90.0, 120.0, 110.0), 1.0);
assert_eq!(img.over_white(30, 15), [255, 0, 0]);
assert_eq!(img.over_white(4, 4), [0, 0, 255]);
}
#[test]
fn replace_keeps_the_transform_and_the_stacking_place() {
let mut s = session();
let first = place(&mut s, "a.png", &png(100, 50, [255, 0, 0, 255], None), json!({"at": [150, 150]}))["ids"][0].clone();
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
s.execute("object.scale", &json!({"sx": 50})).unwrap();
let centre = node(&s, &first).geometric_bounds().unwrap().center();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("select.set", &json!({"ids": [first]})).unwrap();
assert!(s.execute("file.place", &json!({"replace": true, "at": [0, 0], "name": "b.png", "dataBase64": ""})).is_err());
let r = place(&mut s, "b.png", &png(20, 20, [0, 255, 0, 255], Some(144.0)), json!({"replace": true}));
let id = &r["ids"][0];
assert_eq!(top_children(&s)[0].id.0, id.as_u64().unwrap(), "same place in the stacking order");
assert_eq!(top_children(&s).len(), 2);
let c = node(&s, id).geometric_bounds().unwrap().center();
assert!(close(c.x, centre.x) && close(c.y, centre.y), "{c:?} vs {centre:?}");
// 144 ppi at the old image's 50%: 288 ppi, rotated as it was.
let info = s.execute("image.info", &json!({})).unwrap();
assert!(close(info["ppi"][0].as_f64().unwrap(), 288.0) && close(info["ppi"][1].as_f64().unwrap(), 288.0), "{info}");
let NodeKind::Image(im) = &node(&s, id).kind else { panic!() };
let [a, b, ..] = im.xf.as_coeffs();
assert!(close(b.atan2(a).to_degrees(), -30.0), "{:?}", im.xf);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(top_children(&s)[0].id.0, first.as_u64().unwrap(), "one undo step brings the old image back");
}
#[test]
fn replace_needs_exactly_one_selected_object() {
let mut s = session();
let r = s
.execute("file.place", &json!({"replace": true, "name": "b.png", "dataBase64": vectorcraft_format::base64_encode(&png(2, 2, [0; 4], None))}));
assert!(r.unwrap_err().to_string().contains("select the one object"));
}
#[test]
fn template_puts_the_art_on_a_locked_template_layer_below_the_current_one() {
let mut s = session();
s.execute("layer.new", &json!({})).unwrap();
let current = s.doc().unwrap().current_layer().unwrap();
let r = place(&mut s, "sketch.png", &png(10, 10, [0, 0, 0, 255], None), json!({"template": true}));
let st = s.doc().unwrap();
let layers = &st.doc.layers;
assert_eq!(layers.len(), 3);
let t = layers.iter().position(|l| l.name.as_deref() == Some("Template sketch.png")).unwrap();
assert_eq!(layers[t + 1].id, current, "right below the current layer");
assert!(layers[t].locked && matches!(layers[t].kind, NodeKind::Layer { template: true, .. }));
assert_eq!(st.doc.layer_of(NodeId(r["ids"][0].as_u64().unwrap())), Some(layers[t].id));
assert!(st.selection.is_empty(), "locked template art isn't selected");
assert_eq!(st.history.undo.len(), 2, "layer.new, then Place as one step");
}
#[test]
fn pdf_pages_and_native_artboards_place_as_clipped_groups() {
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50, "artboards": 2})).unwrap();
src.execute("artboard.setProps", &json!({"index": 1, "width": 40})).unwrap();
let b1 = src.doc().unwrap().doc.artboards[1].rect;
src.execute("shape.rectangle", &json!({"x": b1.x0 + 5.0, "y": 5, "width": 20, "height": 10})).unwrap();
let pdf = serialize(&mut src, "pdf");
let native = serialize(&mut src, "vectorcraft");
let mut s = session();
for (name, bytes) in [("two.pdf", &pdf), ("two.vectorcraft", &native)] {
let r = place(&mut s, name, bytes, json!({"page": 2}));
assert!(close(r["width"].as_f64().unwrap(), 40.0) && close(r["height"].as_f64().unwrap(), 50.0), "{name}: {r}");
let g = node(&s, &r["ids"][0]);
assert!(matches!(g.kind, NodeKind::Group { clip: true, .. }), "{name}: clipped to the page");
let r = place(&mut s, name, bytes, json!({"page": 2, "crop": "bounding"}));
assert!((r["width"].as_f64().unwrap() - 20.0).abs() < 1.5, "{name}: the art's bounds: {r}");
let err = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes), "page": 3})).unwrap_err();
assert!(err.to_string().contains("has 2"), "{name}: {err}");
}
}
#[test]
fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() {
// The file: a global "Brand" red rectangle and a "Star" symbol instance.
let mut src = session();
src.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
src.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
src.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap();
src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
src.execute("symbol.new", &json!({"name": "Star"})).unwrap();
let bytes = serialize(&mut src, "vectorcraft");
// The document: its own, different "Brand" and "Star".
let mut s = session();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#00ff00", "global": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
s.execute("symbol.new", &json!({"name": "Star"})).unwrap();
let r = place(&mut s, "logo.vectorcraft", &bytes, json!({"crop": "bounding"}));
let g = node(&s, &r["ids"][0]);
let (mut fill, mut symbol) = (None, None);
g.walk(&mut |n| match &n.kind {
NodeKind::SymbolInstance { symbol: name, .. } => symbol = Some(name.clone()),
NodeKind::Path { .. } => {
fill = n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None })
}
_ => {}
});
assert_eq!(symbol.as_deref(), Some("Star 2"));
let doc = &s.doc().unwrap().doc;
assert!(doc.symbols.iter().any(|x| x.name == "Star 2") && doc.symbols.iter().any(|x| x.name == "Star"));
let Some(Paint::Solid { color, swatch: None, .. }) = fill else { panic!("unlinked: {fill:?}") };
assert_eq!(color.to_hex(), "#ff0000", "keeps its look");
assert_eq!(doc.swatch("Brand").and_then(|w| w.paint.color()).map(|c| c.to_hex()).as_deref(), Some("#00ff00"));
// A document without them gets them as they are.
let mut fresh = session();
let r = place(&mut fresh, "logo.vectorcraft", &bytes, json!({}));
let doc = &fresh.doc().unwrap().doc;
assert!(doc.symbols.iter().any(|x| x.name == "Star") && doc.swatch("Brand").is_some());
let mut linked = false;
node(&fresh, &r["ids"][0]).walk(&mut |n| {
linked |= n
.appearance
.items
.iter()
.any(|i| matches!(i, AppearanceItem::Fill(f) if matches!(&f.paint, Paint::Solid { swatch: Some(w), .. } if w == "Brand")));
});
assert!(linked, "still linked to the swatch it brought");
}
#[test]
fn info_reports_size_resolution_colour_mode_and_link() {
let mut s = session();
let mut gray = vec![];
image::GrayImage::from_pixel(300, 150, image::Luma([128])).write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png).unwrap();
let gray = cmd::fileio::ppi::with_png_resolution(&gray, (300.0, 300.0));
let mut p = file("scan.png", &gray);
p["thumbnail"] = json!(16);
let info = s.execute("file.place.info", &p).unwrap();
assert_eq!((info["colorMode"].as_str(), info["pixelWidth"].as_u64()), (Some("Grayscale"), Some(300)));
assert_eq!(info["ppi"], json!([300.0, 300.0]));
assert!(close(info["width"].as_f64().unwrap(), 72.0));
let thumb = image::load_from_memory(&vectorcraft_format::base64_decode(info["thumbnailBase64"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!((thumb.width(), thumb.height()), (16, 8));
assert!(top_children(&s).is_empty(), "info places nothing");
// A path is linked by default.
let path = std::env::temp_dir().join(format!("vectorcraft-place-{}.png", std::process::id()));
std::fs::write(&path, &gray).unwrap();
let path = path.to_string_lossy().to_string();
let r = s.execute("file.place", &json!({"path": path})).unwrap();
assert_eq!(r["linked"], true);
let im = s.execute("image.info", &json!({})).unwrap();
assert_eq!((im["linked"].as_bool(), im["link"].as_str(), im["colorMode"].as_str()), (Some(true), Some(path.as_str()), Some("Grayscale")));
assert!(close(im["ppi"][0].as_f64().unwrap(), 300.0), "{im}");
let r = s.execute("file.place", &json!({"path": path, "link": false})).unwrap();
assert_eq!(r["linked"], false);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_queue_of_3_with_click_drag_and_esc_places_2() {
let mut s = session();
let view = ViewInfo::default();
let files: Vec<Value> = ["a.png", "b.png", "c.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect();
let r = s.execute("file.place.queue", &json!({"files": files, "thumbnail": 8})).unwrap();
assert_eq!(r["count"], 3);
assert!(r["files"][0]["thumbnailBase64"].is_string());
assert_eq!(s.tool_id(), "place");
assert_eq!(s.tool_options()["name"], "a.png");
let ev = |kind, x, y| PointerEvent::new(kind, x, y);
// Click: 100% with its top-left corner at the click.
s.pointer(&ev(PointerKind::Down, 10.0, 10.0), view).unwrap();
s.pointer(&ev(PointerKind::Up, 10.0, 10.0), view).unwrap();
let a = s.doc().unwrap().selection.objects[0];
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds(), Some(Rect::new(10.0, 10.0, 50.0, 30.0)));
// Drag: the dragged size, aspect kept.
s.pointer(&ev(PointerKind::Down, 100.0, 100.0), view).unwrap();
s.pointer(&ev(PointerKind::Drag, 180.0, 110.0), view).unwrap();
s.pointer(&ev(PointerKind::Up, 180.0, 110.0), view).unwrap();
let b = s.doc().unwrap().selection.objects[0];
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().name.as_deref(), Some("b.png"));
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds(), Some(Rect::new(100.0, 100.0, 180.0, 140.0)));
// Esc discards the last one and asks for the previous tool back.
assert!(s.tool_claims_key(ToolKey::Escape, view));
let reqs = s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap();
assert_eq!(reqs, vec![UiRequest::SwitchTool("selection".into())]);
assert_eq!(top_children(&s).len(), 2);
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "one undo step per placement");
}
#[test]
fn bad_place_params_are_errors() {
let mut s = session();
let ok = vectorcraft_format::base64_encode(&png(2, 2, [0; 4], None));
for p in [
json!({}),
json!({"name": "x.png", "dataBase64": "%%%"}),
json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(b"hello")}),
json!({"name": "x.png", "dataBase64": ok, "rect": [0, 0, 0, 10]}),
json!({"name": "x.png", "dataBase64": ok, "at": [1e12, 0]}),
json!({"name": "x.png", "dataBase64": ok, "page": 0}),
json!({"name": "x.png", "dataBase64": ok, "crop": "trim"}),
json!({"name": "x.png", "dataBase64": ok, "template": true, "replace": true}),
] {
assert!(s.execute("file.place", &p).is_err(), "{p}");
}
assert!(s.execute("file.place.queue", &json!({})).is_err());
assert!(s.execute("file.place.queue", &json!({"files": [{"name": "notes.txt", "dataBase64": ""}]})).is_err());
assert_eq!(s.tool_id(), "selection", "a failed queue leaves the tool alone");
assert!(top_children(&s).is_empty());
}

View File

@ -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<u8>)> {
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::<u8>()), 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());
}
}

View File

@ -36,4 +36,6 @@ mod tests_freeform;
#[cfg(test)]
mod tests_gradient;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_svg;

View File

@ -0,0 +1,58 @@
//! File → Place over MCP, headless: `file.place` through `run_command`, and the place cursor driven
//! by pointer gestures and keys.
use serde_json::{Value, json};
use crate::{Backend, Headless, call_tool};
fn text(r: &crate::ToolResult) -> Value {
assert!(!r.is_error, "{r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
}
/// A headless session with a 400×300 document, and a 30×20 PNG as base64.
fn setup() -> (Headless, String) {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 30, "height": 20}})).unwrap();
let png = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "png"}})).unwrap();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 400, "height": 300}})).unwrap();
(h, png["dataBase64"].as_str().unwrap().to_string())
}
fn images(h: &Headless) -> usize {
let mut n = 0;
h.session.doc().unwrap().doc.walk(|x| n += usize::from(matches!(x.kind, vectorcraft_doc::NodeKind::Image(_))));
n
}
#[test]
fn headless_run_command_file_place() {
let (mut h, png) = setup();
let r = text(&call_tool(
&mut h,
"run_command",
&json!({"command": "file.place", "params": {"name": "tile.png", "dataBase64": png, "at": [100, 50]}}),
));
assert_eq!((r["format"].as_str(), r["width"].as_f64(), r["height"].as_f64()), (Some("png"), Some(30.0), Some(20.0)), "{r}");
assert_eq!(images(&h), 1);
let sel = h.session.doc().unwrap().selection.objects.clone();
assert_eq!(sel.len(), 1);
assert_eq!(sel[0].0, r["ids"][0].as_u64().unwrap());
}
#[test]
fn headless_place_cursor_places_on_clicks_and_esc_ends_it() {
let (mut h, png) = setup();
let files = json!([{"name": "a.png", "dataBase64": png}, {"name": "b.png", "dataBase64": png}, {"name": "c.png", "dataBase64": png}]);
let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.place.queue", "params": {"files": files}})));
assert_eq!(r["count"], 3);
assert_eq!(h.session.tool_id(), "place");
let click = |x: f64| json!({"events": [{"kind": "down", "x": x, "y": 10}, {"kind": "up", "x": x, "y": 10}]});
text(&call_tool(&mut h, "pointer_gesture", &click(10.0)));
text(&call_tool(&mut h, "pointer_gesture", &click(60.0)));
assert_eq!(images(&h), 2);
let r = text(&call_tool(&mut h, "press_key", &json!({"key": "Escape"})));
assert_eq!(r["handledBy"], "tool");
assert_eq!(h.session.tool_id(), "selection", "the last file discarded: back to the previous tool");
assert_eq!(images(&h), 2);
}

View File

@ -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<dyn Tool> {
"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))

245
crates/tools/src/place.rs Normal file
View File

@ -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<Entry>,
current: usize,
/// The tool to return to when the last file is placed or discarded.
prev: Option<String>,
/// 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<Rect> {
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<Action> {
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<Action> {
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<Action> {
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<Overlay> {
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::<Vec<_>>(),
})
}
/// `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<Entry> = 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<Action> {
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<Value> =
(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())]);
}
}

View File

@ -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<Vec<vectorcraft_engine::UiRequest>>) {
match r {
Ok(reqs) => {
for r in reqs {
match r {

View File

@ -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);

View File

@ -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).

View File

@ -0,0 +1,83 @@
//! File → Place: the files picked, one line each about what they are, and the Place options row
//! (Link, Template, Replace). OK places one file centred in the view (or in place of the selected
//! object) or loads the place cursor with several ([`crate::place::confirm`]).
//!
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file
//! and one selected object: Replace applies), `__info` (a line per file).
use serde_json::{Value, json};
use super::DialogSpec;
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Place.
pub const KIND: &str = "place";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Place".into(),
body,
confirm: crate::place::confirm,
ok: Some("Place"),
min_width: 340.0,
max_width: Some(460.0),
..DialogSpec::FORM
};
/// The options row: (field, label, tooltip).
const OPTIONS: [(&str, &str, &str); 3] = [
("link", "Link", "Keep a link to an image file instead of only embedding it"),
("template", "Template", "Place onto a new locked, dimmed template layer below the current layer"),
("replace", "Replace", "Swap the selected object for the file, keeping its place and transform"),
];
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let files: Vec<Value> = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default();
let info = d.fields.get("__info").cloned().unwrap_or_default();
widgets::subheader(ui, if files.len() == 1 { "File" } else { "Files" });
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().max_height(180.0).auto_shrink([false, true]).show(ui, |ui| {
ui.set_width(ui.available_width());
for (i, f) in files.iter().enumerate() {
let path = f.get("path").and_then(Value::as_str);
let name = path
.map(vectorcraft_engine::cmd::fileio::file_name)
.or_else(|| f.get("name").and_then(Value::as_str).map(str::to_string))
.unwrap_or_default();
ui.horizontal(|ui| {
ui.add_space(6.0);
ui.vertical(|ui| {
ui.add_space(3.0);
let label = ui.label(egui::RichText::new(name).font(theme::semibold(12.0)).color(t.text_strong));
if let Some(p) = path {
label.on_hover_text(p);
}
ui.label(egui::RichText::new(info[i].as_str().unwrap_or_default()).size(11.5).color(t.text_dim));
ui.add_space(3.0);
});
});
}
});
});
if files.len() > 1 {
ui.add_space(4.0);
widgets::dim_label(ui, "Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one.");
}
ui.add_space(10.0);
// The options row.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 18.0;
for (key, label, tip) in OPTIONS {
let on = d.bool(key);
let enabled = key != "replace" || d.bool("__replace");
let resp = ui.scope(|ui| widgets::check(ui, label, on && enabled, enabled));
if resp.inner {
d.fields.insert(key.into(), json!(!on));
}
resp.response.on_hover_text(tip);
}
});
false
}

View File

@ -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<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
app.session.clipboard = nodes;
// Straight to the engine: `app.run` would let the system clipboard replace these nodes.
app.session.execute("edit.pasteInPlace", &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<RefCell<Vec<(String, Vec<u8>)>>>;

View File

@ -20,6 +20,7 @@ pub mod io;
pub mod menus;
pub mod palette;
pub mod panels;
pub mod place;
pub mod prefs_dialog;
pub mod render_worker;
pub mod shortcut_editor;
@ -41,6 +42,8 @@ mod tests_overprint;
#[cfg(test)]
mod tests_paintchips;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_recolor;
#[cfg(test)]
mod tests_svg;
@ -85,6 +88,13 @@ pub struct Services {
pub clipboard_read: Option<Box<dyn FnMut() -> Option<String>>>,
/// Open a URL in the system browser (desktop). Without it, egui opens it (a new tab on the web).
pub open_url: Option<OpenUrlFn>,
/// Show an open dialog for several files (File → Place…); returns their paths (none: cancelled).
pub pick_open_multi: Option<Box<dyn FnMut() -> Vec<String>>>,
/// Web: start an async pick of files to place (they arrive via `place_inbox`).
pub place_async: Option<Box<dyn FnMut()>>,
/// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the
/// canvas).
pub place_inbox: Option<place::PlaceInbox>,
}
/// Cached canvas raster.
@ -160,6 +170,8 @@ pub struct VectorcraftApp {
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
pub custom_titlebar: bool,
/// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details.
pub place: place::PlaceState,
}
impl VectorcraftApp {
@ -199,6 +211,7 @@ impl VectorcraftApp {
canvas_rect: None,
hover_doc: None,
custom_titlebar: false,
place: Default::default(),
}
}
@ -412,6 +425,7 @@ impl VectorcraftApp {
self.status(format!("Couldn't open {name}: {e}"));
}
}
place::drain(self);
}
}
@ -480,21 +494,31 @@ impl VectorcraftApp {
if self.fonts_ready {
shortcuts::handle(self, ctx);
}
// Native only: the web host reads dropped files asynchronously and feeds `Services::inbox`.
// Native only: the web host reads dropped files asynchronously and feeds the inboxes.
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let path = f.path().to_path_buf();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes() {
Ok(b) => {
let p = Some(path.to_string_lossy().to_string()).filter(|s| !s.is_empty());
if let Err(e) = io::open_bytes(self, &name, &b, p) {
self.status(format!("Couldn't open {name}: {e}"));
}
}
self.take_dropped_files(ctx);
}
/// Files dropped on the window: placed where they were dropped on the canvas, else opened.
#[cfg(not(target_arch = "wasm32"))]
fn take_dropped_files(&mut self, ctx: &egui::Context) {
let (dropped, pos, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos(), i.modifiers.shift));
if dropped.is_empty() {
return;
}
let target = self.drop_target(pos, shift);
let mut files = vec![];
for f in dropped {
let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty());
let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name);
// A file placed by its path is read by the engine.
let bytes = if path.is_some() && target != place::DropTarget::Open { Ok(vec![]) } else { f.bytes() };
match bytes {
Ok(b) => files.push((name, path, b)),
Err(e) => self.status(format!("Couldn't read {name}: {e}")),
}
}
place::drop_files(self, files, target);
}
/// Inject synthetic events (one press/release step per frame).

View File

@ -58,7 +58,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?, svg?: {…SVG options}} like Save As, but the document keeps its path"),
("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"),
("file.revert", "Revert", "F12", "{}"),
("file.place", "Place…", "Cmd+Shift+P", "{path?}"),
(
"file.place",
"Place…",
"Cmd+Shift+P",
"{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view",
),
("file.openRecent1", "Open Recent File 1", "", "{}"),
("file.openRecent2", "Open Recent File 2", "", "{}"),
("file.openRecent3", "Open Recent File 3", "", "{}"),
@ -434,20 +439,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Ok(Value::Null)
}
"file.recentFiles" => Ok(json!(app.ui.recent_files)),
"file.place" => {
let path = match s("path") {
Some(p) => Some(p),
None => app.services.pick_open.as_mut().and_then(|f| f()),
};
match path {
Some(path) => {
let bytes = app.services.read.as_ref().ok_or("no reader".to_string()).and_then(|r| r(&path));
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
bytes.and_then(|b| io::place_bytes(app, &name, &b)).map(|_| Value::Null)
}
None => Err("cancelled".into()),
}
}
"file.place" => crate::place::run(app, p),
"file.place.queue" => crate::place::queue(app, p),
"file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => {
crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None);
Ok(Value::Null)

350
crates/ui-egui/src/place.rs Normal file
View File

@ -0,0 +1,350 @@
//! File → Place in the app: picking the files, the Place dialog's options, files dropped on the
//! window, the loaded place cursor and the Control bar's image details. The engine places
//! (`file.place`, `file.place.queue`); this gathers the files and options.
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_geom::Point;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, io};
/// The loaded place cursor's thumbnail size (px on the longer side).
const THUMB: u32 = 64;
/// Where the thumbnail sits from the pointer (points).
const THUMB_OFFSET: egui::Vec2 = egui::vec2(14.0, 14.0);
/// A file to place that arrived asynchronously (web): picked for File → Place (`drop: None`), or
/// dropped on the canvas at a point, embedded or not.
pub struct PlaceArrival {
pub name: String,
pub bytes: Vec<u8>,
pub drop: Option<(Point, bool)>,
}
pub type PlaceInbox = Arc<Mutex<Vec<PlaceArrival>>>;
/// 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<u8>)>,
/// 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<Value, String> {
let r = app.session.execute(id, p).map_err(|e| e.to_string());
app.sync_views();
if let Err(e) = &r {
app.status(e.clone());
}
r
}
// ---------- File → Place… ----------
/// File → Place… (`file.place`): with a file (`path` or `name` + `dataBase64`), place it, centred
/// in the view unless `at`, `rect` or `replace` say otherwise; with `paths`, the Place dialog for
/// them; with neither, pick the files first.
pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
if p.get("path").is_none() && p.get("dataBase64").is_none() {
let files: Vec<Value> = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(|path| json!({ "path": path })).collect(),
None => return pick(app).map(|_| Value::Null),
};
if files.is_empty() {
return Err("paths: give at least one file".into());
}
open_dialog(app, files);
return Ok(Value::Null);
}
let mut p = p.clone();
if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none())
&& let Some(v) = app.view()
{
p["at"] = json!([v.center.x, v.center.y]);
}
engine(app, "file.place", &p)
}
/// Pick files to place: the Place dialog opens with them (on the web once they arrive).
fn pick(app: &mut VectorcraftApp) -> Result<(), String> {
if let Some(f) = app.services.place_async.as_mut() {
f();
return Ok(());
}
let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) {
(Some(f), _) => f(),
(None, Some(f)) => f().into_iter().collect(),
(None, None) => vec![],
};
if paths.is_empty() {
return Err("cancelled".into());
}
open_dialog(app, paths.into_iter().map(|path| json!({ "path": path })).collect());
Ok(())
}
/// The Place dialog for `files` (`{path}`, or `{name}` with the bytes in [`PlaceState`]).
fn open_dialog(app: &mut VectorcraftApp, files: Vec<Value>) {
let units = app.session.active().map(|d| d.doc.units).unwrap_or_default();
let info: Vec<String> = 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<Value> {
if let Some(path) = file.get("path").and_then(Value::as_str) {
return Some(json!({ "path": path }));
}
let name = file.get("name").and_then(Value::as_str)?;
let bytes = app.place.picked.iter().find(|(n, _)| n == name).map(|(_, b)| b)?;
Some(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }))
}
/// One line about `file` for the Place dialog ("300 × 150 px, 300 ppi, RGB (1 in × 0.5 in)"), or
/// why it can't be placed.
fn summary(app: &mut VectorcraftApp, file: &Value, units: vectorcraft_doc::Unit) -> String {
let Some(p) = source(app, file) else { return "not found".into() };
let i = match app.session.execute("file.place.info", &p) {
Ok(i) => i,
Err(e) => return e.to_string(),
};
let size = format!("{} × {}", units.format(i["width"].as_f64().unwrap_or(0.0)), units.format(i["height"].as_f64().unwrap_or(0.0)));
match (i["pixelWidth"].as_u64(), i["pixelHeight"].as_u64()) {
(Some(w), Some(h)) => {
let ppi = i["ppi"][0].as_f64().unwrap_or(72.0);
format!("{w} × {h} px, {} ppi, {} ({size})", fmt_ppi(ppi), i["colorMode"].as_str().unwrap_or("RGB"))
}
_ => format!("{} ({size})", i["format"].as_str().unwrap_or_default().to_uppercase()),
}
}
/// A resolution as the Control bar and the Place dialog show it ("300", "299.5").
fn fmt_ppi(v: f64) -> String {
let s = format!("{v:.1}");
s.strip_suffix(".0").map(str::to_string).unwrap_or(s)
}
/// Place (the dialog's OK): one file is placed centred in the view (or replaces the selection);
/// several load the place cursor.
pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Value, String> {
let files: Vec<Value> = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default();
let sources: Vec<Value> = files.iter().filter_map(|f| source(app, f)).collect();
if sources.is_empty() {
return Err("no files to place".into());
}
let (link, template) = (d.bool("link"), d.bool("template"));
app.ui.place_link = link;
app.ui.dialog = None;
let r = match &sources[..] {
[one] => {
let mut p = one.clone();
p["link"] = json!(link);
p["template"] = json!(template);
if d.bool("replace") && d.bool("__replace") {
p["replace"] = json!(true);
}
run(app, &p)
}
many => {
let paths: Vec<&Value> = many.iter().filter_map(|s| s.get("path")).collect();
let files: Vec<&Value> = many.iter().filter(|s| s.get("path").is_none()).collect();
queue(app, &json!({ "paths": paths, "files": files, "link": link, "template": template }))
}
};
app.place.picked.clear();
r
}
// ---------- the place cursor ----------
/// `file.place.queue` with thumbnails for the cursor (unless `thumbnail` is given).
pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
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<egui::Pos2>, 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<String>, Vec<u8>)>, 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<PlaceArrival> =
app.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
let mut picked = vec![];
for a in arrived {
match a.drop {
Some((at, embed)) => drop_files(app, vec![(a.name, None, a.bytes)], DropTarget::Place { at, embed }),
None => {
picked.push(json!({ "name": a.name }));
app.place.picked.retain(|(n, _)| *n != a.name);
app.place.picked.push((a.name, a.bytes));
}
}
}
if !picked.is_empty() {
open_dialog(app, picked);
}
}
/// Files dragged over the window: the canvas is outlined where they would be placed.
pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) {
let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty());
if !hovering || matches!(app.drop_target(ctx.input(|i| i.pointer.latest_pos()), false), DropTarget::Open) {
return;
}
let t = Tokens::get(ctx);
painter.rect_filled(rect, 0.0, t.accent.gamma_multiply(0.08));
painter.rect_stroke(rect.shrink(1.5), 0.0, egui::Stroke::new(3.0, t.accent), egui::StrokeKind::Inside);
}
// ---------- the Control bar ----------
/// The Control bar's details for the one selected image: Linked File or Embedded, its file name,
/// colour mode and effective resolution (`image.info`, fetched again only when it changes).
pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let Some(st) = app.session.active() else { return };
let key = match &st.selection.objects[..] {
[id] if st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Image(_))) => (st.uid, st.revision, id.0),
_ => return,
};
if app.place.info.as_ref().is_none_or(|(k, _)| *k != key) {
let info = app.session.execute("image.info", &json!({ "id": key.2 })).unwrap_or_default();
app.place.info = Some((key, info));
}
let Some((_, i)) = &app.place.info else { return };
let t = Tokens::get(ui.ctx());
let name = i["link"].as_str().map(fileio::file_name).or_else(|| i["name"].as_str().map(str::to_string)).unwrap_or_default();
ui.label(egui::RichText::new(name).size(12.0).color(t.text_strong));
ui.separator();
let ppi = i["ppi"].as_array().and_then(|a| a.first()).and_then(Value::as_f64).unwrap_or(0.0);
ui.label(egui::RichText::new(format!("{} PPI: {}", i["colorMode"].as_str().unwrap_or("RGB"), fmt_ppi(ppi))).size(12.0).color(t.text_dim));
ui.separator();
}

View File

@ -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;
}
}

View File

@ -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,
}
}
}

View File

@ -0,0 +1,200 @@
//! File → Place in the app: the Place dialog, files dropped on the window, the loaded place
//! cursor and the Control bar's image details.
use std::sync::Arc;
use egui::{Pos2, Shape, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
use crate::canvas::Xf;
/// A `w`×`h` red PNG declaring `ppi`.
fn png(w: u32, h: u32, ppi: f64) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::from_pixel(w, h, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out, (ppi, ppi))
}
/// `bytes` written to a fresh temporary file named `name` → its path.
fn temp_file(name: &str, bytes: &[u8]) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-place-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().to_string()
}
/// An app reading files from disk, with a 400×300 document.
fn app() -> VectorcraftApp {
let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app
}
/// File → Place… with `path` chosen in the file picker.
fn place_picked(app: &mut VectorcraftApp, path: &str) {
let path = path.to_string();
app.services.pick_open = Some(Box::new(move || Some(path.clone())));
app.run("file.place", json!({})).unwrap();
}
#[derive(Debug)]
struct Dropped(std::path::PathBuf);
impl egui::DroppedFile for Dropped {
fn path(&self) -> &std::path::Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, 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<egui::Event>, dropped: &[&str], shift: bool) {
events.push(egui::Event::ModifiersChanged(egui::Modifiers { shift, ..Default::default() }));
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
events,
dropped_files: dropped.iter().map(|p| Arc::new(Dropped(p.into())) as egui::DroppedFileHandle).collect(),
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node {
let st = app.session.active().unwrap();
let n = st.doc.node(st.selection.objects[0]).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Image(_)), "{:?}", n.kind);
n
}
#[test]
fn a_dropped_png_is_centred_at_the_pointer() {
let mut app = app();
let ctx = egui::Context::default();
// Two frames: fonts, then the canvas lays out.
frame(&mut app, &ctx, vec![], &[], false);
frame(&mut app, &ctx, vec![], &[], false);
let rect = app.canvas_rect.expect("the canvas is laid out");
let pos = rect.center() + vec2(60.0, -40.0);
let path = temp_file("drop.png", &png(20, 10, 72.0));
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], false);
let n = selected_image(&app);
let want = Xf::new(rect, app.view().unwrap()).to_doc(pos);
let c = n.geometric_bounds().unwrap().center();
assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "{c:?} vs {want:?}");
assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_deref() == Some(path.as_str())), "linked by default");
assert_eq!(app.session.documents().len(), 1, "placed, not opened");
// Shift embeds.
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], true);
assert!(matches!(&selected_image(&app).kind, NodeKind::Image(im) if im.link.is_none()));
}
#[test]
fn files_dropped_off_the_canvas_or_with_no_document_open_and_are_recent() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let path = temp_file("open-me.png", &png(8, 8, 72.0));
frame(&mut app, &ctx, vec![], &[], false);
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))], &[&path], false);
assert_eq!(app.session.documents().len(), 1, "no document: the drop opens");
assert_eq!(app.ui.recent_files.first(), Some(&path));
// Over the tab bar (above the canvas): opened too.
frame(&mut app, &ctx, vec![], &[], false);
let top = app.canvas_rect.unwrap().top();
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(300.0, top - 10.0))], &[&path], false);
assert_eq!(app.session.documents().len(), 2);
}
#[test]
fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() {
let mut app = app();
let a = temp_file("a.png", &png(300, 150, 300.0));
let b = temp_file("b.png", &png(10, 10, 72.0));
app.run("file.place", json!({"paths": [a, b]})).unwrap();
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert_eq!(d.kind, crate::dialogs::place::KIND);
assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one file and one selected object");
assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)");
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for label in ["Place", "a.png", "b.png", "Link", "Template", "Replace", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.tool_id(), "place");
assert_eq!(app.session.tool_options()["count"], 2);
// The cursor carries the current file's thumbnail and the number of files.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut shapes = vec![];
for _ in 0..2 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::place::paint_cursor(&mut app, ui.ctx(), ui.painter(), Pos2::new(50.0, 50.0)));
out.textures_delta.clear();
shapes = out.shapes;
}
let mut text = String::new();
let mut textured = false;
for s in shapes {
match s.shape {
Shape::Text(t) => text.push_str(t.galley.text()),
Shape::Mesh(m) => textured |= m.texture_id != egui::TextureId::default(),
_ => {}
}
}
assert!(textured, "the thumbnail");
assert_eq!(text, "2", "the badge");
}
#[test]
fn one_file_places_centred_in_the_view_and_replace_swaps_the_selection() {
let mut app = app();
app.view_mut().unwrap().center = vectorcraft_geom::Point::new(120.0, 80.0);
place_picked(&mut app, &temp_file("one.png", &png(300, 150, 300.0)));
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert_eq!(d.kind, crate::dialogs::place::KIND);
assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one selected object");
assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)");
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for label in ["Place", "one.png", "Link", "Template", "Replace", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let first = selected_image(&app);
assert_eq!(first.geometric_bounds().unwrap().center(), vectorcraft_geom::Point::new(120.0, 80.0), "centred in the view");
// With one object selected, Replace applies.
place_picked(&mut app, &temp_file("two.png", &png(40, 40, 144.0)));
assert!(app.ui.dialog.as_ref().unwrap().bool("__replace"));
app.ui.dialog.as_mut().unwrap().fields.insert("replace".into(), json!(true));
app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false));
crate::dialogs::confirm(&mut app).unwrap();
let second = selected_image(&app);
assert_eq!(second.name.as_deref(), Some("two.png"));
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1, "replaced");
assert!(!app.ui.place_link, "Link is remembered");
}
#[test]
fn the_control_bar_shows_the_image_file_link_colour_mode_and_ppi() {
let mut app = app();
let path = temp_file("photo.png", &png(30, 30, 300.0));
app.run("file.place", json!({"path": path})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar);
for s in ["Linked File", "photo.png", "RGB PPI: 300"] {
assert!(text.contains(s), "{s} in {text}");
}
let v: Value = app.run("file.place", json!({"path": path, "link": false})).unwrap();
assert_eq!(v["linked"], false);
let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar);
assert!(text.contains("Embedded"), "{text}");
}

View File

@ -22,6 +22,7 @@
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,

View File

@ -806,3 +806,22 @@ passed straight back to `file.new`. Print presets and sizes without `units` star
```json
{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}}
```
## Place
`file.place` puts another file's art into the active document as one undo step without touching the clipboard:
a raster image at 100% of its physical size (the resolution its file declares, else 72 ppi; linked to its `path`
unless `link: false`), an SVG as one group, a PDF/.ai page or a native document's artboard (`page`, `crop`) as one
clipped group, with the images, symbols, patterns and swatches it uses. `at` centres it, `rect` fits it, `replace`
swaps the selected object (keeping its place and transform), `template` puts it on a new template layer.
`file.place.info` describes a file without placing it and `image.info` reports a placed image's link, colour mode and
effective ppi. `file.place.queue` loads the place cursor (the `place` tool) with several files: headless, drive it
with `pointer_gesture` (a click places at 100% with the top-left corner there, a drag at the dragged size) and
`press_key` (Left/Right/Up/Down cycle, Escape discards the current file).
```json
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/photo.jpg","at":[300,200]}}}
{"name":"run_command","arguments":{"command":"file.place","params":{"name":"logo.svg","dataBase64":"PHN2Zy…","rect":[0,0,100,100]}}}
{"name":"run_command","arguments":{"command":"file.place.queue","params":{"paths":["/tmp/a.png","/tmp/b.pdf"]}}}
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":40,"y":40},{"kind":"up","x":40,"y":40}]}}
```