Merge pull request #96 from storytold/pr/m4-links-package

M4.63–M4.64: Links panel, Placement Options and Package
This commit is contained in:
BFlat 2026-10-05 12:49:30 +07:00 committed by GitHub
commit d767fa22e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 3029 additions and 166 deletions

View File

@ -173,10 +173,31 @@ fn services() -> Services {
let _ = webbrowser::open(url);
})),
reveal: Some(Box::new(reveal)),
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
..Default::default()
}
}
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
fn open_file(path: &str) -> Result<(), String> {
#[cfg(windows)]
let mut c = {
use std::os::windows::process::CommandExt as _;
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("\"{}\"", path.replace('/', "\\")));
c
};
#[cfg(target_os = "macos")]
let mut c = std::process::Command::new("open");
#[cfg(not(any(target_os = "macos", windows)))]
let mut c = std::process::Command::new("xdg-open");
#[cfg(not(windows))]
c.arg(path);
c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
}
/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets
/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches
/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon.

View File

@ -328,7 +328,14 @@ fn images_link_png_or_jpeg_files() {
let mut png = vec![];
image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 128])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = doc_with(|d| {
let im = ImageObject { key: "raster-1".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), link: None };
let im = ImageObject {
key: "raster-1".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
};
vec![Node::new(d.alloc_id(), NodeKind::Image(im))]
});
d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });

View File

@ -48,7 +48,7 @@ pub use appearance::{
};
pub use graph::{GraphKind, GraphSpec};
pub use hit::{Hit, HitKind};
pub use links::LinkInfo;
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use metadata::{CopyrightStatus, DocMetadata};
pub use node::Knockout;

View File

@ -156,6 +156,136 @@ impl Document {
}
}
/// Links panel → Placement Options: how a file read again (Relink, Update Link) takes the place of
/// the art it replaces.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlacementOptions {
#[serde(default)]
pub preserve: Preserve,
/// Where the new art sits in the old bounds (all but [`Preserve::Bounds`]).
#[serde(default)]
pub align: Align,
/// Clip the new art to the old bounds where it is larger.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub clip: bool,
}
/// What a relinked image keeps of the art it replaces.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Preserve {
/// Its scale (relative to each file's 100% size), rotation and position.
Transforms,
/// Its bounds: the new art is stretched into them.
#[default]
Bounds,
/// Nothing but its position: the new art at 100%, unrotated.
FileDimensions,
/// Its bounds: the new art scaled proportionally to fit inside them.
Fit,
/// Its bounds: the new art scaled proportionally to fill them.
Fill,
}
impl Preserve {
pub const ALL: [Preserve; 5] = [Preserve::Transforms, Preserve::Bounds, Preserve::FileDimensions, Preserve::Fit, Preserve::Fill];
/// The id commands use (`transforms`, `bounds`, `fileDimensions`, `fit`, `fill`).
pub fn id(self) -> &'static str {
match self {
Preserve::Transforms => "transforms",
Preserve::Bounds => "bounds",
Preserve::FileDimensions => "fileDimensions",
Preserve::Fit => "fit",
Preserve::Fill => "fill",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.id() == id)
}
}
/// A point of the 3×3 alignment grid, row by row from the top left.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Align {
TopLeft,
Top,
TopRight,
Left,
#[default]
Center,
Right,
BottomLeft,
Bottom,
BottomRight,
}
impl Align {
pub const ALL: [Align; 9] =
[Align::TopLeft, Align::Top, Align::TopRight, Align::Left, Align::Center, Align::Right, Align::BottomLeft, Align::Bottom, Align::BottomRight];
/// The id commands use (`topLeft` … `bottomRight`).
pub fn id(self) -> &'static str {
match self {
Align::TopLeft => "topLeft",
Align::Top => "top",
Align::TopRight => "topRight",
Align::Left => "left",
Align::Center => "center",
Align::Right => "right",
Align::BottomLeft => "bottomLeft",
Align::Bottom => "bottom",
Align::BottomRight => "bottomRight",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.id() == id)
}
/// The fractions (0, ½ or 1) of the free room left of and above the new art.
pub fn fractions(self) -> (f64, f64) {
let i = Self::ALL.iter().position(|a| *a == self).unwrap_or(4);
((i % 3) as f64 / 2.0, (i / 3) as f64 / 2.0)
}
}
impl Document {
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
/// art included) for which `pick` holds; only the subtrees holding one are copied.
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
fn holds(n: &Node, pick: &dyn Fn(&ImageObject) -> bool) -> bool {
let mut hit = false;
walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::Image(im) if pick(im)));
hit
}
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&ImageObject) -> bool, f: &mut dyn FnMut(&mut ImageObject)) {
if !holds(n, pick) {
return;
}
let n = Arc::make_mut(n);
if let NodeKind::Image(im) = &mut n.kind
&& pick(im)
{
f(im);
}
if let Some(m) = &mut n.mask {
update(&mut m.art, pick, f);
}
for c in n.children_mut().into_iter().flatten() {
update(c, pick, f);
}
}
let roots =
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
update(root, &pick, &mut f);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -171,8 +301,15 @@ mod tests {
fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) -> NodeId {
let id = d.alloc_id();
let layer = d.layers[0].id;
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link })))
.unwrap()
d.insert(
Some(layer),
0,
Node::new(
id,
NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }),
),
)
.unwrap()
}
#[test]

View File

@ -204,6 +204,9 @@ pub struct ImageObject {
/// before links had details hold just the path.
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")]
pub link: Option<crate::LinkInfo>,
/// Links panel → Placement Options: how a file read again takes this image's place.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub placement: crate::links::PlacementOptions,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

View File

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

View File

@ -1,4 +1,5 @@
//! Document Info panel data and Object → Make Pixel Perfect.
//! Document Info panel data, its categories and the text report (Document Info › Save…, File →
//! Package), and Object → Make Pixel Perfect.
use std::collections::{BTreeMap, BTreeSet};
@ -16,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Info",
["Window", "Document Info"],
None,
"{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …}",
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
has_doc,
info
),
@ -32,6 +33,160 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Document Info's categories: (id, title), in the order the panel and the report show them.
pub const CATEGORIES: [(&str, &str); 11] = [
("document", "Document"),
("objects", "Objects"),
("graphicStyles", "Graphic Styles"),
("spotColors", "Spot Colors"),
("patterns", "Pattern Objects"),
("gradients", "Gradient Swatches"),
("symbols", "Symbols"),
("fonts", "Fonts"),
("fontDetails", "Font Details"),
("linkedImages", "Linked Images"),
("embeddedImages", "Embedded Images"),
];
/// The object counts of `document.info` and their labels.
const OBJECT_LABELS: [(&str, &str); 16] = [
("paths", "Paths"),
("compoundPaths", "Compound Paths"),
("groups", "Groups"),
("clipGroups", "Clipping Masks"),
("textObjects", "Text Objects"),
("images", "Images"),
("symbolInstances", "Symbol Instances"),
("gradients", "Gradient Objects"),
("patterns", "Pattern Objects"),
("meshes", "Gradient Meshes"),
("blends", "Blends"),
("envelopes", "Envelopes"),
("repeats", "Repeats"),
("opacityMasks", "Opacity Masks"),
("liveEffects", "Objects with Effects"),
("guides", "Guides"),
];
/// One category of Document Info: its id, title and `(label, value)` rows (a list item has an
/// empty value).
pub struct Section {
pub id: &'static str,
pub title: &'static str,
pub rows: Vec<(String, String)>,
}
/// What a font's `fsType` lets a copy of it do.
pub fn embedding_label(fs_type: u16) -> &'static str {
match fs_type & 0x000f {
0x0002 => "embedding not allowed",
0x0004 => "embedding for preview and print",
0x0008 => "embedding for editing",
_ => "embedding allowed",
}
}
/// An image object as Document Info lists it: (name, width, height, linked file).
type ImageRow = (String, u32, u32, Option<String>);
/// The Document Info categories of `d` (only `category` when given) from `info` (what
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
let names = |k: &str| -> Vec<(String, String)> {
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
};
let u = d.units;
CATEGORIES
.iter()
.filter(|(id, _)| category.is_none_or(|c| c == *id))
.map(|&(id, title)| {
let rows = match id {
"document" => {
let mut rows = vec![
("Name".to_string(), info["document"]["name"].as_str().unwrap_or_default().to_string()),
("Color Mode".into(), info["document"]["colorMode"].as_str().unwrap_or_default().into()),
("Units".into(), u.label().into()),
("Artboards".into(), d.artboards.len().to_string()),
];
rows.extend(
d.artboards.iter().map(|a| (a.name.clone(), format!("{} × {}", u.format(a.rect.width()), u.format(a.rect.height())))),
);
rows.push(("Raster Effects".into(), format!("{} ppi", d.raster_effects_ppi)));
let counts = [
("Swatches", "swatches"),
("Character Styles", "characterStyles"),
("Paragraph Styles", "paragraphStyles"),
("Pattern Swatches", "patterns"),
];
rows.extend(counts.map(|(label, k)| (label.to_string(), info[k].to_string())));
rows
}
"objects" => {
OBJECT_LABELS.iter().filter_map(|(k, label)| Some((label.to_string(), info["objects"][k].as_u64()?.to_string()))).collect()
}
"graphicStyles" => names("graphicStyleNames"),
"spotColors" => names("spotColors"),
"patterns" => names("patternNames"),
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
"symbols" => names("symbols"),
"fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(),
"fontDetails" => {
let db = vectorcraft_text::FontDb::global();
fonts
.iter()
.map(|(family, style)| {
let detail = match db.face(family, style) {
Some(f) if f.family.eq_ignore_ascii_case(family) => {
let file =
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
format!("{file}; {}", embedding_label(f.fs_type()))
}
Some(f) => format!("missing: shown in {} {}", f.family, f.style),
None => "missing".into(),
};
(format!("{family} {style}"), detail)
})
.collect()
}
"linkedImages" => {
images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect()
}
_ => images.iter().filter(|i| i.3.is_none()).map(|(name, w, h, _)| (name.clone(), format!("{w} × {h} px"))).collect(),
};
Section { id, title, rows }
})
.collect()
}
/// `sections` as the plain-text report of document `name`: a heading, then each category's title
/// and rows.
pub fn report_text(name: &str, selection_only: bool, sections: &[Section]) -> String {
let mut out = format!("Document Info: {name}\n");
if selection_only {
out.push_str("(the selection only)\n");
}
for sec in sections {
out.push_str(&format!("\n{}\n", sec.title.to_uppercase()));
if sec.rows.is_empty() {
out.push_str("None\n");
}
for (label, value) in &sec.rows {
if value.is_empty() {
out.push_str(&format!("{label}\n"));
} else {
out.push_str(&format!("{label}: {value}\n"));
}
}
}
out
}
/// The text report of the active document (`document.info {format: "text"}` as a string).
pub fn report(s: &mut Session, selection_only: bool) -> Result<String> {
let v = info(s, &json!({ "selectionOnly": selection_only, "format": "text" }))?;
Ok(v["text"].as_str().unwrap_or_default().to_string())
}
fn paint_kind(p: &Paint) -> Option<&'static str> {
match p {
Paint::Gradient(_) => Some("gradients"),
@ -41,6 +196,17 @@ fn paint_kind(p: &Paint) -> Option<&'static str> {
}
fn info(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.info";
let category = str_param(p, "category");
if let Some(c) = category.filter(|c| !CATEGORIES.iter().any(|(id, _)| id == c)) {
let ids: Vec<&str> = CATEGORIES.iter().map(|(id, _)| *id).collect();
return Err(bad(C, format!("category `{c}`: one of {}", ids.join(", "))));
}
let text = match str_param(p, "format") {
None | Some("json") => false,
Some("text") => true,
Some(f) => return Err(bad(C, format!("format `{f}`: text or json"))),
};
let st = s.doc()?;
let d = &st.doc;
let selection_only = bool_or(p, "selectionOnly", false);
@ -49,6 +215,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
let mut fonts = BTreeSet::new();
let mut images: BTreeMap<String, Value> = BTreeMap::new();
let mut image_rows: Vec<ImageRow> = vec![];
let mut patterns = BTreeSet::new();
let mut symbols = BTreeSet::new();
let mut spot = BTreeSet::new();
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
@ -63,7 +231,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
NodeKind::Compound { .. } => Some("compoundPaths"),
NodeKind::Text(t) => {
for run in &t.runs {
fonts.insert(format!("{} {}", run.style.font_family, run.style.font_style));
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone()));
}
Some("textObjects")
}
@ -72,6 +240,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
im.key.clone(),
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
);
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone())));
Some("images")
}
NodeKind::SymbolInstance { symbol, .. } => {
@ -94,6 +264,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(k) = paint_kind(paint) {
*counts.entry(k).or_default() += 1;
}
if let Paint::Pattern { pattern, .. } = paint {
patterns.insert(pattern.clone());
}
if let Paint::Solid { swatch: Some(name), .. } = paint
&& d.swatch(name).is_some_and(|s| s.spot)
{
@ -114,10 +287,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
}
});
}
Ok(json!({
let font_names: Vec<String> = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect();
let mut out = json!({
"document": document_summary(d),
"objects": counts,
"fonts": fonts,
"fonts": font_names,
"images": images,
"symbols": symbols,
"spotColors": spot,
@ -127,7 +301,16 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"characterStyles": d.char_styles.len(),
"paragraphStyles": d.para_styles.len(),
"patterns": d.patterns.len(),
}))
"patternNames": patterns,
});
// The document's name: its file's, else its title.
out["document"]["name"] = json!(st.title());
let sections = sections(d, &out, &fonts, &image_rows, category);
if text {
return Ok(json!({ "text": report_text(&st.title(), selection_only, &sections) }));
}
out["sections"] = sections.iter().map(|s| json!({ "id": s.id, "title": s.title, "rows": s.rows })).collect();
Ok(out)
}
fn document_summary(d: &Document) -> Value {
@ -205,6 +388,14 @@ mod tests {
assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1);
}
#[test]
fn embedding_permissions_read_from_fs_type() {
assert_eq!(embedding_label(0), "embedding allowed");
assert_eq!(embedding_label(0x0002), "embedding not allowed");
assert_eq!(embedding_label(0x0004 | 0x0100), "embedding for preview and print");
assert_eq!(embedding_label(0x0008), "embedding for editing");
}
#[test]
fn pixel_perfect_snaps_edges_and_strokes() {
let mut s = Session::new();

View File

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

View File

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

View File

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

View File

@ -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
),
]
}
@ -194,6 +254,32 @@ fn previewing(d: &Document, key: &str) -> bool {
d.images.get(key).is_none_or(ImageBlob::is_proxy)
}
/// A linked file of a document (File → Package): its path, name and bytes.
pub(crate) struct LinkedFile {
pub path: String,
pub name: String,
/// Read from where the file is found, else the pixels the document holds when they are the
/// file's own; `None` when neither.
pub bytes: Option<Vec<u8>>,
}
/// Every file the linked images of `d` (saved at `doc_path`) show, in the layers, symbols and
/// patterns, once each.
pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFile> {
let mut seen = std::collections::BTreeSet::new();
groups(d, None)
.into_iter()
.filter(|g| seen.insert(g.link.path.clone()))
.map(|g| {
let own = |b: &&ImageBlob| !b.is_proxy() && fileio::format_for_name(g.link.name()).is_some_and(|f| f.mime == b.mime);
let bytes = locate(&g.link, folder(doc_path))
.and_then(|mut f| f.read().ok())
.or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), bytes }
})
.collect()
}
// ---------- reading files into the document ----------
/// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a
@ -215,19 +301,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 +436,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 +574,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 +630,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) }))
}

