M4.64: File > Package collects the document, its linked files and fonts with a report; Document Info categories and Save

File > Package (saved documents; unsaved changes are saved first) writes
<folder>/<name>/<document>.vectorcraft, the linked files in Links/ (relinked in the
packaged copy), the fonts its type uses in Fonts/ (leaving out fonts whose licence
doesn't allow embedding) and <document> Report.txt, then offers Show Package. Without a
folder (the web, agents) the same files come back as a zip.

Document Info's flyout picks one category (document, objects, styles, spot colours,
patterns, gradients, symbols, fonts, font details, linked and embedded images) and Save…
writes the text report Package shares.

Engine: file.package {folder?, name?, copyLinks?, linksFolder?, relink?, copyFonts?,
report?}; document.info gains category, format: text and sections. Text: FontFace
path, fs_type and embeddable. UI: ui.packageDialog (dialog `package`), file.showPackage,
docInfo.save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 11:17:50 +07:00
parent 2925e7f9b5
commit 8d8ca11e50
15 changed files with 1128 additions and 80 deletions

View File

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

View File

@ -254,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

View File

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

View File

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

View File

@ -1082,6 +1082,8 @@ mod tests_outlinestroke;
#[cfg(test)]
mod tests_overprint;
#[cfg(test)]
mod tests_package;
#[cfg(test)]
mod tests_paintproxy;
#[cfg(test)]
mod tests_panelcmds;

View File

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

View File

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

View File

@ -33,6 +33,7 @@ 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;
@ -211,6 +212,7 @@ registry! {
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).
@ -317,6 +319,8 @@ mod tests_export;
#[cfg(test)]
mod tests_import_pdf;
#[cfg(test)]
mod tests_package;
#[cfg(test)]
mod tests_raster_formats;
#[cfg(test)]

View File

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

View File

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

View File

@ -115,6 +115,20 @@ fn is_web(app: &VectorcraftApp) -> bool {
app.services.download.is_some()
}
/// Write `bytes` to `path`, else to a file picked with `name` suggested next to the document (the
/// web downloads them as `name`) → where they went.
pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name: &str, bytes: &[u8]) -> Result<String, String> {
let path = match path {
Some(p) => p,
None => {
let (_, folder) = suggested(app, "");
pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })?
}
};
write_to(&mut app.services, &path, bytes)?;
Ok(path)
}
/// Where a file goes when no path was given: the suggested name on the web (a download), else the
/// save panel's choice.
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {

View File

@ -449,6 +449,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{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).
@ -825,6 +838,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"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)
@ -1054,6 +1070,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"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,
}
}
@ -1119,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"),

View File

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

View File

@ -312,3 +312,9 @@ Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?
one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's
`links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to
the system and return `{path}`.
Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved
gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder),
`copyLinks`, `linksFolder`, `relink`, `copyFonts`, `report`. `ui.dialog.confirm` saves unsaved changes, runs
`file.package` and then opens a `confirm` dialog whose OK runs `file.showPackage {folder}`; the web downloads the zip
instead. Document Info's flyout picks a category and Save… runs `docInfo.save {path?, selectionOnly?}`.

View File

@ -1094,3 +1094,23 @@ bounds; `clip` puts it in a clip group of the old bounds when it is larger). The
{"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"}}}
```