mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
file.place of a .txt file takes `text: {characterSet: unicode|ansi, platform: windows|mac,
removeLineReturns, removeParagraphReturns, replaceSpaces}`: Unicode reads UTF-8 or UTF-16 by its
byte-order mark (other bytes as the platform's 8-bit set), ANSI reads Windows-1252 or Mac Roman;
CR LF, CR and LF all break lines; joining lines makes each block one paragraph, blank lines can
go, and runs of spaces can become tabs. The text becomes area type filling `rect`, the replaced
object's bounds, or the artboard less a 36 pt margin (type reflows rather than scaling), as one
undo step. The place cursor and file.place.info take text files too, and File → Place lists them.
In the app, placing a text file (the Place dialog, a drop, file.place without options) opens the
Text Import Options dialog. Its dropdown rows share a form helper with Document Setup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
509 lines
24 KiB
Rust
509 lines
24 KiB
Rust
//! 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 file with
|
|
//! `link` ([`super::links`]).
|
|
//! - 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"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
|
|
//! (`crop: "bounding"`).
|
|
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
|
//! [`text`]).
|
|
//!
|
|
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
|
|
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
|
|
//! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the
|
|
//! `place` tool) with several files, which emits one `file.place` per click or drag.
|
|
|
|
mod adopt;
|
|
mod text;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_color::Paint;
|
|
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling};
|
|
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
|
use vectorcraft_pdf::CropTo;
|
|
|
|
use super::fileio::{self, Format, RasterImage};
|
|
use super::*;
|
|
use crate::{EngineError, MAX_COORD};
|
|
|
|
const PLACE: &str = "file.place";
|
|
const QUEUE: &str = "file.place.queue";
|
|
/// The most files one `file.place.queue` loads.
|
|
const MAX_QUEUE: usize = 100;
|
|
/// The longest thumbnail side `thumbnail` may ask for (px).
|
|
const MAX_THUMBNAIL: f64 = 512.0;
|
|
/// A text file's natural box (`file.place.info`, the place cursor): a letter page less 1 in margins.
|
|
const TEXT_FRAME: Rect = Rect::new(0.0, 0.0, 468.0, 648.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), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), 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) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {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?, password?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}",
|
|
always,
|
|
info
|
|
),
|
|
cmd!(
|
|
query "image.info",
|
|
"Image Info",
|
|
[],
|
|
None,
|
|
"{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
|
|
has_doc,
|
|
image_info
|
|
),
|
|
cmd!(
|
|
query "file.place.queue",
|
|
"Load Place Cursor",
|
|
[],
|
|
None,
|
|
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
|
|
has_doc,
|
|
queue
|
|
),
|
|
]
|
|
}
|
|
|
|
// ---------- reading the file ----------
|
|
|
|
/// A file's art at its natural size, before it joins a document.
|
|
enum Art {
|
|
Image(RasterImage),
|
|
/// A text file's text, placed as area type filling the natural box.
|
|
Text(String),
|
|
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 link to a raster image's file (for Link), when read from a path.
|
|
link: Option<LinkInfo>,
|
|
}
|
|
|
|
/// 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);
|
|
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) {
|
|
let text = text::import(&src.bytes, text::TextOptions::parse(p, cmd)?, cmd)?;
|
|
if text.trim().is_empty() {
|
|
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
|
}
|
|
return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None });
|
|
}
|
|
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 opts = fileio::LoadOptions::from_params(cmd, p)?;
|
|
let pdf = matches!(format.id, "pdf" | "ai" | "ait");
|
|
if !pdf && !matches!(opts.crop, CropTo::Crop | CropTo::Bounding) {
|
|
return Err(bad(
|
|
cmd,
|
|
format!("crop `{}`: only PDF pages have art, trim, bleed and media boxes; use \"crop\" or \"bounding\"", opts.crop.id()),
|
|
));
|
|
}
|
|
let crop = opts.crop != CropTo::Bounding;
|
|
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);
|
|
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
|
|
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link });
|
|
}
|
|
let (mut doc, warnings) = match format.id {
|
|
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
|
|
// bounds, below).
|
|
"pdf" | "ai" | "ait" => {
|
|
let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts };
|
|
fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))?
|
|
}
|
|
_ => {
|
|
let mut l = fileio::load(src.name, &src.bytes)?;
|
|
// Linked images (a native document's) show their files, found from its folder.
|
|
super::links::resolve(&mut l.doc, src.path, false);
|
|
(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 {
|
|
// The page placed is a PDF import's only one.
|
|
let index = if pdf { 0 } else { page - 1 };
|
|
let n = doc.artboards.len();
|
|
let board = doc.artboards.get(index).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(index).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, link: None })
|
|
}
|
|
|
|
/// 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<LinkInfo>) -> Node {
|
|
let mut node = match l.art {
|
|
Art::Image(img) => {
|
|
let (sx, sy) = pt_per_px(img.ppi);
|
|
super::links::store_image(d, &img.key, img.blob, link.is_some());
|
|
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::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
|
|
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 (with
|
|
/// `image_xf`); other objects keep their centre.
|
|
fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine {
|
|
if image_xf && 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 old_node = old.and_then(|id| st.doc.node(id));
|
|
// Type reflows rather than scales: its frame takes the size it is placed at.
|
|
let is_text = matches!(loaded.art, Art::Text(_));
|
|
if is_text {
|
|
let size = match (old_node.and_then(Node::geometric_bounds), rect) {
|
|
(Some(b), _) => b.size(),
|
|
(None, Some(r)) => r.size(),
|
|
(None, None) => text_frame(&st.doc, at).size(),
|
|
};
|
|
loaded.natural = Rect::from_origin_size(Point::ORIGIN, size);
|
|
}
|
|
let natural = loaded.natural;
|
|
let m = match (old_node, rect) {
|
|
(Some(o), _) => replace_xf(&st.doc, o, natural, !is_text),
|
|
(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.link.take().filter(|_| bool_or(p, "link", true));
|
|
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 }),
|
|
)
|
|
}
|
|
|
|
/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a
|
|
/// 36 pt margin.
|
|
fn text_frame(d: &Document, at: Option<Point>) -> Rect {
|
|
let board = at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map_or(TEXT_FRAME, |a| a.rect);
|
|
Rect::from_center_size(board.center(), ((board.width() - 72.0).max(72.0), (board.height() - 72.0).max(72.0)))
|
|
}
|
|
|
|
/// A new locked template layer named after the file, right below the layer `below` (else at the
|
|
/// bottom).
|
|
fn template_layer(d: &mut Document, below: Option<NodeId>, name: &str) -> Result<NodeId> {
|
|
let id = d.add_layer(Some(&format!("Template {name}")));
|
|
let at = below.and_then(|b| d.layers.iter().position(|l| l.id == b)).unwrap_or(0);
|
|
d.move_node(id, None, at)?;
|
|
if let Some(n) = d.node_mut(id) {
|
|
n.locked = true;
|
|
if let NodeKind::Layer { template, .. } = &mut n.kind {
|
|
*template = true;
|
|
}
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
// ---------- file.place.queue ----------
|
|
|
|
/// The files `file.place.queue` names, as `file.place` params.
|
|
fn queued_files(p: &Value) -> Result<Vec<Value>> {
|
|
let list = |k: &str| p.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
|
let mut out = vec![];
|
|
for v in list("paths") {
|
|
let path = v.as_str().ok_or_else(|| bad(QUEUE, "paths must be strings"))?;
|
|
out.push(json!({ "path": path }));
|
|
}
|
|
for f in list("files") {
|
|
let (Some(name), Some(data)) = (str_param(f, "name"), str_param(f, "dataBase64")) else {
|
|
return Err(bad(QUEUE, "files must be {name, dataBase64}"));
|
|
};
|
|
out.push(json!({ "name": name, "dataBase64": data }));
|
|
}
|
|
match out.len() {
|
|
0 => Err(bad(QUEUE, "give paths or files")),
|
|
n if n > MAX_QUEUE => Err(bad(QUEUE, format!("at most {MAX_QUEUE} files"))),
|
|
_ => Ok(out),
|
|
}
|
|
}
|
|
|
|
fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let thumbnail = p.get("thumbnail").and_then(Value::as_f64);
|
|
let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]);
|
|
for mut q in queued_files(p)? {
|
|
for k in ["link", "template", "page", "crop", "text"] {
|
|
if let Some(v) = p.get(k) {
|
|
q[k] = v.clone();
|
|
}
|
|
}
|
|
match load(&q, QUEUE) {
|
|
Ok(l) => {
|
|
let (w, h) = (l.natural.width(), l.natural.height());
|
|
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
|
|
entries.push(json!({ "params": q, "name": l.name, "width": w, "height": h }));
|
|
if let Some(px) = thumbnail {
|
|
f["thumbnailBase64"] = json!(thumbnail_png(l, px)?);
|
|
}
|
|
files.push(f);
|
|
}
|
|
Err(e) => {
|
|
let name = str_param(&q, "path").or(str_param(&q, "name")).map(fileio::file_name).unwrap_or_default();
|
|
skipped.push(json!({ "name": name, "error": e.to_string() }));
|
|
}
|
|
}
|
|
}
|
|
if entries.is_empty() {
|
|
let why = skipped.first().and_then(|e| e["error"].as_str()).unwrap_or("nothing to place");
|
|
return Err(bad(QUEUE, why));
|
|
}
|
|
// Reloading the cursor keeps the tool to return to.
|
|
let prev = Some(s.tool_id()).filter(|t| *t != "place");
|
|
let view = s.last_view;
|
|
s.select_tool("place", view)?;
|
|
s.set_tool_option("queue", &json!({ "entries": entries, "prev": prev }));
|
|
Ok(json!({ "count": files.len(), "files": files, "skipped": skipped }))
|
|
}
|
|
|
|
// ---------- queries ----------
|
|
|
|
/// The colour mode an image file stores its pixels in.
|
|
fn color_mode(bytes: &[u8]) -> &'static str {
|
|
use image::{ExtendedColorType as E, ImageDecoder as _};
|
|
let kind = image::ImageReader::new(std::io::Cursor::new(bytes))
|
|
.with_guessed_format()
|
|
.ok()
|
|
.and_then(|r| r.into_decoder().ok())
|
|
.map(|d| d.original_color_type());
|
|
match kind {
|
|
Some(E::L1 | E::La1 | E::L2 | E::La2 | E::L4 | E::La4 | E::L8 | E::La8 | E::L16 | E::La16) => "Grayscale",
|
|
Some(E::Cmyk8 | E::Cmyk16) => "CMYK",
|
|
_ => "RGB",
|
|
}
|
|
}
|
|
|
|
/// `l` rendered as a PNG at most `px` (≤ [`MAX_THUMBNAIL`]) pixels on its longer side, base64.
|
|
fn thumbnail_png(l: Loaded, px: f64) -> Result<String> {
|
|
let natural = l.natural;
|
|
let mut d = Document::new(natural.width().max(1.0), natural.height().max(1.0));
|
|
let layer = d.layers.first().map(|l| l.id);
|
|
let node = build(&mut d, l, None);
|
|
d.insert(layer, 0, node)?;
|
|
let k = px.clamp(1.0, MAX_THUMBNAIL) / natural.width().max(natural.height()).max(1e-9);
|
|
let png = vectorcraft_render::Renderer::new().render_region(&d, natural, k, false).to_png().map_err(EngineError::Other)?;
|
|
Ok(vectorcraft_format::base64_encode(&png))
|
|
}
|
|
|
|
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
|
let mut l = load(p, "file.place.info")?;
|
|
let natural = l.natural;
|
|
let mut out = json!({
|
|
"name": l.name,
|
|
"format": l.format.id,
|
|
"width": natural.width(),
|
|
"height": natural.height(),
|
|
"warnings": std::mem::take(&mut l.warnings),
|
|
});
|
|
if let Art::Image(img) = &l.art {
|
|
let (x, y) = img.ppi.unwrap_or((72.0, 72.0));
|
|
out["pixelWidth"] = json!(img.width);
|
|
out["pixelHeight"] = json!(img.height);
|
|
out["ppi"] = json!([x, y]);
|
|
out["colorMode"] = json!(color_mode(&img.blob.bytes));
|
|
}
|
|
if let Some(px) = p.get("thumbnail").and_then(Value::as_f64) {
|
|
out["thumbnailBase64"] = json!(thumbnail_png(l, px)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "image.info";
|
|
let st = s.doc()?;
|
|
let id = match (id_param(p, "id"), &st.selection.objects[..]) {
|
|
(Some(id), _) => id,
|
|
(None, [id]) => *id,
|
|
_ => return Err(bad(C, "select one image or pass id")),
|
|
};
|
|
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
|
let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) };
|
|
// Effective resolution: 72 pt per inch over the points one pixel spans along each axis.
|
|
let [a, b, c, d, ..] = im.xf.as_coeffs();
|
|
let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l);
|
|
let size = n.geometric_bounds().unwrap_or_default();
|
|
Ok(json!({
|
|
"id": id.0,
|
|
"name": n.display_name(),
|
|
"linked": im.link.is_some(),
|
|
"link": im.link.as_ref().map(|l| &l.path),
|
|
"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(),
|
|
}))
|
|
}
|