View File

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

View File

@ -35,6 +35,7 @@ pub mod newdoc;
mod object;
mod opacitymask;
mod overprint;
mod package;
mod paint;
mod panelcmds;
mod path;
@ -223,6 +224,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(links::specs());
v.extend(pdfcmds::specs());
v.extend(fileio::dxf::specs());
v.extend(package::specs());
v
})
}

View File

@ -0,0 +1,265 @@
//! File → Package: a saved document copied into a folder of its own with the files its linked
//! images show (`Links/`), the fonts its type uses (`Fonts/`, leaving out fonts whose licence
//! doesn't allow embedding) and a text report (the package's contents, then the Document Info
//! report). The packaged document links to the copies (Relink); the open document doesn't change.
//! Without a folder (the web, agents) the same files come back as a zip archive.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io::Write;
use std::path::Path;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeKind};
use super::fileio::{create_dir, write_file};
use super::*;
const C: &str = "file.package";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"file.package",
"Package",
[],
None,
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
has_doc,
package
)]
}
/// A file of the package: its path in the package folder (`/` separators) and bytes.
type Entry = (String, Vec<u8>);
/// `name`, else the first free `stem 2.ext`, `stem 3.ext`… (compared without case, as most
/// desktop file systems do); the name is taken.
fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
let p = Path::new(name);
let stem = p.file_stem().map_or_else(|| name.to_string(), |s| s.to_string_lossy().into_owned());
let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
let out = unique_name(&stem, |s| taken.contains(&format!("{s}{ext}").to_lowercase())) + &ext;
taken.insert(out.to_lowercase());
out
}
/// A font file's extension from its first bytes.
fn font_ext(bytes: &[u8]) -> &'static str {
match bytes.get(..4) {
Some(b"OTTO") => "otf",
Some(b"ttcf") => "ttc",
_ => "ttf",
}
}
/// The fonts (family, style) of the type in the layers and symbols of `d`.
fn used_fonts(d: &Document) -> BTreeSet<(String, String)> {
let mut fonts = BTreeSet::new();
let mut add = |n: &Node| {
if let NodeKind::Text(t) = &n.kind {
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
}
};
d.walk(&mut add);
for s in &d.symbols {
s.art.walk(&mut add);
}
fonts
}
/// The options of a package.
struct Options {
copy_links: bool,
links_folder: bool,
relink: bool,
copy_fonts: bool,
report: bool,
}
fn package(s: &mut Session, p: &Value) -> Result<Value> {
let o = Options {
copy_links: bool_or(p, "copyLinks", true),
links_folder: bool_or(p, "linksFolder", true),
relink: bool_or(p, "relink", true),
copy_fonts: bool_or(p, "copyFonts", true),
report: bool_or(p, "report", true),
};
let doc_path = s.doc()?.path.clone().ok_or_else(|| bad(C, "save the document first: Package collects a saved document"))?;
let stem = Path::new(&doc_path).file_stem().map_or_else(|| "Untitled".into(), |s| s.to_string_lossy().into_owned());
let name = str_param(p, "name").map_or_else(|| format!("{stem} Folder"), str::to_string);
if name.trim().is_empty() || name == "." || name == ".." || name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
return Err(bad(C, format!("`{name}` can't name a folder")));
}
let folder = str_param(p, "folder");
let root = folder.map(|f| Path::new(f).join(&name));
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
let st = s.doc()?;
let mut entries: Vec<Entry> = vec![];
let mut taken = HashSet::new();
let mut lines = vec![];
// The linked files.
let (mut copies, mut missing) = (BTreeMap::new(), vec![]);
if o.copy_links {
let dir = if o.links_folder { "Links/" } else { "" };
let mut names = HashSet::new();
lines.push("LINKED FILES".to_string());
for f in super::links::linked_files(&st.doc, Some(&doc_path)) {
let Some(bytes) = f.bytes else {
lines.push(format!("{}: not found ({}), not copied", f.name, f.path));
missing.push(f.name);
continue;
};
let rel = format!("{dir}{}", free_name(&f.name, if o.links_folder { &mut names } else { &mut taken }));
lines.push(format!("{} → {rel}", f.path));
copies.insert(f.path, rel.clone());
entries.push((rel, bytes));
}
lines.push(String::new());
}
// The fonts.
let (mut fonts, mut skipped) = (0, vec![]);
if o.copy_fonts {
let db = vectorcraft_text::FontDb::global();
let mut files = HashSet::new();
lines.push("FONTS".to_string());
for (family, style) in used_fonts(&st.doc) {
let font = format!("{family} {style}");
let reason = match db.face(&family, &style) {
Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => {
let data = f.file_data();
let file = f.path().and_then(Path::file_name).map_or_else(
|| format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""),
|n| n.to_string_lossy().into_owned(),
);
// A collection, or a style shown in another face's file, is copied once.
if files.insert(file.to_lowercase()) {
entries.push((format!("Fonts/{file}"), data.to_vec()));
fonts += 1;
}
lines.push(format!("{font} → Fonts/{file}"));
continue;
}
Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding",
_ => "not available on this computer",
};
lines.push(format!("{font}: {reason}, not copied"));
skipped.push(json!({ "font": font, "reason": reason }));
}
lines.push(String::new());
}
// The document, linking to the copies.
let mut doc = (*st.doc).clone();
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
if let Some(root) = &root
&& let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy())
{
doc = d;
}
if o.relink && !copies.is_empty() {
let copied = |im: &vectorcraft_doc::ImageObject| im.link.as_ref().is_some_and(|l| copies.contains_key(&l.path));
doc.update_images(copied, |im| {
if let Some(l) = &mut im.link
&& let Some(rel) = copies.get(&l.path)
{
l.path = root.as_ref().map_or_else(|| rel.clone(), |r| r.join(rel).to_string_lossy().into_owned());
(l.relative, l.modified) = (Some(rel.clone()), None);
}
});
}
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
taken.insert(doc_name.to_lowercase());
// The report.
if let Some(info) = info {
let report_name = free_name(&format!("{stem} Report.txt"), &mut taken);
let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n");
lines.iter().for_each(|l| text.push_str(&format!("{l}\n")));
text.push_str(&info);
entries.push((report_name, text.into_bytes()));
}
let files: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect();
let mut out = json!({ "files": files, "links": copies.len(), "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped });
match root {
Some(root) => {
for (rel, bytes) in &entries {
let path = root.join(rel);
if let Some(dir) = path.parent() {
create_dir(&dir.to_string_lossy())?;
}
write_file(&path.to_string_lossy(), bytes)?;
}
out["folder"] = json!(root.to_string_lossy());
}
None => {
let named: Vec<(String, &[u8])> = entries.iter().map(|(rel, b)| (format!("{name}/{rel}"), b.as_slice())).collect();
let zip = zip(&named)?;
out["name"] = json!(format!("{name}.zip"));
out["bytes"] = json!(zip.len());
out["dataBase64"] = json!(vectorcraft_format::base64_encode(&zip));
}
}
Ok(out)
}
// ---------- zip archives ----------
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
fn dos_time() -> (u16, u16) {
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
}
/// A zip archive of `files` ((path with `/` separators, bytes)), each deflated, or stored when
/// that isn't smaller.
pub(crate) fn zip(files: &[(String, &[u8])]) -> Result<Vec<u8>> {
let too_big = || bad(C, "too large for a zip archive: package to a folder");
let io = |e: std::io::Error| EngineError::Other(e.to_string());
let (time, date) = dos_time();
let (mut out, mut central) = (Vec::new(), Vec::new());
for (name, bytes) in files {
let mut crc = flate2::Crc::new();
crc.update(bytes);
let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(bytes).map_err(io)?;
let deflated = enc.finish().map_err(io)?;
let (method, data): (u16, &[u8]) = if deflated.len() < bytes.len() { (8, &deflated) } else { (0, bytes) };
let offset = u32::try_from(out.len()).map_err(|_| too_big())?;
let (packed, size) = (u32::try_from(data.len()).map_err(|_| too_big())?, u32::try_from(bytes.len()).map_err(|_| too_big())?);
let name_len = u16::try_from(name.len()).map_err(|_| too_big())?;
// Version 2.0, UTF-8 names, method, time, date, CRC-32, sizes, name length, no extra field.
let common = [&20u16.to_le_bytes()[..], &0x0800u16.to_le_bytes(), &method.to_le_bytes(), &time.to_le_bytes(), &date.to_le_bytes()].concat();
let sums = [crc.sum().to_le_bytes(), packed.to_le_bytes(), size.to_le_bytes()].concat();
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&common);
out.extend_from_slice(&sums);
out.extend_from_slice(&name_len.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(data);
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes());
central.extend_from_slice(&common);
central.extend_from_slice(&sums);
central.extend_from_slice(&name_len.to_le_bytes());
// No extra field or comment, disk 0, no attributes.
central.extend_from_slice(&[0; 12]);
central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let count = u16::try_from(files.len()).map_err(|_| too_big())?;
let (start, len) = (u32::try_from(out.len()).map_err(|_| too_big())?, u32::try_from(central.len()).map_err(|_| too_big())?);
out.extend_from_slice(&central);
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0; 4]);
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
Ok(out)
}

View File

@ -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[..]) {

View File

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

View File

@ -1060,6 +1060,8 @@ mod tests_linked_stops;
#[cfg(test)]
mod tests_links;
#[cfg(test)]
mod tests_linkspanel;
#[cfg(test)]
mod tests_live;
#[cfg(test)]
mod tests_maskview;
@ -1080,6 +1082,8 @@ mod tests_outlinestroke;
#[cfg(test)]
mod tests_overprint;
#[cfg(test)]
mod tests_package;
#[cfg(test)]
mod tests_paintproxy;
#[cfg(test)]
mod tests_panelcmds;

View File

@ -328,7 +328,11 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node
d.images.insert("img1".into(), ImageBlob::new("image/png", png));
let id = d.alloc_id();
let l = d.default_layer();
d.insert(l, 0, Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None })))?;
d.insert(
l,
0,
Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })),
)?;
sel.set([id]);
Ok(id)
})

View File

@ -137,7 +137,14 @@ fn image_node(s: &mut Session) -> NodeId {
s.edit("Place", |d, sel| {
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(bytes));
let id = d.alloc_id();
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 2, height: 2, xf: Default::default(), link: None };
let im = vectorcraft_doc::ImageObject {
key: "pic".into(),
width: 2,
height: 2,
xf: Default::default(),
link: None,
placement: Default::default(),
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?;
sel.set([id]);
Ok(id)

View File

@ -157,7 +157,7 @@ fn clicking_an_image_samples_its_pixel() {
let l = d.layers[0].id;
let id = d.alloc_id();
let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0);
let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None };
let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() };
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).map_err(|e| crate::EngineError::Other(e.to_string()))
})
.unwrap();

View File

