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