mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
M4.63: Links panel with status, Relink, Go To, Update, Edit Original, Embed/Unembed, Link Info and Placement Options
The Links panel lists every image (thumbnail, name, page, missing/modified/embedded badge) with Show filters, sorting and thumbnail sizes; its buttons and flyout relink (to a file or a folder), go to, update, edit the original, embed and unembed, show in folder and open Placement Options. Link Info shows format, colour space, location, ppi and effective ppi, dimensions, scale, rotation, size and dates. Edit > Edit Original and the Properties panel's image section reach the same commands. Engine: links.list, links.goTo, links.embed, links.unembed, links.info and links.placementOptions; links.relink and links.update now place the new file as the image's Placement Options say (transforms, bounds, file dimensions, fit, fill, the alignment point, clip to the old bounds). Model: ImageObject.placement. UI: links.editOriginal, links.reveal, ui.placementOptionsDialog; Services.open_file and pick_folder on the desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
171de62fde
commit
2925e7f9b5
@@ -251,7 +251,8 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let (w, h) = (img.width, img.height);
|
||||
let image = ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None };
|
||||
let image =
|
||||
ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() };
|
||||
let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() };
|
||||
clip.images.insert(img.key, img.blob);
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
|
||||
@@ -280,7 +280,10 @@ fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let mut d = Document::new(width as f64, height as f64);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None }));
|
||||
let mut n = Node::new(
|
||||
id,
|
||||
NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
|
||||
);
|
||||
n.name = Some(name.to_string());
|
||||
d.images.insert(key, blob);
|
||||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
|
||||
@@ -514,6 +514,18 @@ pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
Some((m.len(), modified))
|
||||
}
|
||||
|
||||
/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_created(path: &str) -> Option<u64> {
|
||||
let t = std::fs::metadata(path).ok()?.created().ok()?;
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_created(_: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working directory (as given when absolute already, or when
|
||||
/// that fails).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -1039,7 +1039,10 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
|
||||
fn raster_node(d: &mut Document, r: Raster) -> Node {
|
||||
let key = unique_key(d, "flattened");
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png));
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None }));
|
||||
let image = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() }),
|
||||
);
|
||||
let Some(path) = r.clip else { return image };
|
||||
super::rasterfx::clip_group(d, path, FillRule::NonZero, image)
|
||||
}
|
||||
|
||||
+465
-25
@@ -9,17 +9,23 @@
|
||||
//! Preferences → File Handling → Update Links, and missing ones show their preview.
|
||||
//!
|
||||
//! `links.check` reports, `links.update` reads modified files again, `links.relink` points images
|
||||
//! at other files.
|
||||
//! at other files. A file read again takes the image's place as its Placement Options say
|
||||
//! ([`PlacementOptions`]: keep the bounds or the transforms, fit or fill the bounds…).
|
||||
//!
|
||||
//! The Links panel lists every image (`links.list`) with its Link Info (`links.info`); Go To
|
||||
//! selects one (`links.goTo`), Embed keeps a linked file's pixels in the document
|
||||
//! (`links.embed`) and Unembed writes an embedded image to a file it then links to
|
||||
//! (`links.unembed`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::links::hash_bytes;
|
||||
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacementOptions};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_stamp, read_file};
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -51,6 +57,60 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
cmd!(
|
||||
query "links.list",
|
||||
"Links",
|
||||
[],
|
||||
None,
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview)}], missing, modified, embedded}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
"links.goTo",
|
||||
"Go To Link",
|
||||
[],
|
||||
None,
|
||||
"{id?} select image `id` (default: the first selected image); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}",
|
||||
has_doc,
|
||||
go_to
|
||||
),
|
||||
cmd!(
|
||||
"links.embed",
|
||||
"Embed Image",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: the selected linked images)} keep the linked files' pixels in the document and drop the links (an image whose file can't be found and that shows the saved preview stays linked: relink it first). One undo step → {embedded: [ids], missing: [ids]}",
|
||||
has_doc,
|
||||
embed
|
||||
),
|
||||
cmd!(
|
||||
"links.unembed",
|
||||
"Unembed",
|
||||
[],
|
||||
None,
|
||||
"{id?: an embedded image (default: the one selected), path?} write the image's pixels to the file at path (PNG, JPEG, GIF or WebP as stored, other images as PNG; a path ending in .png/.jpg/.jpeg/.gif/.webp/.tif/.tiff/.bmp converts them) and link the image to it, keeping its bounds. One undo step → {id, path}; no path → {name, dataBase64} (nothing changes: there is no file to link to)",
|
||||
has_doc,
|
||||
unembed
|
||||
),
|
||||
cmd!(
|
||||
query "links.info",
|
||||
"Link Info",
|
||||
[],
|
||||
None,
|
||||
"{id?} an image's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch)",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(
|
||||
"links.placementOptions",
|
||||
"Placement Options",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: the selected images), preserve?: transforms|bounds (default)|fileDimensions|fit|fill, align?: topLeft|top|topRight|left|center (default)|right|bottomLeft|bottom|bottomRight (where the new art sits; not for bounds), clip?: false (clip it to the old bounds where it is larger)} how a file read again (links.relink, links.update) takes each image's place: transforms keeps its scale (relative to each file's 100% size), rotation and position; bounds stretches it into the old bounds; fileDimensions puts it at 100%, unrotated; fit and fill scale it proportionally to fit inside or cover the old bounds. Without options → {placement} of the first image; else one undo step → {ids, placement}",
|
||||
has_doc,
|
||||
placement_options
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -215,19 +275,103 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
|
||||
Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?))
|
||||
}
|
||||
|
||||
/// Image objects `ids` show the file read for `link` (`img`), each keeping its bounds.
|
||||
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead) {
|
||||
store_image(d, &img.key, img.blob, true);
|
||||
/// Points per pixel of the file image `key` of `d` shows, at the resolution it declares (72 ppi
|
||||
/// when it declares none, or when only its preview is loaded).
|
||||
fn file_pt(d: &Document, key: &str) -> (f64, f64) {
|
||||
super::place::pt_per_px(file_ppi(d, key))
|
||||
}
|
||||
|
||||
/// The resolution the file image `key` of `d` shows declares (none while only its preview is
|
||||
/// loaded).
|
||||
fn file_ppi(d: &Document, key: &str) -> Option<(f64, f64)> {
|
||||
d.images.get(key).filter(|b| !b.is_proxy()).and_then(|b| fileio::ppi::resolution(&b.bytes))
|
||||
}
|
||||
|
||||
/// Where the clip of an image goes: the old bounds' frame (bounds space to the document) and size.
|
||||
type Clip = (Affine, (f64, f64));
|
||||
|
||||
/// The transform an image placed with `xf` showing `old` pixels (`old_pt`: points per pixel at
|
||||
/// 100%) gives a new file of `new` pixels (`new_pt`), as Placement Options `o` say; and the clip
|
||||
/// when `o.clip` and the new art overflows the old bounds.
|
||||
fn placed_xf(xf: Affine, old: (u32, u32), old_pt: (f64, f64), new: (u32, u32), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option<Clip>) {
|
||||
let (ow, oh, nw, nh) = (old.0 as f64, old.1 as f64, new.0.max(1) as f64, new.1.max(1) as f64);
|
||||
let [a, b, c, d, ..] = xf.as_coeffs();
|
||||
// The bounds' frame: the transform without its scale (rotation, reflection and position).
|
||||
let (su, sv) = (a.hypot(b), c.hypot(d));
|
||||
if o.preserve == Preserve::Bounds || su < 1e-12 || sv < 1e-12 {
|
||||
return (xf * Affine::scale_non_uniform(ow / nw, oh / nh), None);
|
||||
}
|
||||
let (frame, room) = match o.preserve {
|
||||
Preserve::FileDimensions => {
|
||||
let r = xf.transform_rect_bbox(Rect::new(0.0, 0.0, ow, oh));
|
||||
(Affine::translate(r.origin().to_vec2()), (r.width(), r.height()))
|
||||
}
|
||||
_ => (xf * Affine::scale_non_uniform(1.0 / su, 1.0 / sv), (ow * su, oh * sv)),
|
||||
};
|
||||
// The new file's size at 100% (pt), then as placed.
|
||||
let natural = (nw * new_pt.0, nh * new_pt.1);
|
||||
let size = match o.preserve {
|
||||
Preserve::Transforms => (natural.0 * su / old_pt.0, natural.1 * sv / old_pt.1),
|
||||
Preserve::Fit | Preserve::Fill => {
|
||||
let (kx, ky) = (room.0 / natural.0, room.1 / natural.1);
|
||||
let k = if o.preserve == Preserve::Fit { kx.min(ky) } else { kx.max(ky) };
|
||||
(natural.0 * k, natural.1 * k)
|
||||
}
|
||||
_ => natural,
|
||||
};
|
||||
let (fx, fy) = o.align.fractions();
|
||||
let at = Affine::translate(((room.0 - size.0) * fx, (room.1 - size.1) * fy));
|
||||
let over = size.0 > room.0 + 1e-6 || size.1 > room.1 + 1e-6;
|
||||
(frame * at * Affine::scale_non_uniform(size.0 / nw, size.1 / nh), (o.clip && over).then_some((frame, room)))
|
||||
}
|
||||
|
||||
/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just
|
||||
/// it changes, else the image goes into a new clip group.
|
||||
fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> {
|
||||
let path = shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame);
|
||||
let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind {
|
||||
NodeKind::Group { children, clip: true } => match &children[..] {
|
||||
[c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
if let Some(c) = clip_path {
|
||||
if let Some(Node { kind: NodeKind::Path { path: p, .. }, .. }) = d.node_mut(c) {
|
||||
*p = path;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let image = d.remove(id)?;
|
||||
let none = vectorcraft_color::Paint::None;
|
||||
let mut clip = Node::path(d.alloc_id(), path, Appearance::basic(none.clone(), none, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), image], clip: true });
|
||||
d.insert(par, index, group)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement
|
||||
/// Options say (`keep_bounds`: in its bounds, whatever they say).
|
||||
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, keep_bounds: bool) -> Result<()> {
|
||||
let new_pt = super::place::pt_per_px(img.ppi);
|
||||
let mut clips = vec![];
|
||||
for id in ids {
|
||||
let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue };
|
||||
let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..im.placement } } else { im.placement };
|
||||
let (xf, clip) = placed_xf(im.xf, (im.width, im.height), file_pt(d, &im.key), (img.width, img.height), new_pt, o);
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
if (im.width, im.height) != (img.width, img.height) {
|
||||
im.xf *= Affine::scale_non_uniform(im.width as f64 / img.width as f64, im.height as f64 / img.height as f64);
|
||||
}
|
||||
(im.key, im.width, im.height, im.link) = (img.key.clone(), img.width, img.height, Some(link.clone()));
|
||||
(im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone()));
|
||||
}
|
||||
clips.extend(clip.map(|c| (*id, c)));
|
||||
}
|
||||
store_image(d, &img.key, img.blob, true);
|
||||
clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip))
|
||||
}
|
||||
|
||||
/// Groups as `{name, path, ids}` rows (those with objects in the layers).
|
||||
@@ -266,11 +410,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
|
||||
let row = rows(std::iter::once(&g));
|
||||
match probe(&g, folder(doc_path)) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link) {
|
||||
Ok(read) => {
|
||||
install(d, &g.ids, read);
|
||||
out.updated.extend(row);
|
||||
}
|
||||
(Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link).and_then(|read| install(d, &g.ids, read, false)) {
|
||||
Ok(()) => out.updated.extend(row),
|
||||
Err(_) => out.missing.extend(row),
|
||||
},
|
||||
(Status::Modified, _) => out.modified.extend(row),
|
||||
@@ -407,10 +548,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let updated: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
if !reads.is_empty() {
|
||||
s.edit("Update Links", |d, _| {
|
||||
reads.into_iter().for_each(|(ids, read)| install(d, &ids, read));
|
||||
Ok(())
|
||||
})?;
|
||||
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?;
|
||||
}
|
||||
Ok(json!({ "updated": updated, "missing": missing }))
|
||||
}
|
||||
@@ -466,12 +604,314 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
if !reads.is_empty() {
|
||||
s.edit("Relink", |d, _| {
|
||||
reads.into_iter().for_each(|(ids, read)| install(d, &ids, read));
|
||||
Ok(())
|
||||
})?;
|
||||
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?;
|
||||
}
|
||||
not_found.sort();
|
||||
not_found.dedup();
|
||||
Ok(json!({ "relinked": relinked, "notFound": not_found }))
|
||||
}
|
||||
|
||||
// ---------- the Links panel ----------
|
||||
|
||||
/// Image object `id` of `d`.
|
||||
fn image_of(d: &Document, id: NodeId) -> Option<&ImageObject> {
|
||||
match &d.node(id)?.kind {
|
||||
NodeKind::Image(im) => Some(im),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected image objects of `st` that `keep` accepts.
|
||||
fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) -> Vec<NodeId> {
|
||||
st.selection.objects.iter().copied().filter(|id| image_of(&st.doc, *id).is_some_and(&keep)).collect()
|
||||
}
|
||||
|
||||
/// `ids` (else the selected images `keep` accepts), checked to be images; an error when none.
|
||||
fn images_param(st: &crate::DocState, p: &Value, cmd: &str, keep: impl Fn(&ImageObject) -> bool) -> Result<Vec<NodeId>> {
|
||||
let ids = ids_param(p, "ids").unwrap_or_else(|| selected_images(st, keep));
|
||||
if let Some(id) = ids.iter().find(|id| image_of(&st.doc, **id).is_none()) {
|
||||
return Err(bad(cmd, format!("object {} is not an image", id.0)));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select the images, or pass ids"));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// `id`, else the first selected image.
|
||||
fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
|
||||
match image_of(&st.doc, id) {
|
||||
Some(_) => Ok(id),
|
||||
None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)),
|
||||
None => Err(bad(cmd, format!("object {} is not an image", id.0))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per linked image in the layers of `st`: its file's status, where it was found when away from
|
||||
/// its path, and whether the image shows its preview (each file probed once).
|
||||
fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
|
||||
let mut out = BTreeMap::new();
|
||||
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, folder(st.path.as_deref()));
|
||||
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
|
||||
let preview = previewing(&st.doc, &g.key);
|
||||
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The format of the file image `im` of `d` shows: its link's extension, else its pixels' type.
|
||||
fn format_label(d: &Document, im: &ImageObject) -> &'static str {
|
||||
im.link
|
||||
.as_ref()
|
||||
.and_then(|l| fileio::format_for_name(l.name()))
|
||||
.or_else(|| d.images.get(&im.key).and_then(|b| fileio::FORMATS.iter().find(|f| f.raster && f.mime == b.mime)))
|
||||
.map_or("Image", |f| f.label)
|
||||
}
|
||||
|
||||
/// Sort rank of a `links.list` status: missing, modified, ok, embedded.
|
||||
fn status_rank(status: &str) -> usize {
|
||||
["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.list";
|
||||
let st = s.doc()?;
|
||||
let show = str_param(p, "show").unwrap_or("all");
|
||||
if !matches!(show, "all" | "missing" | "modified" | "embedded") {
|
||||
return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded")));
|
||||
}
|
||||
let statuses = statuses(st);
|
||||
let mut images = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
images.push((n, im));
|
||||
}
|
||||
});
|
||||
// Top first, as the Layers panel lists them.
|
||||
images.reverse();
|
||||
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
let mut rows = vec![];
|
||||
for (n, im) in images {
|
||||
let (status, found, preview) = match statuses.get(&n.id) {
|
||||
Some((s, f, p)) => (s.as_str(), f.as_deref(), *p),
|
||||
None => ("embedded", None, false),
|
||||
};
|
||||
*counts.entry(status).or_default() += 1;
|
||||
if show != "all" && show != status {
|
||||
continue;
|
||||
}
|
||||
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
|
||||
let mut row = json!({
|
||||
"id": n.id.0,
|
||||
"name": name,
|
||||
"linked": im.link.is_some(),
|
||||
"status": status,
|
||||
"format": format_label(&st.doc, im),
|
||||
"pixelWidth": im.width,
|
||||
"pixelHeight": im.height,
|
||||
});
|
||||
if let Some(l) = &im.link {
|
||||
row["path"] = json!(l.path);
|
||||
if let Some(page) = l.page {
|
||||
row["page"] = json!(page);
|
||||
}
|
||||
}
|
||||
if let Some(f) = found {
|
||||
row["found"] = json!(f);
|
||||
}
|
||||
if preview {
|
||||
row["preview"] = json!(true);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase();
|
||||
match str_param(p, "sort") {
|
||||
None => {}
|
||||
Some("name") => rows.sort_by_cached_key(|r| text(r, "name")),
|
||||
Some("kind") => rows.sort_by_cached_key(|r| (text(r, "format"), text(r, "name"))),
|
||||
Some("status") => rows.sort_by_cached_key(|r| (status_rank(r["status"].as_str().unwrap_or_default()), text(r, "name"))),
|
||||
Some(x) => return Err(bad(C, format!("sort `{x}`: name, kind or status"))),
|
||||
}
|
||||
let count = |k: &str| counts.get(k).copied().unwrap_or(0);
|
||||
Ok(json!({ "links": rows, "missing": count("missing"), "modified": count("modified"), "embedded": count("embedded") }))
|
||||
}
|
||||
|
||||
fn go_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = image_param(st, p, "links.goTo")?;
|
||||
let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default();
|
||||
s.select(|_, sel| sel.set([id]))?;
|
||||
Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] }))
|
||||
}
|
||||
|
||||
fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.embed";
|
||||
let st = s.doc()?;
|
||||
let ids = images_param(st, p, C, |im| im.link.is_some())?;
|
||||
// Images showing their preview take their file's pixels first.
|
||||
let (mut done, mut missing, mut reads) = (vec![], vec![], vec![]);
|
||||
for g in groups(&st.doc, Some(&ids)) {
|
||||
if !previewing(&st.doc, &g.key) {
|
||||
done.extend(g.ids);
|
||||
continue;
|
||||
}
|
||||
match probe(&g, folder(st.path.as_deref())) {
|
||||
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) {
|
||||
Ok(read) => {
|
||||
done.extend(&g.ids);
|
||||
reads.push((g.ids, read));
|
||||
}
|
||||
Err(_) => missing.extend(g.ids),
|
||||
},
|
||||
_ => missing.extend(g.ids),
|
||||
}
|
||||
}
|
||||
if !done.is_empty() {
|
||||
s.edit("Embed", |d, _| {
|
||||
for (ids, read) in reads {
|
||||
install(d, &ids, read, true)?;
|
||||
}
|
||||
for id in &done {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
im.link = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "embedded": ids_json(&done), "missing": ids_json(&missing) }))
|
||||
}
|
||||
|
||||
/// The raster formats Unembed writes, by extension.
|
||||
const UNEMBED_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
|
||||
|
||||
/// Encoded image `bytes` re-encoded in the format of extension `ext` (JPEG without alpha).
|
||||
fn convert_image(bytes: &[u8], ext: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
let format = image::ImageFormat::from_extension(ext).ok_or_else(|| format!("can't write .{ext} images"))?;
|
||||
let img = image::load_from_memory(bytes).map_err(|e| e.to_string())?;
|
||||
let img = if format == image::ImageFormat::Jpeg { image::DynamicImage::ImageRgb8(img.to_rgb8()) } else { img };
|
||||
let mut out = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), format).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.unembed";
|
||||
let st = s.doc()?;
|
||||
let id = image_param(st, p, C)?;
|
||||
let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?;
|
||||
let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?;
|
||||
if im.link.is_some() {
|
||||
return Err(bad(C, format!("image {} is linked already", id.0)));
|
||||
}
|
||||
let blob = st.doc.images.get(&im.key).filter(|b| !b.is_proxy()).ok_or_else(|| bad(C, "the image's pixels aren't in the document"))?;
|
||||
// As stored (PNG, JPEG, GIF, WebP), unless the path names another format.
|
||||
let stored = fileio::FORMATS.iter().find(|f| f.raster && f.mime == blob.mime).map_or("png", |f| f.extensions.first().copied().unwrap_or("png"));
|
||||
let path = str_param(p, "path");
|
||||
let ext = match path.map(fileio::extension) {
|
||||
Some(e) if UNEMBED_EXTS.contains(&e.as_str()) => e,
|
||||
Some(e) if !e.is_empty() => return Err(bad(C, format!("can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp"))),
|
||||
_ => stored.to_string(),
|
||||
};
|
||||
let same = fileio::format(&ext).is_some_and(|f| f.mime == blob.mime);
|
||||
let bytes = if same { blob.bytes.to_vec() } else { convert_image(&blob.bytes, &ext).map_err(|e| bad(C, e))? };
|
||||
let Some(path) = path else {
|
||||
let name = n.display_name();
|
||||
let stem = Path::new(&name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned());
|
||||
return Ok(json!({ "name": format!("{stem}.{ext}"), "dataBase64": vectorcraft_format::base64_encode(&bytes) }));
|
||||
};
|
||||
let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() });
|
||||
write_file(&path, &bytes)?;
|
||||
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
|
||||
s.edit("Unembed", |d, _| install(d, &[id], read, true))?;
|
||||
Ok(json!({ "id": id.0, "path": path }))
|
||||
}
|
||||
|
||||
/// Placement Options as `{preserve, align, clip}`.
|
||||
fn placement_json(o: PlacementOptions) -> Value {
|
||||
json!({ "preserve": o.preserve.id(), "align": o.align.id(), "clip": o.clip })
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.info";
|
||||
let id = image_param(s.doc()?, p, C)?;
|
||||
let mut out = super::place::image_info(s, &json!({ "id": id.0 }))?;
|
||||
let st = s.doc()?;
|
||||
let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?;
|
||||
let (px, py) = file_pt(&st.doc, &im.key);
|
||||
let [a, b, c, d, ..] = im.xf.as_coeffs();
|
||||
out["effectivePpi"] = out["ppi"].take();
|
||||
out["ppi"] = json!([72.0 / px, 72.0 / py]);
|
||||
out["scale"] = json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]);
|
||||
// Counter-clockwise on the page (y points down).
|
||||
// Adding 0 turns -0 into 0.
|
||||
out["rotation"] = json!((-b).atan2(a).to_degrees() + 0.0);
|
||||
out["format"] = json!(format_label(&st.doc, im));
|
||||
out["placement"] = placement_json(im.placement);
|
||||
out["status"] = json!("embedded");
|
||||
if let Some(l) = &im.link {
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
|
||||
let stamp = file_stamp(&l.path);
|
||||
let folder_len = l.path.len().saturating_sub(l.name().len());
|
||||
out["status"] = json!(status.as_str());
|
||||
out["path"] = json!(l.path);
|
||||
out["fileName"] = json!(l.name());
|
||||
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
|
||||
for (k, v) in [
|
||||
("page", l.page.map(u64::from)),
|
||||
("fileSize", stamp.map(|s| s.0).or(l.size)),
|
||||
("modified", stamp.and_then(|s| s.1).or(l.modified)),
|
||||
("created", file_created(&l.path)),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
out[k] = json!(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.placementOptions";
|
||||
let st = s.doc()?;
|
||||
let ids = images_param(st, p, C, |_| true)?;
|
||||
let choice = |k: &str| -> Result<Option<&str>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_str().map(Some).ok_or_else(|| bad(C, format!("{k} must be a string"))),
|
||||
}
|
||||
};
|
||||
let unknown = |k: &str, v: &str| bad(C, format!("{k} `{v}` is not one of the choices"));
|
||||
let preserve = choice("preserve")?.map(|v| Preserve::from_id(v).ok_or_else(|| unknown("preserve", v))).transpose()?;
|
||||
let align = choice("align")?.map(|v| Align::from_id(v).ok_or_else(|| unknown("align", v))).transpose()?;
|
||||
let clip = match p.get("clip") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "clip must be true or false"))?),
|
||||
};
|
||||
let first = ids.first().and_then(|id| image_of(&st.doc, *id)).map(|im| im.placement).unwrap_or_default();
|
||||
if preserve.is_none() && align.is_none() && clip.is_none() {
|
||||
return Ok(json!({ "placement": placement_json(first) }));
|
||||
}
|
||||
let set = |o: &mut PlacementOptions| {
|
||||
o.preserve = preserve.unwrap_or(o.preserve);
|
||||
o.align = align.unwrap_or(o.align);
|
||||
o.clip = clip.unwrap_or(o.clip);
|
||||
};
|
||||
s.edit("Placement Options", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
set(&mut im.placement);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let mut shown = first;
|
||||
set(&mut shown);
|
||||
Ok(json!({ "ids": ids_json(&ids), "placement": placement_json(shown) }))
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
|
||||
let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }));
|
||||
if let Some((path, rule)) = clip {
|
||||
node = super::rasterfx::clip_group(d, path, rule, node);
|
||||
}
|
||||
@@ -555,6 +555,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
height: h,
|
||||
xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -213,7 +213,14 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
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 };
|
||||
let im = ImageObject {
|
||||
key: img.key,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
xf: Affine::scale_non_uniform(sx, sy),
|
||||
link,
|
||||
placement: Default::default(),
|
||||
};
|
||||
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)))),
|
||||
@@ -480,7 +487,7 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) 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[..]) {
|
||||
|
||||
@@ -195,7 +195,8 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
||||
}
|
||||
out.images.insert(key.clone(), ImageBlob::new("image/png", png));
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut image = Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
|
||||
let mut image =
|
||||
Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }));
|
||||
image.name = Some("Raster effect".into());
|
||||
Some(image)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user