@ -11,16 +11,16 @@ use vectorcraft_geom::Rect;
use super::*;
/// A fresh folder for one test (removed when dropped).
struct Folder(PathBuf);
pub(crate) struct Folder(pub(crate) PathBuf);
impl Folder {
fn new(name: &str) -> Self {
pub(crate) fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("vectorcraft-links-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn file(&self, name: &str) -> String {
pub(crate) fn file(&self, name: &str) -> String {
self.0.join(name).to_string_lossy().into_owned()
}
}
@ -32,55 +32,55 @@ impl Drop for Folder {
}
/// A `w`×`h` PNG of one colour.
fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
pub(crate) fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let mut out = vec![];
let [r, g, b] = rgb;
image::RgbaImage::from_pixel(w, h, image::Rgba([r, g, b, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
const RED: [u8; 3] = [230, 20, 20];
const BLUE: [u8; 3] = [20, 20, 230];
pub(crate) const RED: [u8; 3] = [230, 20, 20];
pub(crate) const BLUE: [u8; 3] = [20, 20, 230];
fn write(path: &str, bytes: &[u8]) {
pub(crate) fn write(path: &str, bytes: &[u8]) {
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(path, bytes).unwrap();
}
fn session() -> Session {
pub(crate) fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// Place the file at `path` (linked) → its id.
fn place(s: &mut Session, path: &str) -> NodeId {
pub(crate) fn place(s: &mut Session, path: &str) -> NodeId {
let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
NodeId(r["ids"][0].as_u64().unwrap())
}
fn image(s: &Session, id: NodeId) -> ImageObject {
pub(crate) fn image(s: &Session, id: NodeId) -> ImageObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Image(im) => im.clone(),
k => panic!("not an image: {k:?}"),
}
}
fn bounds(s: &Session, id: NodeId) -> Rect {
pub(crate) fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
/// The colour at the centre of the active document's artboard.
fn centre_colour(doc: &Document) -> [u8; 3] {
pub(crate) fn centre_colour(doc: &Document) -> [u8; 3] {
let img = vectorcraft_render::Renderer::new().render_region(doc, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true);
let [r, g, b, _] = img.pixel(200, 150);
[r, g, b]
}
fn near(a: [u8; 3], b: [u8; 3]) -> bool {
pub(crate) fn near(a: [u8; 3], b: [u8; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 6)
}
@ -89,11 +89,11 @@ fn saved_image(v: &mut Value) -> &mut Value {
&mut v["document"]["layers"][0]["kind"]["children"][0]["kind"]
}
fn save(s: &mut Session, path: &str) {
pub(crate) fn save(s: &mut Session, path: &str) {
s.execute("document.save", &json!({"path": path})).unwrap();
}
fn open(s: &mut Session, path: &str) -> Value {
pub(crate) fn open(s: &mut Session, path: &str) -> Value {
s.execute("document.open", &json!({"path": path})).unwrap()
}
@ -285,8 +285,14 @@ fn relative_paths_are_written_against_the_saved_file() {
let (lid, nid) = (d.layers[0].id, d.alloc_id());
let root = if cfg!(windows) { "C:\\work" } else { "/work" };
let sep = std::path::MAIN_SEPARATOR;
let im =
ImageObject { key: "k".into(), width: 1, height: 1, xf: Default::default(), link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))) };
let im = ImageObject {
key: "k".into(),
width: 1,
height: 1,
xf: Default::default(),
link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))),
placement: Default::default(),
};
d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(im))).unwrap();
let rel = |dest: String| {
let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone());

View File

@ -0,0 +1,284 @@
//! The Links panel's commands: listing images with their status (`links.list`), Go To, Embed and
//! Unembed, Link Info and the Placement Options a relinked file follows.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeKind, PlacementOptions};
use super::tests_links::{BLUE, Folder, RED, bounds, centre_colour, image, near, open, place, png, save, session, write};
use super::*;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// Place the file at `path` embedded → its id.
fn place_embedded(s: &mut Session, path: &str) -> NodeId {
let r = run(s, "file.place", json!({"path": path, "at": [200, 150], "link": false}));
NodeId(r["ids"][0].as_u64().unwrap())
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn embed_keeps_the_pixels_without_the_file() {
let dir = Folder::new("embed");
let pic = dir.file("photo.png");
let bytes = png(600, 300, RED);
write(&pic, &bytes);
let mut s = session();
let id = place(&mut s, &pic);
let placed = bounds(&s, id);
run(&mut s, "select.set", json!({"ids": []}));
assert!(s.execute("links.embed", &json!({})).is_err(), "nothing selected");
run(&mut s, "select.set", json!({"ids": [id.0]}));
assert_eq!(run(&mut s, "links.embed", json!({})), json!({"embedded": [id.0], "missing": []}));
let im = image(&s, id);
assert!(im.link.is_none());
assert_eq!(s.doc().unwrap().doc.images[&im.key].bytes.as_slice(), bytes.as_slice(), "the file's own bytes");
assert_eq!(bounds(&s, id), placed);
run(&mut s, "edit.undo", json!({}));
assert!(image(&s, id).link.is_some(), "one undo step");
run(&mut s, "edit.redo", json!({}));
// Saved and reopened without the file: the full pixels, nothing missing.
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
std::fs::remove_file(&pic).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!(r["missingLinks"], json!([]), "{r}");
let im = image(&s, id);
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy());
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn a_missing_file_showing_its_preview_stays_linked() {
let dir = Folder::new("embed-missing");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
std::fs::remove_file(&pic).unwrap();
open(&mut s, &doc_path);
assert_eq!(run(&mut s, "links.embed", json!({"ids": [id.0]})), json!({"embedded": [], "missing": [id.0]}));
assert!(image(&s, id).link.is_some());
assert!(s.doc().unwrap().history.undo.is_empty(), "nothing changed");
}
#[test]
fn unembed_writes_the_bytes_and_links_to_them() {
let dir = Folder::new("unembed");
let pic = dir.file("photo.png");
let bytes = png(600, 300, RED);
write(&pic, &bytes);
let mut s = session();
let id = place_embedded(&mut s, &pic);
let placed = bounds(&s, id);
// No path: the file's bytes come back and nothing changes.
let r = run(&mut s, "links.unembed", json!({"id": id.0}));
assert_eq!(r["name"], "photo.png");
assert_eq!(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), bytes);
assert!(image(&s, id).link.is_none());
// To a path: written there, and the image links to it.
let out = dir.file("out/photo.png");
std::fs::create_dir_all(dir.file("out")).unwrap();
let r = run(&mut s, "links.unembed", json!({"id": id.0, "path": out}));
assert_eq!(r, json!({"id": id.0, "path": out}));
assert_eq!(std::fs::read(&out).unwrap(), bytes);
let link = image(&s, id).link.unwrap();
assert_eq!((link.path.as_str(), link.size), (out.as_str(), Some(bytes.len() as u64)));
assert_eq!(bounds(&s, id), placed);
assert_eq!(run(&mut s, "links.check", json!({}))["links"][0]["status"], "ok");
assert!(s.execute("links.unembed", &json!({"id": id.0, "path": out})).is_err(), "linked already");
run(&mut s, "edit.undo", json!({}));
assert!(image(&s, id).link.is_none(), "one undo step");
// Another extension converts.
let jpg = dir.file("photo.jpg");
run(&mut s, "links.unembed", json!({"id": id.0, "path": jpg}));
assert_eq!(std::fs::read(&jpg).unwrap()[..2], [0xff, 0xd8]);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
run(&mut s, "edit.undo", json!({}));
assert!(s.execute("links.unembed", &json!({"id": id.0, "path": dir.file("photo.psd")})).is_err(), "not a format it writes");
}
#[test]
fn go_to_selects_the_image() {
let dir = Folder::new("goto");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
assert_ne!(s.doc().unwrap().selection.objects, vec![id]);
let r = run(&mut s, "links.goTo", json!({"id": id.0}));
assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0]}));
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[1].id;
assert!(s.execute("links.goTo", &json!({"id": rect.0})).is_err(), "not an image");
}
#[test]
fn the_list_shows_each_image_with_its_status() {
let dir = Folder::new("list");
let (a, b) = (dir.file("a.png"), dir.file("b.png"));
write(&a, &png(600, 300, RED));
write(&b, &png(40, 20, BLUE));
let mut s = session();
let linked = place(&mut s, &a);
let embedded = place_embedded(&mut s, &b);
let l = run(&mut s, "links.list", json!({}));
let rows = l["links"].as_array().unwrap();
assert_eq!(rows.len(), 2);
// Top first.
assert_eq!((rows[0]["id"].as_u64(), rows[0]["status"].as_str(), rows[0]["name"].as_str()), (Some(embedded.0), Some("embedded"), Some("b.png")));
assert_eq!(
(rows[1]["id"].as_u64(), rows[1]["status"].as_str(), rows[1]["path"].as_str(), rows[1]["format"].as_str()),
(Some(linked.0), Some("ok"), Some(a.as_str()), Some("PNG"))
);
assert_eq!((rows[1]["pixelWidth"].as_u64(), rows[1]["pixelHeight"].as_u64()), (Some(600), Some(300)));
write(&a, &png(300, 300, BLUE));
let l = run(&mut s, "links.list", json!({"sort": "status"}));
assert_eq!((l["modified"].as_u64(), l["embedded"].as_u64(), l["missing"].as_u64()), (Some(1), Some(1), Some(0)));
assert_eq!(l["links"][0]["status"], "modified", "modified before embedded");
let l = run(&mut s, "links.list", json!({"show": "embedded"}));
assert_eq!(l["links"].as_array().unwrap().len(), 1);
std::fs::remove_file(&a).unwrap();
assert_eq!(run(&mut s, "links.list", json!({"show": "missing"}))["links"][0]["id"], linked.0);
assert_eq!(run(&mut s, "links.list", json!({"sort": "name"}))["links"][0]["name"], "a.png");
assert!(s.execute("links.list", &json!({"show": "broken"})).is_err());
assert!(s.execute("links.list", &json!({"sort": "size"})).is_err());
}
#[test]
fn link_info_has_the_file_and_placement_details() {
let dir = Folder::new("info");
let pic = dir.file("photo.png");
let bytes = png(600, 300, RED);
write(&pic, &bytes);
let mut s = session();
let id = place(&mut s, &pic);
let i = run(&mut s, "links.info", json!({"id": id.0}));
assert_eq!((i["status"].as_str(), i["fileName"].as_str(), i["format"].as_str()), (Some("ok"), Some("photo.png"), Some("PNG")));
assert_eq!(std::path::Path::new(i["location"].as_str().unwrap()), dir.0.as_path());
assert_eq!((i["fileSize"].as_u64(), i["pixelWidth"].as_u64(), i["colorMode"].as_str()), (Some(bytes.len() as u64), Some(600), Some("RGB")));
assert_eq!((i["ppi"].clone(), i["effectivePpi"].clone(), i["scale"].clone()), (json!([72.0, 72.0]), json!([72.0, 72.0]), json!([100.0, 100.0])));
assert!(i["modified"].as_u64().is_some());
assert_eq!(i["placement"], json!({"preserve": "bounds", "align": "center", "clip": false}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.scale", json!({"sx": 50}));
run(&mut s, "object.rotate", json!({"angle": 30}));
let i = run(&mut s, "links.info", json!({}));
let num = |v: &Value, k: usize| v.get(k).and_then(Value::as_f64).unwrap();
assert!(close(num(&i["scale"], 0), 50.0) && close(num(&i["effectivePpi"], 0), 144.0), "{i}");
assert!(close(i["rotation"].as_f64().unwrap(), 30.0), "counter-clockwise: {i}");
let embedded = place_embedded(&mut s, &pic);
let i = run(&mut s, "links.info", json!({"id": embedded.0}));
assert_eq!((i["status"].as_str(), i["linked"].as_bool(), i["fileName"].as_str()), (Some("embedded"), Some(false), None));
}
/// A 600×300 pt image at the centre of the artboard, its placement options set to `options`,
/// relinked to a 300×100 px file → the image's id and session.
fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) {
let dir = Folder::new(name);
let (pic, other) = (dir.file("photo.png"), dir.file("blue.png"));
write(&pic, &png(600, 300, RED));
write(&other, &png(300, 100, BLUE));
let mut s = session();
let id = place(&mut s, &pic);
let mut p = options;
p["ids"] = json!([id.0]);
run(&mut s, "links.placementOptions", p);
run(&mut s, "links.relink", json!({"ids": [id.0], "path": other}));
(s, id, dir)
}
#[test]
fn a_relinked_file_follows_the_placement_options() {
let b = |s: &Session, id| {
let r = bounds(s, id);
[r.x0, r.y0, r.x1, r.y1].map(|v| (v * 1e6).round() / 1e6)
};
// The old bounds: -100, 0, 500, 300.
let (s, id, _d) = relinked("pl-bounds", json!({"preserve": "bounds"}));
assert_eq!(b(&s, id), [-100.0, 0.0, 500.0, 300.0], "stretched into the bounds");
let (s, id, _d) = relinked("pl-fit", json!({"preserve": "fit"}));
assert_eq!(b(&s, id), [-100.0, 50.0, 500.0, 250.0], "proportional, centred");
let (s, id, _d) = relinked("pl-transforms", json!({"preserve": "transforms"}));
assert_eq!(b(&s, id), [50.0, 100.0, 350.0, 200.0], "at the same 100%");
let (s, id, _d) = relinked("pl-file", json!({"preserve": "fileDimensions", "align": "topLeft"}));
assert_eq!(b(&s, id), [-100.0, 0.0, 200.0, 100.0]);
let (mut s, id, d) = relinked("pl-fill", json!({"preserve": "fill", "align": "left", "clip": true}));
assert_eq!(b(&s, id), [-100.0, 0.0, 800.0, 300.0], "covers the bounds");
let clip_group = |s: &Session| {
let doc = &s.doc().unwrap().doc;
let g = doc.node(doc.parent_of(id).unwrap()).unwrap();
let NodeKind::Group { children, clip: true } = &g.kind else { panic!("not clipped: {g:?}") };
(g.id, children[0].geometric_bounds().unwrap())
};
let (group, clip) = clip_group(&s);
assert_eq!(clip, vectorcraft_geom::Rect::new(-100.0, 0.0, 500.0, 300.0), "clipped to the old bounds");
// Relinked again: the same clip group, its path following.
run(&mut s, "links.relink", json!({"ids": [id.0], "path": d.file("photo.png")}));
assert_eq!(clip_group(&s).0, group);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(
matches!(s.doc().unwrap().doc.node(s.doc().unwrap().doc.parent_of(id).unwrap()).unwrap().kind, NodeKind::Layer { .. }),
"one undo step each"
);
}
#[test]
fn placement_options_read_set_and_round_trip() {
let dir = Folder::new("placement");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
run(&mut s, "select.set", json!({"ids": [id.0]}));
assert_eq!(run(&mut s, "links.placementOptions", json!({})), json!({"placement": {"preserve": "bounds", "align": "center", "clip": false}}));
let r = run(&mut s, "links.placementOptions", json!({"preserve": "fill", "align": "topRight", "clip": true}));
assert_eq!(r, json!({"ids": [id.0], "placement": {"preserve": "fill", "align": "topRight", "clip": true}}));
assert!(s.execute("links.placementOptions", &json!({"preserve": "stretch"})).is_err());
assert!(s.execute("links.placementOptions", &json!({"align": "middle"})).is_err());
// Saved and opened again.
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
assert_eq!(
saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["placement"],
json!({"preserve": "fill", "align": "topRight", "clip": true})
);
open(&mut s, &doc_path);
let want = PlacementOptions { preserve: vectorcraft_doc::links::Preserve::Fill, align: vectorcraft_doc::links::Align::TopRight, clip: true };
assert_eq!(image(&s, id).placement, want);
run(&mut s, "select.all", json!({}));
run(&mut s, "links.placementOptions", json!({"preserve": "bounds", "align": "center", "clip": false}));
save(&mut s, &doc_path);
let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
assert_eq!(saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["placement"], Value::Null, "the default isn't written");
}
#[test]
fn the_links_commands_are_reachable_headless() {
for id in [
"links.list",
"links.goTo",
"links.embed",
"links.unembed",
"links.info",
"links.placementOptions",
"links.relink",
"links.update",
"links.check",
] {
let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} is not a command"));
assert!(!c.params.is_empty(), "{id} documents its params");
}
// Without a document every one says why instead of failing silently.
let mut s = Session::new();
assert!(s.execute("links.list", &json!({})).is_err());
}

View File

@ -0,0 +1,195 @@
//! File → Package (`file.package`): the document with its linked files and fonts in a folder or a
//! zip, and the Document Info report it shares with Document Info › Save….
use std::collections::BTreeMap;
use std::io::Read;
use serde_json::{Value, json};
use super::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write};
use super::*;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// A saved document `dir/poster.vectorcraft` with two linked files (in `dir/art`) and a line of
/// type.
fn poster(dir: &Folder) -> Session {
let (a, b) = (dir.file("art/red.png"), dir.file("art/blue.png"));
write(&a, &png(600, 300, RED));
write(&b, &png(300, 300, BLUE));
let mut s = session();
place(&mut s, &a);
place(&mut s, &b);
run(&mut s, "text.create", json!({"x": 10, "y": 40, "text": "Poster"}));
save(&mut s, &dir.file("poster.vectorcraft"));
s
}
/// The files in a zip archive by name (CRC-checked).
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let le16 = |i: usize| u16::from_le_bytes([bytes[i], bytes[i + 1]]) as usize;
let le32 = |i: usize| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap()) as usize;
let end = bytes.len() - 22;
assert_eq!(le32(end), 0x0605_4b50, "end of central directory");
let (count, mut i) = (le16(end + 10), le32(end + 16));
let mut out = BTreeMap::new();
for _ in 0..count {
assert_eq!(le32(i), 0x0201_4b50);
let (method, crc, packed, name_len, offset) = (le16(i + 10), le32(i + 16), le32(i + 20), le16(i + 28), le32(i + 42));
let name = String::from_utf8(bytes[i + 46..i + 46 + name_len].to_vec()).unwrap();
i += 46 + name_len + le16(i + 30) + le16(i + 32);
assert_eq!(le32(offset), 0x0403_4b50);
let data = &bytes[offset + 30 + le16(offset + 26) + le16(offset + 28)..][..packed];
let mut content = vec![];
match method {
8 => drop(flate2::read::DeflateDecoder::new(data).read_to_end(&mut content).unwrap()),
_ => content = data.to_vec(),
}
let mut c = flate2::Crc::new();
c.update(&content);
assert_eq!(c.sum() as usize, crc, "{name}");
out.insert(name, content);
}
out
}
#[test]
fn two_links_and_a_font_are_collected_and_relinked() {
let dir = Folder::new("package");
let mut s = poster(&dir);
let out = dir.file("out");
let r = run(&mut s, "file.package", json!({"folder": out}));
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(files, ["poster.vectorcraft", "Links/red.png", "Links/blue.png", "Fonts/SourceSans3-Regular.ttf", "poster Report.txt"]);
assert_eq!((r["links"].as_u64(), r["fonts"].as_u64(), r["missingLinks"].clone()), (Some(2), Some(1), json!([])));
let root = std::path::Path::new(&out).join("poster Folder");
assert_eq!(std::path::Path::new(r["folder"].as_str().unwrap()), root);
for f in &files {
assert!(root.join(f).is_file(), "{f} written");
}
assert_eq!(std::fs::read(root.join("Links/red.png")).unwrap(), png(600, 300, RED));
let font = std::fs::read(root.join("Fonts/SourceSans3-Regular.ttf")).unwrap();
assert_eq!(&font[..4], &[0, 1, 0, 0], "a TrueType file");
// The open document still links to the originals.
let link = |s: &Session| {
let mut paths = vec![];
s.doc().unwrap().doc.visit_images(|_, im| paths.push(im.link.clone().unwrap()));
paths
};
assert!(link(&s).iter().all(|l| l.path.contains("art")));
// The packaged one opens with the copies, even with the originals gone.
std::fs::remove_dir_all(dir.0.join("art")).unwrap();
let r = open(&mut s, &root.join("poster.vectorcraft").to_string_lossy());
assert_eq!(r["missingLinks"], json!([]), "{r}");
for l in link(&s) {
assert!(std::path::Path::new(&l.path).starts_with(root.join("Links")), "{l:?}");
assert!(l.relative.as_deref().is_some_and(|r| r.starts_with("Links/")));
}
assert!(s.doc().unwrap().doc.images.values().all(|b| !b.is_proxy()), "the copies' pixels");
}
#[test]
fn an_unsaved_document_cant_be_packaged() {
let mut s = session();
let e = s.execute("file.package", &json!({"folder": std::env::temp_dir()})).unwrap_err().to_string();
assert!(e.contains("save the document first"), "{e}");
assert!(Session::new().execute("file.package", &json!({})).is_err(), "no document");
}
#[test]
fn without_a_folder_the_zip_has_the_same_files() {
let dir = Folder::new("package-zip");
let mut s = poster(&dir);
let r = run(&mut s, "file.package", json!({"name": "Poster Kit"}));
assert_eq!(r["name"], "Poster Kit.zip");
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
let want: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| format!("Poster Kit/{}", f.as_str().unwrap())).collect();
let mut want_sorted = want.clone();
want_sorted.sort();
assert_eq!(zip.keys().cloned().collect::<Vec<_>>(), want_sorted);
assert_eq!(zip["Poster Kit/Links/blue.png"], png(300, 300, BLUE));
// The packaged document links to the files beside it.
let doc = vectorcraft_format::load(&zip["Poster Kit/poster.vectorcraft"]).unwrap();
let mut rel = vec![];
doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative.unwrap()));
rel.sort();
assert_eq!(rel, ["Links/blue.png", "Links/red.png"]);
let report = String::from_utf8(zip["Poster Kit/poster Report.txt"].clone()).unwrap();
assert!(report.contains("LINKED FILES") && report.contains("→ Links/red.png"), "{report}");
// The same entries as a folder package.
let out = dir.file("out");
let f = run(&mut s, "file.package", json!({"folder": out, "name": "Poster Kit"}));
assert_eq!(f["files"], r["files"]);
}
#[test]
fn options_leave_out_links_fonts_and_the_report() {
let dir = Folder::new("package-options");
let mut s = poster(&dir);
let r = run(&mut s, "file.package", json!({"linksFolder": false, "copyFonts": false, "report": false}));
assert_eq!(r["files"], json!(["poster.vectorcraft", "red.png", "blue.png"]));
let r = run(&mut s, "file.package", json!({"copyLinks": false}));
assert_eq!(r["files"], json!(["poster.vectorcraft", "Fonts/SourceSans3-Regular.ttf", "poster Report.txt"]));
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
let doc = vectorcraft_format::load(&zip["poster Folder/poster.vectorcraft"]).unwrap();
doc.visit_images(|_, im| assert!(im.link.as_ref().unwrap().path.contains("art"), "not relinked: the originals"));
let r = run(&mut s, "file.package", json!({"relink": false}));
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
assert!(zip.contains_key("poster Folder/Links/red.png"), "copied");
let doc = vectorcraft_format::load(&zip["poster Folder/poster.vectorcraft"]).unwrap();
doc.visit_images(|_, im| assert!(im.link.as_ref().unwrap().path.contains("art"), "not relinked"));
assert!(s.execute("file.package", &json!({"name": "../up"})).is_err());
}
#[test]
fn a_missing_link_is_reported_not_copied() {
let dir = Folder::new("package-missing");
let mut s = poster(&dir);
std::fs::remove_file(dir.file("art/blue.png")).unwrap();
// Reopened, the missing file's image shows its preview: nothing to copy.
open(&mut s, &dir.file("poster.vectorcraft"));
let r = run(&mut s, "file.package", json!({}));
assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(1), json!(["blue.png"])));
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap();
assert!(report.contains("blue.png: not found"), "{report}");
}
#[test]
fn the_document_info_report_has_every_category() {
let dir = Folder::new("docinfo-report");
let mut s = poster(&dir);
s.execute("file.place", &json!({"path": dir.file("art/red.png"), "link": false})).unwrap();
let text = run(&mut s, "document.info", json!({"format": "text"}))["text"].as_str().unwrap().to_string();
for h in [
"DOCUMENT",
"OBJECTS",
"GRAPHIC STYLES",
"SPOT COLORS",
"PATTERN OBJECTS",
"GRADIENT SWATCHES",
"SYMBOLS",
"FONTS",
"FONT DETAILS",
"LINKED IMAGES",
"EMBEDDED IMAGES",
] {
assert!(text.contains(&format!("\n{h}\n")), "{h}: {text}");
}
assert!(text.contains("Images: 3") && text.contains("Source Sans 3 Regular: built in; embedding allowed"), "{text}");
assert!(text.contains("red.png: 600 × 300 px — ") && text.contains("Color Mode: RGB") && text.contains("\nSwatches: "), "{text}");
let fonts = run(&mut s, "document.info", json!({"format": "text", "category": "fonts"}))["text"].as_str().unwrap().to_string();
assert!(fonts.contains("FONTS") && !fonts.contains("OBJECTS"), "{fonts}");
let i = run(&mut s, "document.info", json!({"category": "linkedImages"}));
assert_eq!((i["sections"].as_array().unwrap().len(), i["sections"][0]["id"].as_str()), (1, Some("linkedImages")));
assert_eq!(i["sections"][0]["rows"].as_array().unwrap().len(), 2);
let all = run(&mut s, "document.info", json!({}));
assert_eq!(all["sections"].as_array().unwrap().len(), 11);
assert!(s.execute("document.info", &json!({"category": "brushes"})).is_err());
assert!(s.execute("document.info", &json!({"format": "xml"})).is_err());
// The bundled fonts allow embedding.
let face = vectorcraft_text::FontDb::global().face("Source Sans 3", "Regular").unwrap();
assert!(face.embeddable() && face.path().is_none());
}

