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:
commit
d767fa22e0
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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, §ions) }));
|
||||
}
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -280,7 +280,10 @@ fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
|||
let mut d = Document::new(width as f64, height as f64);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None }));
|
||||
let mut n = Node::new(
|
||||
id,
|
||||
NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
|
||||
);
|
||||
n.name = Some(name.to_string());
|
||||
d.images.insert(key, blob);
|
||||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
|
|
|
|||
|
|
@ -514,6 +514,18 @@ pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
|||
Some((m.len(), modified))
|
||||
}
|
||||
|
||||
/// When the file at `path` was created (ms since the Unix epoch), when the file system keeps it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_created(path: &str) -> Option<u64> {
|
||||
let t = std::fs::metadata(path).ok()?.created().ok()?;
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_created(_: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working directory (as given when absolute already, or when
|
||||
/// that fails).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
|
|||
|
|
@ -1039,7 +1039,10 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
|
|||
fn raster_node(d: &mut Document, r: Raster) -> Node {
|
||||
let key = unique_key(d, "flattened");
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png));
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None }));
|
||||
let image = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() }),
|
||||
);
|
||||
let Some(path) = r.clip else { return image };
|
||||
super::rasterfx::clip_group(d, path, FillRule::NonZero, image)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¢ral);
|
||||
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)
|
||||
}
|
||||
|
|
@ -213,7 +213,14 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
|||
Art::Image(img) => {
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
super::links::store_image(d, &img.key, img.blob, link.is_some());
|
||||
let im = ImageObject { key: img.key, width: img.width, height: img.height, xf: Affine::scale_non_uniform(sx, sy), link };
|
||||
let im = ImageObject {
|
||||
key: img.key,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
xf: Affine::scale_non_uniform(sx, sy),
|
||||
link,
|
||||
placement: Default::default(),
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::Image(im))
|
||||
}
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
|
||||
|
|
@ -480,7 +487,7 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "image.info";
|
||||
let st = s.doc()?;
|
||||
let id = match (id_param(p, "id"), &st.selection.objects[..]) {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,8 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
|||
}
|
||||
out.images.insert(key.clone(), ImageBlob::new("image/png", png));
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut image = Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
|
||||
let mut image =
|
||||
Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }));
|
||||
image.name = Some("Raster effect".into());
|
||||
Some(image)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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(|_| ())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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! {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?}`.
|
||||
|
|
|
|||
41
docs/mcp.md
41
docs/mcp.md
|
|
@ -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"}}}
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue