mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M4.24: File > Place as an engine command: images at their physical size, SVG/PDF/native as one group
file.place {path | name+dataBase64, link, template, replace, at, rect, page, crop} places a
file as one undo step without touching the clipboard. Raster images come in at 100% of the
resolution their file declares (PNG pHYs, JFIF/EXIF, TIFF, BMP; 72 ppi otherwise), linked to
their path by default; SVG becomes one group with its embedded images; a PDF/.ai page or a
native document's artboard becomes one clipped group. The images, symbols, patterns and
swatches the art uses join the document (renamed on conflict). Replace keeps the replaced
object's stacking place and transform; Template puts the art on a new locked template layer
below the current one. file.place.info and image.info report size, ppi and colour mode.
File > Place picks a file and opens the Place dialog (Link, Template, Replace); the web picks
through the inbox. The Control bar shows Linked File/Embedded, the file name, colour mode and
ppi of the selected image.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
26246980e3
commit
20e0935116
25 files changed
+1723
-69
No files matched your search
Generated
+1
@@ -4130,6 +4130,7 @@ dependencies = [
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::place::{PlaceArrival, PlaceInbox};
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
@@ -38,7 +39,7 @@ pub fn start() {
|
||||
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
let inbox: Inbox = Arc::default();
|
||||
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), Arc::default(), cc.egui_ctx.clone()));
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
}),
|
||||
)
|
||||
@@ -92,9 +93,26 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let picked = place_inbox.clone();
|
||||
let place_ctx = ctx.clone();
|
||||
Services {
|
||||
// File → Place…: the picked file goes to the Place dialog.
|
||||
place_async: Some(Box::new(move || {
|
||||
let inbox = picked.clone();
|
||||
let ctx = place_ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts));
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name: file.file_name(), bytes });
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
place_inbox: Some(place_inbox),
|
||||
open_async: Some(Box::new(move || {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
@@ -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,392 @@
|
||||
//! File → Place: another file's art into the active document as one undo step that never touches
|
||||
//! the clipboard.
|
||||
//!
|
||||
//! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution
|
||||
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its path with
|
||||
//! `link`.
|
||||
//! - An SVG becomes one group of its art.
|
||||
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
|
||||
//! (`crop: "crop"`) or bounded by its art (`crop: "bounding"`).
|
||||
//!
|
||||
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
|
||||
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
|
||||
//! swatches it uses join the document ([`adopt`]).
|
||||
|
||||
mod adopt;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, Format, RasterImage};
|
||||
use super::*;
|
||||
use crate::{EngineError, MAX_COORD};
|
||||
|
||||
const PLACE: &str = "file.place";
|
||||
/// The longest thumbnail side `thumbnail` may ask for (px).
|
||||
const MAX_THUMBNAIL: f64 = 512.0;
|
||||
|
||||
pub fn specs() -> Vec<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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- 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)
|
||||
}
|
||||
|
||||
// ---------- 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,281 @@
|
||||
//! File → Place (`file.place`, `file.place.info`, `image.info`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A `w`×`h` PNG of one colour (`rgba`), declaring `ppi` when given.
|
||||
fn png(w: u32, h: u32, rgba: [u8; 4], ppi: Option<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 bad_place_params_are_errors() {
|
||||
let mut s = session();
|
||||
let ok = vectorcraft_format::base64_encode(&png(2, 2, [0; 4], None));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"name": "x.png", "dataBase64": "%%%"}),
|
||||
json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(b"hello")}),
|
||||
json!({"name": "x.png", "dataBase64": ok, "rect": [0, 0, 0, 10]}),
|
||||
json!({"name": "x.png", "dataBase64": ok, "at": [1e12, 0]}),
|
||||
json!({"name": "x.png", "dataBase64": ok, "page": 0}),
|
||||
json!({"name": "x.png", "dataBase64": ok, "crop": "trim"}),
|
||||
json!({"name": "x.png", "dataBase64": ok, "template": true, "replace": true}),
|
||||
] {
|
||||
assert!(s.execute("file.place", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(top_children(&s).is_empty());
|
||||
}
|
||||
@@ -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,40 @@
|
||||
//! File → Place over MCP, headless: `file.place` through `run_command`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Backend, Headless, call_tool};
|
||||
|
||||
fn text(r: &crate::ToolResult) -> Value {
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// A headless session with a 400×300 document, and a 30×20 PNG as base64.
|
||||
fn setup() -> (Headless, String) {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 30, "height": 20}})).unwrap();
|
||||
let png = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "png"}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 400, "height": 300}})).unwrap();
|
||||
(h, png["dataBase64"].as_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
fn images(h: &Headless) -> usize {
|
||||
let mut n = 0;
|
||||
h.session.doc().unwrap().doc.walk(|x| n += usize::from(matches!(x.kind, vectorcraft_doc::NodeKind::Image(_))));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_run_command_file_place() {
|
||||
let (mut h, png) = setup();
|
||||
let r = text(&call_tool(
|
||||
&mut h,
|
||||
"run_command",
|
||||
&json!({"command": "file.place", "params": {"name": "tile.png", "dataBase64": png, "at": [100, 50]}}),
|
||||
));
|
||||
assert_eq!((r["format"].as_str(), r["width"].as_f64(), r["height"].as_f64()), (Some("png"), Some(30.0), Some(20.0)), "{r}");
|
||||
assert_eq!(images(&h), 1);
|
||||
let sel = h.session.doc().unwrap().selection.objects.clone();
|
||||
assert_eq!(sel.len(), 1);
|
||||
assert_eq!(sel[0].0, r["ids"][0].as_u64().unwrap());
|
||||
}
|
||||
@@ -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,79 @@
|
||||
//! File → Place: the file picked, a line about what it is, and the Place options row (Link,
|
||||
//! Template, Replace). OK places it centred in the view, or in place of the selected object
|
||||
//! ([`crate::place::confirm`]).
|
||||
//!
|
||||
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one
|
||||
//! selected object: Replace applies), `__info` (a line per file).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Place.
|
||||
pub const KIND: &str = "place";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| "Place".into(),
|
||||
body,
|
||||
confirm: crate::place::confirm,
|
||||
ok: Some("Place"),
|
||||
min_width: 340.0,
|
||||
max_width: Some(460.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// The options row: (field, label, tooltip).
|
||||
const OPTIONS: [(&str, &str, &str); 3] = [
|
||||
("link", "Link", "Keep a link to an image file instead of only embedding it"),
|
||||
("template", "Template", "Place onto a new locked, dimmed template layer below the current layer"),
|
||||
("replace", "Replace", "Swap the selected object for the file, keeping its place and transform"),
|
||||
];
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let files: Vec<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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
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,10 @@ 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>,
|
||||
/// Web: start an async pick of a file to place (it arrives via `place_inbox`).
|
||||
pub place_async: Option<Box<dyn FnMut()>>,
|
||||
/// Files that arrived asynchronously to be placed (web: picked for Place).
|
||||
pub place_inbox: Option<place::PlaceInbox>,
|
||||
}
|
||||
|
||||
/// Cached canvas raster.
|
||||
@@ -160,6 +167,8 @@ pub struct VectorcraftApp {
|
||||
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
|
||||
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
|
||||
pub custom_titlebar: bool,
|
||||
/// File → Place: picked files, the Control bar's image details.
|
||||
pub place: place::PlaceState,
|
||||
}
|
||||
|
||||
impl VectorcraftApp {
|
||||
@@ -199,6 +208,7 @@ impl VectorcraftApp {
|
||||
canvas_rect: None,
|
||||
hover_doc: None,
|
||||
custom_titlebar: false,
|
||||
place: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +422,7 @@ impl VectorcraftApp {
|
||||
self.status(format!("Couldn't open {name}: {e}"));
|
||||
}
|
||||
}
|
||||
place::drain(self);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?, svg?: {…SVG options}} like Save As, but the document keeps its path"),
|
||||
("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"),
|
||||
("file.revert", "Revert", "F12", "{}"),
|
||||
("file.place", "Place…", "Cmd+Shift+P", "{path?}"),
|
||||
(
|
||||
"file.place",
|
||||
"Place…",
|
||||
"Cmd+Shift+P",
|
||||
"{path?} no file: pick one, then the Place dialog (Link, Template, Replace); path (or name+dataBase64, …file.place params): file.place, centred in the view",
|
||||
),
|
||||
("file.openRecent1", "Open Recent File 1", "", "{}"),
|
||||
("file.openRecent2", "Open Recent File 2", "", "{}"),
|
||||
("file.openRecent3", "Open Recent File 3", "", "{}"),
|
||||
@@ -434,20 +439,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<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.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,175 @@
|
||||
//! File → Place in the app: picking the file, the Place dialog's options and the Control bar's
|
||||
//! image details. The engine places (`file.place`); this gathers the file and options.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// A file to place that arrived asynchronously (web): picked for File → Place.
|
||||
pub struct PlaceArrival {
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub type PlaceInbox = Arc<Mutex<Vec<PlaceArrival>>>;
|
||||
|
||||
/// 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 Control bar's `image.info`, for (document uid, revision, image id).
|
||||
info: Option<((u64, u64, u64), Value)>,
|
||||
}
|
||||
|
||||
/// Run engine command `id` itself (past the UI's handling of the same id); an error shows in the
|
||||
/// status bar.
|
||||
fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result<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; without, pick the file first.
|
||||
pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if p.get("path").is_none() && p.get("dataBase64").is_none() {
|
||||
return pick(app).map(|_| Value::Null);
|
||||
}
|
||||
let mut p = p.clone();
|
||||
if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none())
|
||||
&& let Some(v) = app.view()
|
||||
{
|
||||
p["at"] = json!([v.center.x, v.center.y]);
|
||||
}
|
||||
engine(app, "file.place", &p)
|
||||
}
|
||||
|
||||
/// Pick the file to place: the Place dialog opens with it (on the web once it arrives).
|
||||
fn pick(app: &mut VectorcraftApp) -> Result<(), String> {
|
||||
if let Some(f) = app.services.place_async.as_mut() {
|
||||
f();
|
||||
return Ok(());
|
||||
}
|
||||
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
|
||||
open_dialog(app, vec![json!({ "path": path })]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Place dialog for `files` (`{path}`, or `{name}` with the bytes in [`PlaceState`]).
|
||||
fn open_dialog(app: &mut VectorcraftApp, files: Vec<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): the file is placed centred in the view (or replaces the selection).
|
||||
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 Some(mut p) = files.first().and_then(|f| source(app, f)) else { return Err("no file to place".into()) };
|
||||
let link = d.bool("link");
|
||||
app.ui.place_link = link;
|
||||
app.ui.dialog = None;
|
||||
p["link"] = json!(link);
|
||||
p["template"] = json!(d.bool("template"));
|
||||
if d.bool("replace") && d.bool("__replace") {
|
||||
p["replace"] = json!(true);
|
||||
}
|
||||
let r = run(app, &p);
|
||||
app.place.picked.clear();
|
||||
r
|
||||
}
|
||||
|
||||
/// Files picked for File → Place that arrived (web): the Place dialog for them.
|
||||
pub fn drain(app: &mut VectorcraftApp) {
|
||||
let arrived: Vec<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 {
|
||||
picked.push(json!({ "name": a.name }));
|
||||
app.place.picked.retain(|(n, _)| *n != a.name);
|
||||
app.place.picked.push((a.name, a.bytes));
|
||||
}
|
||||
if !picked.is_empty() {
|
||||
open_dialog(app, picked);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the Control bar ----------
|
||||
|
||||
/// The Control bar's details for the one selected image: Linked File or Embedded, its file name,
|
||||
/// colour mode and effective resolution (`image.info`, fetched again only when it changes).
|
||||
pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let key = match &st.selection.objects[..] {
|
||||
[id] if st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Image(_))) => (st.uid, st.revision, id.0),
|
||||
_ => return,
|
||||
};
|
||||
if app.place.info.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
let info = app.session.execute("image.info", &json!({ "id": key.2 })).unwrap_or_default();
|
||||
app.place.info = Some((key, info));
|
||||
}
|
||||
let Some((_, i)) = &app.place.info else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let name = i["link"].as_str().map(fileio::file_name).or_else(|| i["name"].as_str().map(str::to_string)).unwrap_or_default();
|
||||
ui.label(egui::RichText::new(name).size(12.0).color(t.text_strong));
|
||||
ui.separator();
|
||||
let ppi = i["ppi"].as_array().and_then(|a| a.first()).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
ui.label(egui::RichText::new(format!("{} PPI: {}", i["colorMode"].as_str().unwrap_or("RGB"), fmt_ppi(ppi))).size(12.0).color(t.text_dim));
|
||||
ui.separator();
|
||||
}
|
||||
@@ -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,89 @@
|
||||
//! File → Place in the app: the Place dialog and the Control bar's image details.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// A `w`×`h` red PNG declaring `ppi`.
|
||||
fn png(w: u32, h: u32, ppi: f64) -> Vec<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();
|
||||
}
|
||||
|
||||
fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node {
|
||||
let st = app.session.active().unwrap();
|
||||
let n = st.doc.node(st.selection.objects[0]).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Image(_)), "{:?}", n.kind);
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_file_places_centred_in_the_view_and_replace_swaps_the_selection() {
|
||||
let mut app = app();
|
||||
app.view_mut().unwrap().center = vectorcraft_geom::Point::new(120.0, 80.0);
|
||||
place_picked(&mut app, &temp_file("one.png", &png(300, 150, 300.0)));
|
||||
let d = app.ui.dialog.as_ref().expect("the Place dialog");
|
||||
assert_eq!(d.kind, crate::dialogs::place::KIND);
|
||||
assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one selected object");
|
||||
assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)");
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||||
for label in ["Place", "one.png", "Link", "Template", "Replace", "Cancel"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let first = selected_image(&app);
|
||||
assert_eq!(first.geometric_bounds().unwrap().center(), vectorcraft_geom::Point::new(120.0, 80.0), "centred in the view");
|
||||
// With one object selected, Replace applies.
|
||||
place_picked(&mut app, &temp_file("two.png", &png(40, 40, 144.0)));
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("__replace"));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("replace".into(), json!(true));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let second = selected_image(&app);
|
||||
assert_eq!(second.name.as_deref(), Some("two.png"));
|
||||
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1, "replaced");
|
||||
assert!(!app.ui.place_link, "Link is remembered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_bar_shows_the_image_file_link_colour_mode_and_ppi() {
|
||||
let mut app = app();
|
||||
let path = temp_file("photo.png", &png(30, 30, 300.0));
|
||||
app.run("file.place", json!({"path": path})).unwrap();
|
||||
let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar);
|
||||
for s in ["Linked File", "photo.png", "RGB PPI: 300"] {
|
||||
assert!(text.contains(s), "{s} in {text}");
|
||||
}
|
||||
let v: Value = app.run("file.place", json!({"path": path, "link": false})).unwrap();
|
||||
assert_eq!(v["linked"], false);
|
||||
let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar);
|
||||
assert!(text.contains("Embedded"), "{text}");
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
|
||||
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
|
||||
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
|
||||
| `engine.execute` `file.place` | `{}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks one and opens the `place` dialog (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes the file); `ui.dialog.confirm` places it centred in the view, or in place of the selected object. With a file, it runs `file.place` centred in the view |
|
||||
|
||||
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
|
||||
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
|
||||
|
||||
+15
@@ -806,3 +806,18 @@ passed straight back to `file.new`. Print presets and sizes without `units` star
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}}
|
||||
```
|
||||
|
||||
## Place
|
||||
|
||||
`file.place` puts another file's art into the active document as one undo step without touching the clipboard:
|
||||
a raster image at 100% of its physical size (the resolution its file declares, else 72 ppi; linked to its `path`
|
||||
unless `link: false`), an SVG as one group, a PDF/.ai page or a native document's artboard (`page`, `crop`) as one
|
||||
clipped group, with the images, symbols, patterns and swatches it uses. `at` centres it, `rect` fits it, `replace`
|
||||
swaps the selected object (keeping its place and transform), `template` puts it on a new template layer.
|
||||
`file.place.info` describes a file without placing it and `image.info` reports a placed image's link, colour mode and
|
||||
effective ppi.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/photo.jpg","at":[300,200]}}}
|
||||
{"name":"run_command","arguments":{"command":"file.place","params":{"name":"logo.svg","dataBase64":"PHN2Zy…","rect":[0,0,100,100]}}}
|
||||
```
|
||||
Reference in new issue
Block a user