View File

@ -101,7 +101,10 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() {
let mut d = Document::new(200.0, 200.0);
d.images.insert("pic".into(), ImageBlob::new("image/png", png(40, 30)));
let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5);
let im = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None }));
let im = Node::new(
d.alloc_id(),
NodeKind::Image(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() }),
);
add(&mut d, with(im, shadow()));
assert_written("image", &d);

View File

@ -451,7 +451,7 @@ mod tests {
d.images.insert("k".into(), ImageBlob::new("image/png", vec![1, 2, 3, 250]));
// Only images in use are saved.
let img = d.alloc_id();
let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None };
let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() };
d.insert(Some(l), 1, Node::new(img, NodeKind::Image(im))).unwrap();
let bytes = save(&d, true);
assert!(sniff(&bytes));

View File

@ -12,7 +12,7 @@ use vectorcraft_geom::Affine;
fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) {
let (id, layer) = (d.alloc_id(), d.layers[0].id);
let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link };
let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default() };
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
}

View File

@ -33,7 +33,10 @@ fn blob(bytes: &[u8]) -> ImageBlob {
fn image_node(d: &mut Document, key: &str) -> Node {
let id = d.alloc_id();
Node::new(id, NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None }))
Node::new(
id,
NodeKind::Image(ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
)
}
fn opts(version: u32, compress: bool) -> SaveOptions {

View File

@ -75,7 +75,7 @@ proptest! {
d.images.insert(key.clone(), ImageBlob::new("image/png", data.clone()));
let layer = d.layers[0].id;
let id = d.alloc_id();
let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None };
let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() };
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
for pretty in [false, true] {
let back = load(&save(&d, pretty)).unwrap();

View File

@ -404,7 +404,7 @@ impl<'p> Builder<'p> {
(k, w, h)
};
let id = self.id();
self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None })));
self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() })));
}
}

View File

@ -366,7 +366,14 @@ fn tiny_png() -> Vec<u8> {
fn image_roundtrip() {
let mut d = doc(200.0, 200.0);
d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png()));
let im = ImageObject { key: "img1".into(), width: 4, height: 2, xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), link: None };
let im = ImageObject {
key: "img1".into(),
width: 4,
height: 2,
xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0),
link: None,
placement: Default::default(),
};
add(&mut d, Node::new(NodeId(0), NodeKind::Image(im)));
let out = roundtrip(&d);
let l = leaves(&out);

View File

@ -27,7 +27,10 @@ fn doc(bytes: Vec<u8>, mime: &str) -> Document {
let mut d = Document::new(100.0, 100.0);
d.images.insert("img".into(), ImageBlob::new(mime, bytes));
let xf = Affine::scale(72.0 / 600.0);
let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None }));
let n = Node::new(
d.alloc_id(),
NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() }),
);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d

View File

@ -114,7 +114,10 @@ fn placed_images_are_separated_not_blackened() {
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None }));
let n = Node::new(
d.alloc_id(),
NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
);
add(&mut d, n);
let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1);
let cms = vectorcraft_color::cms::active();

View File

@ -49,7 +49,14 @@ fn image_doc(effects: Vec<Effect>) -> Document {
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let mut n = Node::new(
NodeId(0),
NodeKind::Image(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None }),
NodeKind::Image(ImageObject {
key: "red".into(),
width: 30,
height: 30,
xf: Affine::translate((20.0, 20.0)),
link: None,
placement: Default::default(),
}),
);
n.appearance.effects = effects;
doc_with(d, n)

View File

@ -10,7 +10,7 @@ fn doc_with_red_image() -> Document {
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let (id, layer) = (d.alloc_id(), d.layers[0].id);
let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None };
let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default() };
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
d
}

View File

@ -1019,7 +1019,7 @@ impl Importer {
if link.is_some() && !missing { blob.with_proxy() } else { blob }
});
let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64);
Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link })))
Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() })))
}
/// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size.

View File

@ -55,7 +55,7 @@ fn image_doc(key: &str, bytes: Vec<u8>) -> Document {
let mut d = Document::new(100.0, 100.0);
d.images.insert(key.into(), ImageBlob::new("image/png", bytes));
let l = d.layers[0].id;
let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None };
let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
d.insert(Some(l), usize::MAX, n).unwrap();
d

View File

@ -340,7 +340,14 @@ fn image_roundtrip() {
let id = d.alloc_id();
let n = Node::new(
id,
NodeKind::Image(ImageObject { key: "k".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0), link: None }),
NodeKind::Image(ImageObject {
key: "k".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0),
link: None,
placement: Default::default(),
}),
);
let mut d = doc_with(vec![n]);
d.images.insert("k".into(), ImageBlob::new("image/png", png.clone()));

View File

@ -43,6 +43,9 @@ pub struct FontFace {
pub italic: bool,
bytes: FontBytes,
index: u32,
/// The file the face was read from (cataloged system fonts); `None` for the bundled fonts and
/// fonts added as bytes.
path: Option<std::path::PathBuf>,
pub(crate) upem: f64,
/// Ascender in font units (positive = up).
pub(crate) ascent: f64,
@ -109,6 +112,23 @@ impl FontFace {
pub fn glyph_for(&self, c: char) -> u32 {
self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0)
}
/// The file the face was read from (cataloged system fonts).
pub fn path(&self) -> Option<&std::path::Path> {
self.path.as_deref()
}
/// The bytes of the face's font file (a collection holds several faces).
pub fn file_data(&self) -> &[u8] {
self.data()
}
/// The embedding permissions of the OS/2 table (`fsType`; 0, installable, when it has none).
pub fn fs_type(&self) -> u16 {
use skrifa::raw::TableProvider;
self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type())
}
/// May the font file be copied along with a document (its licence doesn't restrict embedding)?
pub fn embeddable(&self) -> bool {
self.fs_type() & 0x000f != 0x0002
}
}
#[cfg(not(target_arch = "wasm32"))]
@ -192,7 +212,7 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
.collect()
}
fn make_face(bytes: FontBytes, index: u32, family: String, style: String) -> Option<FontFace> {
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option<std::path::PathBuf>) -> Option<FontFace> {
let data: &[u8] = match &bytes {
FontBytes::Static(b) => b,
FontBytes::Owned(v) => v.as_slice(),
@ -215,6 +235,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String) -> Opt
shaper,
bytes,
index,
path,
})
}
@ -253,7 +274,7 @@ impl FontDb {
let mut faces = Vec::new();
for data in BUNDLED {
for (i, family, style) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, family, style) {
if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) {
faces.push(Arc::new(f));
}
}
@ -317,13 +338,18 @@ impl FontDb {
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
/// every face was already present).
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
self.add_font_from(bytes, None)
}
/// [`FontDb::add_font`] for the bytes of the file at `path` (when known).
fn add_font_from(&self, bytes: Vec<u8>, path: Option<&std::path::Path>) -> usize {
let data = Arc::new(bytes);
let mut added = 0;
for (i, family, style) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style) {
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(std::path::Path::to_path_buf)) {
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
added += 1;
}
@ -393,7 +419,7 @@ impl FontDb {
let mut any = false;
for p in paths {
if let Ok(data) = std::fs::read(&p) {
any |= self.add_font(data) > 0;
any |= self.add_font_from(data, Some(&p)) > 0;
}
}
any
@ -501,7 +527,7 @@ impl FontDb {
let Ok(data) = std::fs::read(&p) else { continue };
let hit =
enumerate_faces(&data).iter().any(|(i, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
if hit && self.add_font(data) > 0 && covered(self) {
if hit && self.add_font_from(data, Some(&p)) > 0 && covered(self) {
return true;
}
}

View File

@ -199,7 +199,14 @@ mod tests {
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("px".into(), ImageBlob::png(png));
let im = ImageObject { key: "px".into(), width: 3, height: 1, xf: vectorcraft_geom::Affine::scale(10.0), link: None };
let im = ImageObject {
key: "px".into(),
width: 3,
height: 1,
xf: vectorcraft_geom::Affine::scale(10.0),
link: None,
placement: Default::default(),
};
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap();
let (s, p) = (Selection::default(), paint());
let mut cx = cx(&d, &s, &p);

View File

@ -33,9 +33,11 @@ pub mod new_color_group;
mod new_document;
pub mod new_swatch;
pub mod office_export;
pub mod package;
mod path_ops;
pub mod pdf_presets;
pub mod place;
pub mod placement_options;
mod png_options;
pub mod raster_effects;
pub mod recolor;
@ -209,6 +211,8 @@ registry! {
TextExport: [text_export::KIND] => text_export::SPEC,
OfficeExport: [office_export::KIND] => office_export::SPEC,
DxfOptions: [dxf_options::KIND] => dxf_options::SPEC,
PlacementOptions: [placement_options::KIND] => placement_options::SPEC,
Package: [package::KIND] => package::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@ -315,6 +319,8 @@ mod tests_export;
#[cfg(test)]
mod tests_import_pdf;
#[cfg(test)]
mod tests_package;
#[cfg(test)]
mod tests_raster_formats;
#[cfg(test)]

View File

@ -0,0 +1,123 @@
//! File → Package…: where the package folder goes, its name, and what it collects (the linked
//! files, in a folder of their own or not, relinked or not; the fonts; the report). A document
//! never saved is saved first (Save As); unsaved changes are saved before packaging. The web
//! downloads the package as a zip.
//!
//! Fields: `folder` (the location), `name` (the package folder), `copyLinks`, `linksFolder`,
//! `relink`, `copyFonts`, `report`. OK runs `file.package` with them, then offers to show the
//! package folder (`file.showPackage`).
use serde_json::{Value, json};
use super::confirm::ask;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "package";
const LABEL: f32 = 90.0;
/// The options: (field, label).
const OPTIONS: [(&str, &str); 5] = [
("copyLinks", "Copy Links"),
("linksFolder", "Collect Links in a Separate Folder"),
("relink", "Relink Linked Files to the Document"),
("copyFonts", "Copy Fonts Used in the Document"),
("report", "Create Report"),
];
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Package".into(), body, confirm, ok: Some("Package"), min_width: 440.0, max_width: Some(460.0), ..DialogSpec::FORM };
/// Is there no file system to write a folder to (the web downloads a zip)?
fn downloads(app: &VectorcraftApp) -> bool {
app.services.download.is_some()
}
/// Open the dialog for the active document; one never saved is offered Save As first.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let Some(path) = st.path.clone() else {
ask(app, "Save the document first?", "Package collects a saved document with its linked files and fonts.", "file.saveAs", json!({}));
return Ok(Value::Null);
};
let p = std::path::Path::new(&path);
let stem = p.file_stem().map_or_else(|| "Untitled".into(), |s| s.to_string_lossy().into_owned());
let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
let mut fields = json!({ "folder": folder, "name": format!("{stem} Folder") });
for (k, _) in OPTIONS {
fields[k] = json!(true);
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
if !downloads(app) {
widgets::label_row(ui, "Location:", LABEL, |ui| {
form::text(ui, d, "folder", 230.0);
if let Some(pick) = app.services.pick_folder.as_mut()
&& ui.button("Choose…").clicked()
&& let Some(f) = pick()
{
d.fields.insert("folder".into(), json!(f));
}
});
ui.add_space(4.0);
}
widgets::label_row(ui, "Folder Name:", LABEL, |ui| {
form::text(ui, d, "name", 230.0);
});
ui.add_space(10.0);
widgets::subheader(ui, "Options");
for (k, label) in OPTIONS {
// The link options apply only when the links are copied.
let enabled = !matches!(k, "linksFolder" | "relink") || d.bool("copyLinks");
let on = d.bool(k);
if widgets::check(ui, label, on, enabled) {
d.fields.insert(k.into(), json!(!on));
}
}
ui.add_space(6.0);
ui.label(egui::RichText::new("Fonts whose licence doesn't allow embedding are not copied.").size(11.5).color(t.text_dim));
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut params = Value::Object(d.fields.clone());
let web = downloads(app);
if web {
params["folder"] = Value::Null;
} else if d.str("folder").trim().is_empty() {
return Err("choose a location for the package".into());
}
// The package holds the document as saved.
if app.session.active().is_some_and(|st| st.is_dirty()) {
io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
}
let r = app.run("file.package", params)?;
app.ui.dialog = None;
let n = r["files"].as_array().map_or(0, Vec::len);
let missing = r["missingLinks"].as_array().map_or(0, Vec::len);
let note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() };
if web {
let (name, data) = (r["name"].as_str().unwrap_or("package.zip"), r["dataBase64"].as_str().unwrap_or_default());
let bytes = vectorcraft_format::base64_decode(data).ok_or("the package came back damaged")?;
io::write_named(app, None, name, &bytes)?;
app.status(format!("Packaged {n} file(s){note}"));
return Ok(r);
}
let folder = r["folder"].as_str().unwrap_or_default().to_string();
app.status(format!("Packaged {n} file(s) in {folder}{note}"));
let detail = format!("{n} file(s) in {folder}{note}. Show the package folder?");
ask(app, "Package created", &detail, "file.showPackage", json!({ "folder": folder }));
Ok(r)
}
/// `file.showPackage {folder}`: show a package folder in the file manager.
pub fn show_package(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let folder = p.get("folder").and_then(Value::as_str).ok_or("missing `folder`")?.to_string();
io::open_in_app(app, &folder)?;
Ok(json!({ "folder": folder }))
}

View File

@ -0,0 +1,75 @@
//! Links panel → Placement Options: how a file read again (Relink, Update Link) takes the place of
//! the selected images: what it preserves, where it aligns in the old bounds, and whether it is
//! clipped to them.
//!
//! Fields: `ids` (the images; null: the selected ones), `preserve` (transforms|bounds|fileDimensions|fit|fill), `align`
//! (topLeft … bottomRight) and `clip`. OK runs `links.placementOptions` with them: one undo step.
use serde_json::{Value, json};
use super::DialogSpec;
use super::form;
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
pub(super) const KIND: &str = "placementOptions";
const CMD: &str = "links.placementOptions";
/// (value, label) of each Preserve choice.
const PRESERVE: [(&str, &str); 5] = [
("transforms", "Transforms"),
("bounds", "Bounds"),
("fileDimensions", "File Dimensions"),
("fit", "Bounds (Fit Proportionally)"),
("fill", "Bounds (Fill Proportionally)"),
];
/// The alignment points, row by row from the top left (the reference point widget's order).
const ALIGN: [&str; 9] = ["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"];
const LABEL: f32 = 90.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Placement Options".into(), body, confirm, min_width: 400.0, max_width: Some(420.0), ..DialogSpec::FORM };
/// Open the dialog on the placement options of images `ids` (default: the selected ones).
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let ids = p.get("ids").cloned().unwrap_or(Value::Null);
let r = app.session.execute(CMD, &json!({ "ids": ids })).map_err(|e| e.to_string())?;
let mut fields = r["placement"].clone();
fields["ids"] = ids;
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let r = app.run(CMD, Value::Object(d.fields.clone()));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::choice(ui, d, "preserve", "Preserve:", (LABEL, 240.0), &PRESERVE);
ui.add_space(8.0);
// Bounds stretches the art into the old bounds: nothing to align or clip.
let free = d.str("preserve") != "bounds";
ui.add_enabled_ui(free, |ui| {
widgets::label_row(ui, "Alignment:", LABEL, |ui| {
let current = ALIGN.iter().position(|a| *a == d.str("align")).unwrap_or(4);
if let Some(i) = widgets::reference_point(ui, current)
&& let Some(a) = ALIGN.get(i)
{
d.fields.insert("align".into(), json!(a));
}
});
ui.add_space(8.0);
widgets::label_row(ui, "", LABEL, |ui| {
let clip = d.bool("clip");
if widgets::check(ui, "Clip to Bounding Box", clip, free) {
d.fields.insert("clip".into(), json!(!clip));
}
});
});
ui.add_space(6.0);
widgets::dim_label(ui, "Applies when the linked file is relinked or updated.");
false
}

View File

@ -0,0 +1,117 @@
//! File → Package…: the dialog, saving first, the folder or the downloaded zip, Show Package.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
/// A fresh folder for one test (removed when dropped).
struct Folder(std::path::PathBuf);
impl Folder {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-package-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self, name: &str) -> String {
self.0.join(name).to_string_lossy().into_owned()
}
}
impl Drop for Folder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn frame(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| show(app, ui.ctx()))
}
/// An app writing through `std::fs`, with a saved document holding a placed linked image.
fn saved(dir: &Folder) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.services.write = Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string())));
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let mut png = vec![];
image::RgbaImage::from_pixel(300, 300, image::Rgba([1, 2, 3, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
std::fs::write(dir.path("pic.png"), &png).unwrap();
app.run("file.place", json!({"path": dir.path("pic.png")})).unwrap();
app.run("document.save", json!({"path": dir.path("card.vectorcraft")})).unwrap();
app
}
#[test]
fn an_unsaved_document_is_offered_save_as_first() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
assert!(!crate::menus::enabled(&app, "ui.packageDialog"), "no document");
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("ui.packageDialog", json!({})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.str("__command")), (confirm::KIND, "file.saveAs".to_string()));
}
#[test]
fn package_writes_the_folder_then_offers_to_show_it() {
let dir = Folder::new("folder");
let mut app = saved(&dir);
app.run("ui.packageDialog", json!({})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.str("name"), d.bool("copyFonts")), ("package", "card Folder".to_string(), true));
assert_eq!(std::path::Path::new(&d.str("folder")), dir.0.as_path(), "next to the document");
let text = frame(&mut app);
assert!(text.contains("Collect Links in a Separate Folder") && text.contains("Folder Name:"), "{text}");
let out = dir.path("out");
std::fs::create_dir_all(&out).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(out));
// Unsaved changes are saved first.
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let r = confirm(&mut app).unwrap();
assert!(!app.session.active().unwrap().is_dirty());
let root = std::path::Path::new(&out).join("card Folder");
assert!(root.join("card.vectorcraft").is_file() && root.join("Links/pic.png").is_file(), "{r}");
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.str("__command")), (confirm::KIND, "file.showPackage".to_string()));
let opened: Rc<RefCell<Vec<String>>> = Rc::default();
let o = opened.clone();
app.services.open_file = Some(Box::new(move |p: &str| {
o.borrow_mut().push(p.to_string());
Ok(())
}));
confirm(&mut app).unwrap();
assert_eq!(opened.borrow().first().map(std::path::PathBuf::from), Some(root));
}
#[test]
fn the_web_downloads_a_zip() {
let dir = Folder::new("web");
let mut app = saved(&dir);
let got: Rc<RefCell<Vec<(String, usize)>>> = Rc::default();
let g = got.clone();
app.services.download = Some(Box::new(move |name: &str, b: &[u8]| g.borrow_mut().push((name.to_string(), b.len()))));
app.run("ui.packageDialog", json!({})).unwrap();
assert!(!frame(&mut app).contains("Location:"), "no folder to choose");
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let got = got.borrow();
assert_eq!(got.len(), 1);
assert_eq!(got[0].0, "card Folder.zip");
assert!(got[0].1 > 100);
}
#[test]
fn package_without_a_location_says_so() {
let dir = Folder::new("empty");
let mut app = saved(&dir);
app.run("ui.packageDialog", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(" "));
assert!(confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some(), "stays open");
}

View File

@ -115,6 +115,20 @@ fn is_web(app: &VectorcraftApp) -> bool {
app.services.download.is_some()
}
/// Write `bytes` to `path`, else to a file picked with `name` suggested next to the document (the
/// web downloads them as `name`) → where they went.
pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name: &str, bytes: &[u8]) -> Result<String, String> {
let path = match path {
Some(p) => p,
None => {
let (_, folder) = suggested(app, "");
pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })?
}
};
write_to(&mut app.services, &path, bytes)?;
Ok(path)
}
/// Where a file goes when no path was given: the suggested name on the web (a download), else the
/// save panel's choice.
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
@ -479,10 +493,20 @@ pub fn ask_revert(app: &mut VectorcraftApp) -> Result<Value, String> {
/// File → Show in Folder: the document's file in the system file manager.
pub fn reveal(app: &mut VectorcraftApp) -> Result<Value, String> {
let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?;
app.services.reveal.as_mut().ok_or("no file manager here")?(&path)?;
reveal_path(app, &path)?;
Ok(json!({ "path": path }))
}
/// Show the file at `path` in the system file manager (desktop).
pub(crate) fn reveal_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
app.services.reveal.as_mut().ok_or("no file manager here")?(path)
}
/// Open `path` (a file or a folder) in the system's default app for it (desktop).
pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
app.services.open_file.as_mut().ok_or("no app to open files here")?(path)
}
/// 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> {
crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ())

View File

@ -102,7 +102,7 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// Opens a URL in the system browser.
pub type OpenUrlFn = Box<dyn FnMut(&str)>;
/// Shows a file in the system file manager.
/// Shows a file in the system file manager, or opens it in its app.
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Platform services injected by the host app (desktop or web).
@ -141,6 +141,11 @@ pub struct Services {
/// Write a file from any thread (desktop): lets Background Save and Export write off the UI
/// thread ([`background`]). Without it they run at once.
pub write_shared: Option<background::SharedWriteFn>,
/// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show
/// Package). Without it (the web) those answer with an error.
pub open_file: Option<RevealFn>,
/// Show a folder picker; returns its path (desktop: Relink to Folder, Package).
pub pick_folder: Option<Box<dyn FnMut() -> Option<String>>>,
}
/// Cached canvas raster.

View File

@ -431,6 +431,37 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too",
),
(
"links.editOriginal",
"Edit Original",
"",
"{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original) → {path}; edits saved there show after links.update",
),
(
"links.reveal",
"Show in Folder",
"",
"{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}",
),
(
"ui.placementOptionsDialog",
"Placement Options…",
"",
"{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions",
),
(
"ui.packageDialog",
"Package…",
"Cmd+Alt+Shift+P",
"{} open Package for the saved document (dialog `package`: folder, name, copyLinks, linksFolder, relink, copyFonts, report; a document never saved asks to Save As first); OK saves unsaved changes, runs file.package (the web downloads the zip) and offers file.showPackage",
),
("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"),
(
"docInfo.save",
"Save Document Info…",
"",
"{path?, selectionOnly?} write Document Info's text report (document.info {format: \"text\"}) to path, else a picked file (the web downloads it) → {path}",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -804,6 +835,12 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::dxf_options::open(app, p);
Ok(Value::Null)
}
"links.editOriginal" => crate::panels::links::open_file(app, p, false),
"links.reveal" => crate::panels::links::open_file(app, p, true),
"ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p),
"ui.packageDialog" => crate::dialogs::package::open(app),
"file.showPackage" => crate::dialogs::package::show_package(app, p),
"docInfo.save" => crate::panels::doc_info::save_report(app, p),
_ => return None,
};
Some(r)
@ -1031,10 +1068,20 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.spotColors" => app.session.active().is_some(),
"ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.dxfOptionsDialog" => app.session.active().is_some(),
"links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()),
"ui.placementOptionsDialog" => selected_image(app, |_| true),
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
_ => true,
}
}
/// Is an image `keep` accepts selected?
fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool {
app.session.active().is_some_and(|st| {
st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(&n.kind, vectorcraft_doc::NodeKind::Image(im) if keep(im))))
})
}
pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id }));
vec![
@ -1089,7 +1136,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
),
c("Export Selection…", "document.exportSelection"),
Sep,
todos("Package…", "Cmd+Alt+Shift+P"),
c("Package…", "ui.packageDialog"),
sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]),
Sep,
c("Document Setup…", "file.documentSetup"),
@ -1154,7 +1201,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Saturate…", "ui.saturateDialog"),
],
),
todo("Edit Original"),
c("Edit Original", "links.editOriginal"),
Sep,
c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"),
todo("Print Presets…"),
@ -1604,7 +1651,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
panel("Info", "info"),
panel("Layers", "layers"),
panel("Libraries", "libraries"),
todo("Links"),
panel("Links", crate::panels::links::ID),
panel("Magic Wand", "magicWand"),
panel("Navigator", "navigator"),
panel("Pathfinder", "pathfinder"),

View File

@ -1,5 +1,7 @@
//! Document Info panel: the document's setup and what it contains (objects by kind, fonts,
//! images, symbols, spot colours, graphic styles…), for the whole document or the selection only.
//! Document Info panel: the document's setup and what it contains (objects by kind, graphic
//! styles, spot colours, patterns, gradients, symbols, fonts and their files, linked and embedded
//! images), for the whole document or the selection only. The flyout picks one category (or all)
//! and saves the text report (`docInfo.save`, the report File → Package writes too).
use egui::Ui;
use serde_json::{Value, json};
@ -9,98 +11,109 @@ use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
const OBJECT_LABELS: [(&str, &str); 16] = [
("paths", "Paths"),
("compoundPaths", "Compound Paths"),
("groups", "Groups"),
("clipGroups", "Clipping Masks"),
("textObjects", "Text Objects"),
("images", "Images"),
("symbolInstances", "Symbol Instances"),
("gradients", "Gradient Objects"),
("patterns", "Pattern Objects"),
("meshes", "Gradient Meshes"),
("blends", "Blends"),
("envelopes", "Envelopes"),
("repeats", "Repeats"),
("opacityMasks", "Opacity Masks"),
("liveEffects", "Objects with Effects"),
("guides", "Guides"),
];
/// `document.info` and what it was fetched for: (document uid, revision, selection only).
#[derive(Clone, Default)]
struct Cache {
key: Option<(u64, u64, bool)>,
info: Value,
}
fn row(ui: &mut Ui, label: &str, value: String) {
pub(crate) fn row(ui: &mut Ui, label: &str, value: String) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).size(12.0).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(value).size(12.0).color(t.text));
// Long values (paths) are cut to the panel's width; hovering shows them whole.
ui.add(egui::Label::new(egui::RichText::new(value).size(12.0).color(t.text)).truncate());
});
});
}
/// `document.info` with its categories, fetched again only when the document or the scope changes.
fn info(app: &mut VectorcraftApp, ctx: &egui::Context, sel_only: bool) -> Option<Value> {
let st = app.session.active()?;
let key = Some((st.uid, st.revision, sel_only));
let mut c: Cache = pstate(ctx, "docinfo-cache");
if c.key != key {
c.info = app.session.execute("document.info", &json!({ "selectionOnly": sel_only })).ok()?;
c.key = key;
set_pstate(ctx, "docinfo-cache", c.clone());
}
Some(c.info)
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let sel_only: bool = pstate(ui.ctx(), "docinfo-sel");
let Ok(i) = app.session.execute("document.info", &json!({ "selectionOnly": sel_only })) else {
let category: String = pstate(ui.ctx(), "docinfo-category");
let Some(i) = info(app, ui.ctx(), sel_only) else {
widgets::dim_label(ui, "No document");
return;
};
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
widgets::subheader(ui, "Document");
let d = &i["document"];
row(ui, "Color Mode", d["colorMode"].as_str().unwrap_or("").into());
row(ui, "Units", d["units"].as_str().unwrap_or("").into());
row(ui, "Raster Effects", format!("{} ppi", d["rasterEffectsPpi"]));
for a in d["artboards"].as_array().into_iter().flatten() {
row(
ui,
a["name"].as_str().unwrap_or("Artboard"),
format!("{:.2} × {:.2}", a["width"].as_f64().unwrap_or(0.0), a["height"].as_f64().unwrap_or(0.0)),
);
}
widgets::divider(ui);
widgets::subheader(ui, if sel_only { "Objects (selection)" } else { "Objects" });
let objs = &i["objects"];
let mut any = false;
for (k, label) in OBJECT_LABELS {
if let Some(n) = objs[k].as_u64() {
row(ui, label, n.to_string());
any = true;
let sections = i["sections"].as_array().map_or(&[][..], Vec::as_slice);
let shown = sections.iter().filter(|s| category.is_empty() || s["id"] == category.as_str());
for (n, s) in shown.enumerate() {
if n > 0 {
widgets::divider(ui);
}
}
if !any {
widgets::dim_label(ui, "None");
}
widgets::divider(ui);
for (key, title) in [("fonts", "Fonts"), ("symbols", "Symbols"), ("spotColors", "Spot Colors"), ("graphicStyleNames", "Graphic Styles")] {
let list: Vec<&str> = i[key].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
widgets::subheader(ui, &format!("{title} ({})", list.len()));
for f in list {
widgets::dim_label(ui, f);
let title = s["title"].as_str().unwrap_or_default();
let rows = s["rows"].as_array().map_or(&[][..], Vec::as_slice);
// Lists show how many they hold; the selection scope shows on the first.
let title = match s["id"].as_str() {
Some("document" | "objects") => title.to_string(),
_ => format!("{title} ({})", rows.len()),
};
widgets::subheader(ui, &if sel_only && n == 0 && category.is_empty() { format!("{title} (selection)") } else { title });
if rows.is_empty() {
widgets::dim_label(ui, "None");
}
for r in rows {
let (label, value) = (r[0].as_str().unwrap_or_default(), r[1].as_str().unwrap_or_default());
if value.is_empty() {
widgets::dim_label(ui, label);
} else {
row(ui, label, value.into());
}
}
}
let imgs = i["images"].as_object().cloned().unwrap_or_default();
widgets::subheader(ui, &format!("Images ({})", imgs.len()));
for (_, im) in imgs {
let kind = if im["linked"].as_bool() == Some(true) { im["link"].as_str().unwrap_or("Linked").to_string() } else { "Embedded".into() };
widgets::dim_label(ui, &format!("{} × {} px — {kind}", im["width"], im["height"]));
}
widgets::divider(ui);
for (key, label) in [
("swatches", "Swatches"),
("characterStyles", "Character Styles"),
("paragraphStyles", "Paragraph Styles"),
("patterns", "Pattern Swatches"),
] {
row(ui, label, i[key].to_string());
}
});
}
pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) {
/// `docInfo.save {path?, selectionOnly?}`: write the text report to `path`, else a picked file
/// (the web downloads it) → `{path}`.
pub(crate) fn save_report(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let sel_only = p.get("selectionOnly").and_then(Value::as_bool).unwrap_or(false);
let r = app.session.execute("document.info", &json!({ "format": "text", "selectionOnly": sel_only })).map_err(|e| e.to_string())?;
let text = r["text"].as_str().unwrap_or_default();
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
let stem = std::path::Path::new(&title).file_stem().map_or_else(|| "Untitled".into(), |s| s.to_string_lossy().into_owned());
let path = p.get("path").and_then(Value::as_str).map(str::to_string);
let path = crate::io::write_named(app, path, &format!("{stem} Info.txt"), text.as_bytes())?;
app.status(format!("Saved {path}"));
Ok(json!({ "path": path }))
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let sel: bool = pstate(ui.ctx(), "docinfo-sel");
if menu_item(ui, "Selection Only", true, sel) {
set_pstate(ui.ctx(), "docinfo-sel", !sel);
}
ui.separator();
let category: String = pstate(ui.ctx(), "docinfo-category");
if menu_item(ui, "All Categories", true, category.is_empty()) {
set_pstate(ui.ctx(), "docinfo-category", String::new());
}
let info = info(app, ui.ctx(), sel).unwrap_or_default();
for s in info["sections"].as_array().into_iter().flatten() {
let (id, title) = (s["id"].as_str().unwrap_or_default(), s["title"].as_str().unwrap_or_default());
if menu_item(ui, title, true, category == id) {
set_pstate(ui.ctx(), "docinfo-category", id.to_string());
}
}
ui.separator();
if menu_item(ui, "Save…", app.session.active().is_some(), false) {
crate::menus::invoke(app, "docInfo.save", json!({ "selectionOnly": sel }));
}
}
#[cfg(test)]
@ -119,4 +132,31 @@ mod tests {
});
out.textures_delta.clear();
}
#[test]
fn categories_filter_the_panel_and_save_writes_the_report() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(text.contains("Color Mode") && text.contains("Artboard 1"), "{text}");
let menu_text = crate::tests_labels::painted_text(&mut app, menu);
assert!(menu_text.contains("Font Details") && menu_text.contains("Save…"), "{menu_text}");
let filtered = crate::tests_labels::painted_text(&mut app, |app, ui| {
set_pstate(ui.ctx(), "docinfo-category", "fonts".to_string());
show(app, ui);
});
assert!(filtered.contains("Source Sans 3 Regular") && !filtered.contains("Color Mode"), "{filtered}");
// Save…: written through the host.
let written: std::rc::Rc<std::cell::RefCell<Vec<u8>>> = Default::default();
let w = written.clone();
app.services.write = Some(Box::new(move |_: &str, b: &[u8]| {
w.borrow_mut().extend_from_slice(b);
Ok(())
}));
let r = app.run("docInfo.save", json!({"path": "/tmp/info.txt"})).unwrap();
assert_eq!(r["path"], "/tmp/info.txt");
let report = String::from_utf8(written.borrow().clone()).unwrap();
assert!(report.starts_with("Document Info: ") && report.contains("\nFONT DETAILS\n"), "{report}");
}
}

View File

@ -337,7 +337,7 @@ fn has_styled_target(n: &Node) -> bool {
/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc`
/// allocation, so the address is a free change detector). Only rendered for visible rows.
fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool {
pub(crate) fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool {
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {

View File

@ -0,0 +1,464 @@
//! Links panel: every image in the document, linked files with their status (missing, modified)
//! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects
//! its image; the buttons Relink, Go To, Update and Edit Original act on the selected rows; Link
//! Info shows the details of the first. The flyout adds Relink to Folder, Embed / Unembed,
//! Placement Options, Show filters, Sort, thumbnail size and Show in Folder.
//!
//! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI
//! commands: they hand the file to the system).
use egui::{Sense, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use super::{pstate, set_pstate};
use crate::theme::{self, Tokens};
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
pub const ID: &str = "links";
/// Seconds between two looks at the linked files (they change outside the app).
const RECHECK: f64 = 2.0;
const SHOW: [(&str, &str); 4] = [("all", "Show All"), ("missing", "Show Missing"), ("modified", "Show Modified"), ("embedded", "Show Embedded")];
const SORT: [(&str, &str); 3] = [("name", "Sort by Name"), ("kind", "Sort by Kind"), ("status", "Sort by Status")];
const THUMBS: [(f32, &str); 4] = [(0.0, "No Thumbnails"), (18.0, "Small Thumbnails"), (28.0, "Medium Thumbnails"), (44.0, "Large Thumbnails")];
/// The panel's options (flyout).
#[derive(Clone)]
struct Options {
show: &'static str,
sort: Option<&'static str>,
/// Thumbnail side (pt); 0 hides them.
thumb: f32,
/// The Link Info section is open.
info: bool,
}
impl Default for Options {
fn default() -> Self {
Self { show: "all", sort: None, thumb: 28.0, info: true }
}
}
/// `links.list` and the first selected image's `links.info`, with what they were fetched for.
#[derive(Clone, Default)]
struct Cache {
/// (document uid, revision, show, sort).
key: Option<(u64, u64, &'static str, Option<&'static str>)>,
at: f64,
list: Value,
info: Option<(u64, Value)>,
}
fn options(ctx: &egui::Context) -> Options {
pstate(ctx, "links-options")
}
fn set_options(ctx: &egui::Context, f: impl FnOnce(&mut Options)) {
let mut o = options(ctx);
f(&mut o);
set_pstate(ctx, "links-options", o);
}
/// The rows to show (cached per document revision, the files looked at again every [`RECHECK`]
/// seconds) and the Link Info of the first selected image when the section is open.
fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option<Cache> {
let st = app.session.active()?;
let key = Some((st.uid, st.revision, o.show, o.sort));
let first = selected_images(app).first().copied();
let now = ctx.input(|i| i.time);
let mut c: Cache = pstate(ctx, "links-cache");
let stale = c.key != key || now - c.at > RECHECK;
if stale {
c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort })).ok()?;
(c.key, c.at) = (key, now);
}
let want = first.filter(|_| o.info);
if stale || c.info.as_ref().map(|(id, _)| *id) != want {
c.info = want.and_then(|id| Some((id, app.session.execute("links.info", &json!({ "id": id })).ok()?)));
}
set_pstate(ctx, "links-cache", c.clone());
// Files change on disk without a frame of ours: look again later.
ctx.request_repaint_after(std::time::Duration::from_secs_f64(RECHECK));
Some(c)
}
/// The ids of the selected image objects.
fn selected_images(app: &VectorcraftApp) -> Vec<u64> {
let Some(st) = app.session.active() else { return vec![] };
let image = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)));
st.selection.objects.iter().filter(|id| image(id)).map(|id| id.0).collect()
}
/// The rows of `list` whose image is selected.
fn selected_rows<'a>(list: &'a Value, selected: &[u64]) -> Vec<&'a Value> {
list["links"].as_array().into_iter().flatten().filter(|r| r["id"].as_u64().is_some_and(|id| selected.contains(&id))).collect()
}
/// The ids of `rows` that `keep` accepts.
fn ids(rows: &[&Value], keep: impl Fn(&Value) -> bool) -> Vec<u64> {
rows.iter().filter(|r| keep(r)).filter_map(|r| r["id"].as_u64()).collect()
}
fn status_is(r: &Value, status: &str) -> bool {
r["status"].as_str() == Some(status)
}
fn report(app: &mut VectorcraftApp, r: Result<Value, String>, done: impl FnOnce(&Value) -> String) {
let msg = match r {
Ok(v) => done(&v),
Err(e) => e,
};
app.status(msg);
}
/// Relink images `ids` to a picked file.
pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec<u64>) {
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
let Some(path) = app.services.pick_open.as_mut().and_then(|f| f(&pick)) else { return };
let r = app.run("links.relink", json!({ "ids": ids, "path": path }));
report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len)));
}
/// Relink images `ids` (none: every missing one) to the files of their names in a picked folder.
fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
let Some(folder) = app.services.pick_folder.as_mut().and_then(|f| f()) else { return };
let ids = Some(ids).filter(|i| !i.is_empty());
let r = app.run("links.relink", json!({ "ids": ids, "folder": folder }));
report(app, r, |v| {
let missed: Vec<&str> = v["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
let n = v["relinked"].as_array().map_or(0, Vec::len);
if missed.is_empty() { format!("Relinked {n} image(s)") } else { format!("Relinked {n} image(s); not in that folder: {}", missed.join(", ")) }
});
}
/// Select image `id` and scroll it into view.
pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) {
let Ok(r) = app.run("links.goTo", json!({ "id": id })) else { return };
let b: Vec<f64> = r["bounds"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect();
if let (&[x0, y0, x1, y1], Some(v)) = (&b[..], app.view_mut()) {
v.center = vectorcraft_geom::Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0);
v.fitted = true;
}
}
/// Write embedded image `id` to a picked file (the web downloads it) and link it there.
pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) {
if let Some(dl) = app.services.download.as_mut() {
if let Ok(v) = app.session.execute("links.unembed", &json!({ "id": id }))
&& let (Some(name), Some(bytes)) = (v["name"].as_str(), v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode))
{
dl(name, &bytes);
}
return;
}
let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named(name))) else { return };
let r = app.run("links.unembed", json!({ "id": id, "path": path }));
report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default()));
}
/// The UI commands `links.editOriginal` (open the linked file in its app) and `links.reveal`
/// (show it in its folder) for image `id` (default: the selected linked image) → `{path}`.
pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Result<Value, String> {
let id = p.get("id").and_then(Value::as_u64).or_else(|| {
let list = app.session.execute("links.list", &json!({})).ok()?;
let sel = selected_images(app);
ids(&selected_rows(&list, &sel), |r| r["linked"] == true).first().copied()
});
let info = app.session.execute("links.info", &json!({ "id": id })).map_err(|e| e.to_string())?;
let path = info["path"].as_str().ok_or("select a linked image: an embedded one has no file")?.to_string();
if info["status"] == "missing" {
return Err(format!("{path} can't be found: relink it first"));
}
if reveal { crate::io::reveal_path(app, &path) } else { crate::io::open_in_app(app, &path) }?;
Ok(json!({ "path": path }))
}
/// A status badge: missing (red, ?), modified (amber, !), embedded (the image icon).
fn badge(ui: &Ui, r: egui::Rect, status: &str) {
let t = Tokens::get(ui.ctx());
let c = r.center();
let p = ui.painter();
let font = theme::semibold(10.0);
match status {
"missing" => {
p.circle_filled(c, 6.5, t.error);
p.text(c, egui::Align2::CENTER_CENTER, "?", font, egui::Color32::WHITE);
}
"modified" => {
let pts = vec![c + vec2(0.0, -7.0), c + vec2(7.0, 6.0), c + vec2(-7.0, 6.0)];
p.add(egui::Shape::convex_polygon(pts, t.warning, egui::Stroke::NONE));
p.text(c + vec2(0.0, 1.5), egui::Align2::CENTER_CENTER, "!", font, egui::Color32::BLACK);
}
"embedded" => icons::paint(ui, "image", egui::Rect::from_center_size(c, vec2(13.0, 13.0)), t.text_dim),
_ => {}
}
}
fn status_tip(r: &Value) -> String {
let path = r["found"].as_str().or(r["path"].as_str()).unwrap_or_default();
match r["status"].as_str().unwrap_or_default() {
"missing" => format!("Missing: {path}"),
"modified" => format!("Modified since it was read: {path}"),
"embedded" => "Embedded".into(),
_ => path.to_string(),
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let o = options(ui.ctx());
let Some(cache) = cached(app, ui.ctx(), &o) else {
widgets::dim_label(ui, "No document");
return;
};
let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return };
let selected = selected_images(app);
let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice);
let mut clicked: Option<(u64, bool, bool)> = None;
widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
ui.set_width(ui.available_width());
if rows.is_empty() {
let detail =
if o.show == "all" { "File › Place… brings in an image, linked or embedded." } else { "Nothing matches the Show filter." };
super::empty_state(ui, "link", "No images", detail);
return;
}
egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, true]).show(ui, |ui| {
let h = o.thumb.max(16.0) + 6.0;
for r in rows {
let Some(id) = r["id"].as_u64() else { continue };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click());
if !ui.is_rect_visible(rect) {
continue;
}
if selected.contains(&id) {
ui.painter().rect_filled(rect, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(rect, 0.0, t.hover);
}
let mut x = rect.left() + 6.0;
if o.thumb > 0.0 {
let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb));
ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE);
if let Some(n) = doc.node(NodeId(id)) {
super::layers::real_thumb(ui, &doc, n, tr);
}
ui.painter().rect_stroke(tr, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
x = tr.right() + 6.0;
}
let right = rect.right() - 22.0;
badge(
ui,
egui::Rect::from_center_size(egui::pos2(right + 10.0, rect.center().y), vec2(16.0, 16.0)),
r["status"].as_str().unwrap_or_default(),
);
let mut text_right = right - 4.0;
if let Some(page) = r["page"].as_u64() {
let g = ui.painter().layout_no_wrap(format!("{page}"), egui::FontId::proportional(11.5), t.text_dim);
text_right -= g.size().x;
ui.painter().galley(egui::pos2(text_right, rect.center().y - g.size().y / 2.0), g, t.text_dim);
text_right -= 6.0;
}
let name = r["name"].as_str().unwrap_or_default();
let clip = egui::Rect::from_min_max(egui::pos2(x, rect.top()), egui::pos2(text_right.max(x), rect.bottom()));
ui.painter().with_clip_rect(clip).text(
egui::pos2(x, rect.center().y),
egui::Align2::LEFT_CENTER,
name,
egui::FontId::proportional(12.5),
t.text,
);
let resp = resp.on_hover_text(status_tip(r));
if resp.double_clicked() {
clicked = Some((id, false, true));
} else if resp.clicked() {
clicked = Some((id, ui.input(|i| i.modifiers.shift || i.modifiers.command), false));
}
}
});
});
match clicked {
Some((id, _, true)) => go_to(app, id),
Some((id, true, _)) => {
app.run("select.toggle", json!({ "id": id })).ok();
}
Some((id, false, _)) => {
app.run("select.set", json!({ "ids": [id] })).ok();
}
None => {}
}
if o.info {
link_info(ui, cache.info.as_ref().map(|(_, i)| i));
}
let sel = selected_rows(&cache.list, &selected);
let modified = ids(&sel, |r| status_is(r, "modified") || r["preview"] == true && !status_is(r, "missing"));
let can_edit = sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing"));
widgets::bottom_bar(ui, |ui| {
let info = !o.info;
if widgets::icon_button(ui, if o.info { "chevron-down" } else { "chevron-right" }, "Show Link Info", false, 24.0).clicked() {
set_options(ui.ctx(), |o| o.info = info);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::icon_button_enabled(ui, "pencil", "Edit Original", false, can_edit, 24.0).clicked() {
crate::menus::invoke(app, "links.editOriginal", json!({}));
}
if widgets::icon_button_enabled(ui, "rotate-cw", "Update Link", false, !modified.is_empty(), 24.0).clicked() {
let r = app.run("links.update", json!({ "ids": modified }));
report(app, r, |v| format!("Updated {} image(s)", v["updated"].as_array().map_or(0, Vec::len)));
}
if widgets::icon_button_enabled(ui, "zoom-in", "Go To Link", false, sel.len() == 1, 24.0).clicked()
&& let Some(id) = sel.first().and_then(|r| r["id"].as_u64())
{
go_to(app, id);
}
if widgets::icon_button_enabled(ui, "link", "Relink…", false, !sel.is_empty() && can_pick(app), 24.0).clicked() {
relink(app, ids(&sel, |_| true));
}
});
});
}
/// Pick-a-file actions need a file dialog (the desktop app).
fn can_pick(app: &VectorcraftApp) -> bool {
app.services.pick_open.is_some()
}
/// Bytes as B, KB or MB.
fn size_label(bytes: u64) -> String {
match bytes {
b if b < 1024 => format!("{b} B"),
b if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
b => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
}
}
/// Milliseconds since the Unix epoch as a UTC date and time.
fn date_label(ms: u64) -> String {
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(i64::try_from(ms / 1000).unwrap_or(i64::MAX));
format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02} UTC")
}
/// The Link Info section: the details of `info` (`links.info`), or a hint.
fn link_info(ui: &mut Ui, info: Option<&Value>) {
widgets::divider(ui);
let Some(i) = info else {
widgets::dim_label(ui, "Select an image to see its Link Info.");
return;
};
let pair = |k: &str| {
let n: Vec<f64> = i[k].as_array().into_iter().flatten().filter_map(Value::as_f64).collect();
match n[..] {
[x, y] if (x - y).abs() < 0.05 => format!("{x:.0}"),
[x, y] => format!("{x:.0} × {y:.0}"),
_ => String::new(),
}
};
let row = super::doc_info::row;
row(ui, "Name", i["fileName"].as_str().or(i["name"].as_str()).unwrap_or_default().into());
row(ui, "Format", i["format"].as_str().unwrap_or_default().into());
row(ui, "Color Space", i["colorMode"].as_str().unwrap_or_default().into());
if let Some(l) = i["location"].as_str() {
row(ui, "Location", l.into());
}
row(ui, "PPI", pair("ppi"));
row(ui, "Effective PPI", pair("effectivePpi"));
row(ui, "Dimensions", format!("{} × {} px", i["pixelWidth"], i["pixelHeight"]));
let scale: Vec<f64> = i["scale"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect();
if let [x, y] = scale[..] {
row(ui, "Scale", format!("H: {x:.1}% V: {y:.1}%"));
}
row(ui, "Rotation", format!("{:.1}°", i["rotation"].as_f64().unwrap_or(0.0)));
if let Some(b) = i["fileSize"].as_u64() {
row(ui, "Size", size_label(b));
}
for (k, label) in [("created", "Created"), ("modified", "Modified")] {
if let Some(ms) = i[k].as_u64() {
row(ui, label, date_label(ms));
}
}
if let Some(p) = i["page"].as_u64() {
row(ui, "Page", p.to_string());
}
let status = match i["status"].as_str().unwrap_or_default() {
"ok" => "Linked",
"missing" => "Missing",
"modified" => "Modified",
_ => "Embedded",
};
row(ui, "Status", status.into());
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let o = options(ui.ctx());
// The rows the panel shows (the menu opens from it).
let list = pstate::<Cache>(ui.ctx(), "links-cache").list;
let selected = selected_images(app);
let sel = selected_rows(&list, &selected);
let linked = ids(&sel, |r| r["linked"] == true);
let embedded: Vec<&Value> = sel.iter().copied().filter(|r| status_is(r, "embedded")).collect();
let any = !sel.is_empty();
let missing_any = list["missing"].as_u64().unwrap_or(0) > 0;
if menu_item(ui, "Relink…", any && can_pick(app), false) {
relink(app, ids(&sel, |_| true));
}
if menu_item(ui, "Relink to Folder…", (any || missing_any) && app.services.pick_folder.is_some(), false) {
relink_to_folder(app, ids(&sel, |_| true));
}
if menu_item(ui, "Go To Link", sel.len() == 1, false)
&& let Some(id) = sel.first().and_then(|r| r["id"].as_u64())
{
go_to(app, id);
}
if menu_item(ui, "Update Link", sel.iter().any(|r| status_is(r, "modified")), false) {
let r = app.run("links.update", json!({ "ids": ids(&sel, |r| status_is(r, "modified")) }));
report(app, r, |v| format!("Updated {} image(s)", v["updated"].as_array().map_or(0, Vec::len)));
}
if menu_item(ui, "Edit Original", sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) {
crate::menus::invoke(app, "links.editOriginal", json!({}));
}
ui.separator();
if menu_item(ui, "Embed Image(s)", !linked.is_empty(), false) {
let r = app.run("links.embed", json!({ "ids": linked }));
report(app, r, |v| {
let missing = v["missing"].as_array().map_or(0, Vec::len);
let n = v["embedded"].as_array().map_or(0, Vec::len);
if missing == 0 { format!("Embedded {n} image(s)") } else { format!("Embedded {n} image(s); {missing} missing: relink them first") }
});
}
if menu_item(ui, "Unembed…", embedded.len() == 1, false)
&& let Some(r) = embedded.first()
&& let Some(id) = r["id"].as_u64()
{
let name = r["name"].as_str().unwrap_or("Image").to_string();
unembed(app, id, &name);
}
if menu_item(ui, "Placement Options…", any, false) {
crate::menus::invoke(app, "ui.placementOptionsDialog", json!({ "ids": ids(&sel, |_| true) }));
}
if menu_item(ui, "Show in Folder", sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) {
crate::menus::invoke(app, "links.reveal", json!({}));
}
ui.separator();
for (id, label) in SHOW {
if menu_item(ui, label, true, o.show == id) {
set_options(ui.ctx(), |o| o.show = id);
}
}
ui.separator();
for (id, label) in SORT {
if menu_item(ui, label, true, o.sort == Some(id)) {
set_options(ui.ctx(), |o| o.sort = if o.sort == Some(id) { None } else { Some(id) });
}
}
ui.separator();
for (px, label) in THUMBS {
if menu_item(ui, label, true, o.thumb == px) {
set_options(ui.ctx(), |o| o.thumb = px);
}
}
if menu_item(ui, "Show Link Info", true, o.info) {
set_options(ui.ctx(), |o| o.info = !o.info);
}
}

View File

@ -23,6 +23,7 @@ pub mod image_trace;
pub mod info;
pub mod layers;
pub mod library_panel;
pub mod links;
pub mod magic_wand;
pub mod navigator;
pub mod opentype;
@ -94,6 +95,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
flattener_preview::ID => flattener_preview::show(app, ui),
"attributes" => attributes::show(app, ui),
"colorThemes" => color_themes::show(app, ui),
links::ID => links::show(app, ui),
_ => {
dim_label(ui, "This panel is on the roadmap (see the parity plan).");
}
@ -136,6 +138,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
"attributes" => attributes::menu(app, ui),
"colorThemes" => color_themes::menu(app, ui),
"layers" => layers::menu(app, ui),
links::ID => links::menu(app, ui),
_ => {
ui.add_enabled(false, egui::Button::new("No options").frame(false));
}
@ -446,6 +449,8 @@ mod tests_effectedit;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_links;
#[cfg(test)]
mod tests_maskview;
#[cfg(test)]
mod tests_stroke;

View File

@ -30,6 +30,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
transform_section(app, ui);
divider(ui);
if n_sel == 1 && matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Image(_))) {
image_section(app, ui);
divider(ui);
}
if matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Text(_))) {
type_sections(app, ui);
divider(ui);
@ -109,6 +113,46 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// The one selected image: its file (or Embedded), colour mode and resolution, and the Links
/// panel's actions for it.
fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(i) = crate::place::selected_image_info(app) else { return };
let (name, details) = crate::place::image_summary(i);
let (Some(id), linked) = (i["id"].as_u64(), i["linked"] == true) else { return };
section_header(ui, if linked { "Linked File" } else { "Embedded Image" });
ui.add(egui::Label::new(egui::RichText::new(&name).size(12.0).color(t.text_strong)).truncate());
dim_label(ui, &details);
ui.add_space(4.0);
let w = (ui.available_width() - 6.0) / 2.0;
let links = crate::panels::links::ID;
ui.horizontal(|ui| {
if linked {
if widgets::flat_button(ui, "Embed", w).clicked() {
app.run("links.embed", json!({ "ids": [id] })).ok();
}
if widgets::flat_button(ui, "Edit Original", w).clicked() {
crate::menus::invoke(app, "links.editOriginal", json!({ "id": id }));
}
} else {
if widgets::flat_button(ui, "Unembed…", w).clicked() {
crate::panels::links::unembed(app, id, &name);
}
if widgets::flat_button(ui, "Image Trace", w).clicked() {
app.ui.open_panel = Some("imageTrace".into());
}
}
});
ui.horizontal(|ui| {
if app.services.pick_open.is_some() && widgets::flat_button(ui, "Relink…", w).clicked() {
crate::panels::links::relink(app, vec![id]);
}
if widgets::flat_button(ui, "Links", w).clicked() {
app.ui.open_panel = Some(links.into());
}
});
}
/// Whether the selection uses more than one colour (`recolor.colors`), which offers the Recolor
/// quick action; cached per document revision.
fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {

View File

@ -0,0 +1,154 @@
//! The Links panel, its flyout, Link Info, Edit Original / Show in Folder, the Placement Options
//! dialog and the Properties panel's image section, drawn headless.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
use crate::tests_labels::painted_text;
/// A fresh folder for one test (removed when dropped).
struct Folder(std::path::PathBuf);
impl Folder {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-links-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn file(&self, name: &str, bytes: &[u8]) -> String {
let p = self.0.join(name);
std::fs::write(&p, bytes).unwrap();
p.to_string_lossy().into_owned()
}
}
impl Drop for Folder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn png(w: u32, h: u32) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
/// A document with a linked `linked.png` and an embedded `embedded.png` (the selection).
fn app(dir: &Folder) -> (VectorcraftApp, u64, u64) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let a = dir.file("linked.png", &png(300, 200));
let b = dir.file("embedded.png", &png(40, 20));
let linked = app.run("file.place", json!({"path": a, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap();
let embedded = app.run("file.place", json!({"path": b, "at": [300, 200], "link": false})).unwrap()["ids"][0].as_u64().unwrap();
(app, linked, embedded)
}
fn panel(app: &mut VectorcraftApp) -> String {
painted_text(app, super::links::show)
}
#[test]
fn the_panel_lists_the_images_with_link_info_for_the_selected_one() {
let dir = Folder::new("panel");
let (mut app, linked, _) = app(&dir);
let text = panel(&mut app);
assert!(text.contains("linked.png") && text.contains("embedded.png"), "{text}");
// The selection is the embedded image.
assert!(text.contains("Embedded") && text.contains("Effective PPI"), "{text}");
app.run("select.set", json!({"ids": [linked]})).unwrap();
let text = panel(&mut app);
assert!(text.contains("Linked") && text.contains("300 × 200 px") && text.contains("Location"), "{text}");
// The flyout draws with every item.
let menu = painted_text(&mut app, super::links::menu);
for item in ["Relink to Folder…", "Embed Image(s)", "Placement Options…", "Show Missing", "Sort by Status", "Large Thumbnails", "Show in Folder"]
{
assert!(menu.contains(item), "{item}: {menu}");
}
}
#[test]
fn an_empty_document_shows_how_to_add_images() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
assert!(panel(&mut app).contains("No images"));
}
#[test]
fn edit_original_and_show_in_folder_hand_the_file_to_the_system() {
let dir = Folder::new("open");
let (mut app, linked, embedded) = app(&dir);
let opened: Rc<RefCell<Vec<(String, bool)>>> = Rc::default();
let (o, r) = (opened.clone(), opened.clone());
app.services.open_file = Some(Box::new(move |p: &str| {
o.borrow_mut().push((p.to_string(), false));
Ok(())
}));
app.services.reveal = Some(Box::new(move |p: &str| {
r.borrow_mut().push((p.to_string(), true));
Ok(())
}));
assert!(!crate::menus::enabled(&app, "links.editOriginal"), "the embedded image is selected");
assert!(app.run("links.editOriginal", json!({})).is_err());
app.run("select.set", json!({"ids": [linked, embedded]})).unwrap();
assert!(crate::menus::enabled(&app, "links.editOriginal"));
let r = app.run("links.editOriginal", json!({})).unwrap();
let path = r["path"].as_str().unwrap().to_string();
assert!(path.ends_with("linked.png"));
app.run("links.reveal", json!({"id": linked})).unwrap();
assert_eq!(*opened.borrow(), [(path.clone(), false), (path, true)]);
// Without the services (the web): an error, nothing opened.
(app.services.open_file, app.services.reveal) = (None, None);
assert!(app.run("links.reveal", json!({"id": linked})).is_err());
assert!(app.run("links.editOriginal", json!({"id": linked})).is_err());
}
#[test]
fn placement_options_dialog_sets_the_options() {
let dir = Folder::new("dialog");
let (mut app, linked, _) = app(&dir);
app.run("select.set", json!({"ids": [linked]})).unwrap();
assert!(crate::menus::enabled(&app, "ui.placementOptionsDialog"));
app.run("ui.placementOptionsDialog", json!({})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.str("preserve"), d.str("align")), ("placementOptions", "bounds".into(), "center".into()));
let text = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
assert!(text.contains("Clip to Bounding Box") && text.contains("Preserve"), "{text}");
let fields = &mut app.ui.dialog.as_mut().unwrap().fields;
fields.insert("preserve".into(), json!("fit"));
fields.insert("align".into(), json!("bottomRight"));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let r = app.run("links.placementOptions", json!({"ids": [linked]})).unwrap();
assert_eq!(r["placement"], json!({"preserve": "fit", "align": "bottomRight", "clip": false}));
}
#[test]
fn properties_shows_the_selected_image_and_its_link_actions() {
let dir = Folder::new("props");
let (mut app, linked, _) = app(&dir);
let text = painted_text(&mut app, super::properties::show);
assert!(text.contains("Embedded Image") && text.contains("embedded.png") && text.contains("Unembed…"), "{text}");
app.run("select.set", json!({"ids": [linked]})).unwrap();
let text = painted_text(&mut app, super::properties::show);
assert!(text.contains("Linked File") && text.contains("Edit Original") && text.contains("Embed"), "{text}");
}
#[test]
fn the_links_menu_items_run_commands() {
let tree = crate::menus::menu_tree();
let ids = |label: &str| {
let (_, items) = tree.iter().find(|(t, _)| *t == label).unwrap();
format!("{items:?}")
};
assert!(ids("Edit").contains("links.editOriginal"));
assert!(ids("Window").contains(super::links::ID));
}

View File

@ -335,24 +335,36 @@ pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter:
// ---------- 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 };
/// `image.info` of the one selected image, fetched again only when it changes (the Control bar's
/// and the Properties panel's image details).
pub fn selected_image_info(app: &mut VectorcraftApp) -> Option<&Value> {
let st = app.session.active()?;
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,
_ => return None,
};
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());
app.place.info.as_ref().map(|(_, i)| i)
}
/// An image's file name, and its colour mode and effective resolution (`image.info` `i`).
pub fn image_summary(i: &Value) -> (String, String) {
let name = i["link"].as_str().map(fileio::file_name).or_else(|| i["name"].as_str().map(str::to_string)).unwrap_or_default();
let ppi = i["ppi"].as_array().and_then(|a| a.first()).and_then(Value::as_f64).unwrap_or(0.0);
(name, format!("{} PPI: {}", i["colorMode"].as_str().unwrap_or("RGB"), fmt_ppi(ppi)))
}
/// The Control bar's details for the one selected image: Linked File or Embedded, its file name,
/// colour mode and effective resolution.
pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let Some(i) = selected_image_info(app) else { return };
let (name, details) = image_summary(i);
let t = Tokens::get(ui.ctx());
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.label(egui::RichText::new(details).size(12.0).color(t.text_dim));
ui.separator();
}

View File

@ -106,6 +106,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
(crate::panels::flattener_preview::ID, "Flattener Preview", "eye"),
("attributes", "Attributes", "settings"),
("colorThemes", "Color Themes", "sun"),
(crate::panels::links::ID, "Links", "link"),
];
/// Groups of icon panels separated by dividers in the collapsed column.

View File

@ -85,6 +85,10 @@ pub struct Tokens {
pub highlight: Color32,
/// The bleed outline around artboards (Document Setup → Bleed).
pub bleed: Color32,
/// Error marks (a missing linked file).
pub error: Color32,
/// Warning marks (a modified linked file).
pub warning: Color32,
}
impl Tokens {
@ -127,6 +131,8 @@ impl Tokens {
caption_close_text: Color32::WHITE,
highlight: hex(0xff3030),
bleed: hex(0xf03030),
error: hex(0xe34850),
warning: hex(0xf0a330),
};
match b {
Brightness::Dark => base,

View File

@ -305,3 +305,16 @@ DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`,
Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and
writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to
`dwg` shows, and on confirm answers, the hint to export DXF instead.
Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?}` for agents) opens the
`placementOptions` dialog for the selected images (fields `ids`, `preserve`: `transforms`/`bounds`/`fileDimensions`/
`fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as
one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's
`links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to
the system and return `{path}`.
Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved
gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder),
`copyLinks`, `linksFolder`, `relink`, `copyFonts`, `report`. `ui.dialog.confirm` saves unsaved changes, runs
`file.package` and then opens a `confirm` dialog whose OK runs `file.showPackage {folder}`; the web downloads the zip
instead. Document Info's flyout picks a category and Save… runs `docInfo.save {path?, selectionOnly?}`.

View File

@ -1073,3 +1073,44 @@ opened.
{"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}}
{"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}}
```
## Links panel
`links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top
first, as the Links panel does: `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth,
pixelHeight, path?, found?, page?, preview?}`. `links.info {id?}` is the Link Info: `image.info`'s fields plus
`status`, `format`, the file's `ppi` and the `effectivePpi`, `scale` (% of 100%), `rotation` (degrees,
counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`.
`links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose
file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file
(another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`.
`links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight,
clip?}` decides how a relinked or updated file takes an image's place (default `bounds`: stretched into the old
bounds; `clip` puts it in a clip group of the old bounds when it is larger). The UI commands `links.editOriginal` and
`links.reveal` open the linked file in its app or show it in its folder (desktop).
```json
{"name":"run_command","arguments":{"command":"links.list","params":{"show":"missing"}}}
{"name":"run_command","arguments":{"command":"links.placementOptions","params":{"ids":[12],"preserve":"fit","align":"top"}}}
{"name":"run_command","arguments":{"command":"links.unembed","params":{"id":14,"path":"/tmp/logo.png"}}}
```
## Package and Document Info
`file.package {folder?, name?, copyLinks?, linksFolder?, relink?, copyFonts?, report?}` copies a saved document (an
unsaved one is an error) into `folder/name` (default name `<document> Folder`): `<document>.vectorcraft`, its linked
files in `Links/` (relinked: the packaged document points at the copies; the open one doesn't change), the fonts its
type uses in `Fonts/` (fonts whose licence doesn't allow embedding are listed in `skippedFonts` instead) and
`<document> Report.txt`. Every option defaults to true. Without `folder` (the web, or an agent that wants the bytes) the
result carries the same files as a zip (`{name: "<name>.zip", dataBase64}`, entries under `<name>/`).
`document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`:
document, objects, graphicStyles, spotColors, patterns, gradients, symbols, fonts, fontDetails, linkedImages,
embeddedImages); `format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?}`) and
the package report write.
```json
{"name":"run_command","arguments":{"command":"file.package","params":{"folder":"/tmp/handoff","copyFonts":false}}}
{"name":"run_command","arguments":{"command":"document.info","params":{"format":"text","category":"fontDetails"}}}
```