From 8d8ca11e502ee2ed6cc03c699716c80c31107fdf Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 11:17:50 +0700 Subject: [PATCH] M4.64: File > Package collects the document, its linked files and fonts with a report; Document Info categories and Save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File > Package (saved documents; unsaved changes are saved first) writes //.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 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 --- crates/engine/src/cmd/docinfo.rs | 203 ++++++++++++++- crates/engine/src/cmd/links.rs | 26 ++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/package.rs | 265 ++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_package.rs | 195 ++++++++++++++ crates/text/src/fontdb.rs | 36 ++- crates/ui-egui/src/dialogs/mod.rs | 4 + crates/ui-egui/src/dialogs/package.rs | 123 +++++++++ crates/ui-egui/src/dialogs/tests_package.rs | 117 +++++++++ crates/ui-egui/src/io.rs | 14 ++ crates/ui-egui/src/menus.rs | 19 +- crates/ui-egui/src/panels/doc_info.rs | 176 ++++++++----- docs/control-protocol.md | 6 + docs/mcp.md | 20 ++ 15 files changed, 1128 insertions(+), 80 deletions(-) create mode 100644 crates/engine/src/cmd/package.rs create mode 100644 crates/engine/src/tests_package.rs create mode 100644 crates/ui-egui/src/dialogs/package.rs create mode 100644 crates/ui-egui/src/dialogs/tests_package.rs diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index c92dbff8..29a0d744 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -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 { "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 { ] } +/// 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); + +/// 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
{ + 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 { + 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 { + 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 { let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut fonts = BTreeSet::new(); let mut images: BTreeMap = BTreeMap::new(); + let mut image_rows: Vec = 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 { 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 { 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 { 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 { } }); } - Ok(json!({ + let font_names: Vec = 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 { "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(); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 9d72f3e2..e9260936 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -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>, +} + +/// 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 { + 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 diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8d5c8c5b..941d84ae 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs new file mode 100644 index 00000000..f21fbede --- /dev/null +++ b/crates/engine/src/cmd/package.rs @@ -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 { + vec![cmd!( + "file.package", + "Package", + [], + None, + "{folder?, name?: (default: \" 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 ( Report.txt)} copy the saved document as it is now into folder/name as .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: \".zip\", dataBase64} (a zip of the files in /) instead of folder", + has_doc, + package + )] +} + +/// A file of the package: its path in the package folder (`/` separators) and bytes. +type Entry = (String, Vec); + +/// `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 { + 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 { + 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 = 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> { + 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) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ed93359b..d3b8706a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs new file mode 100644 index 00000000..2c607fc1 --- /dev/null +++ b/crates/engine/src/tests_package.rs @@ -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> { + 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 = 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::>(), 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()); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 20762b37..da064a5a 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -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, 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 { +fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option) -> Option { 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) -> 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, 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; } } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 317becf3..bfdd5227 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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)] diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs new file mode 100644 index 00000000..ba9d846b --- /dev/null +++ b/crates/ui-egui/src/dialogs/package.rs @@ -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 { + 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 { + 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 { + 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 })) +} diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs new file mode 100644 index 00000000..e6279378 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -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>> = 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>> = 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"); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 98dc0bb7..3388aef0 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -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, name: &str, bytes: &[u8]) -> Result { + 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 { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d112e12d..a6cf8e75 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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)> { ), 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"), diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index baad743c..7352868b 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -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 { + 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 { + 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>> = 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}"); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2a3c5a5e..7ac6e247 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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?}`. diff --git a/docs/mcp.md b/docs/mcp.md index 3754fddd..c38c9cae 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 ` Folder`): `.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 +` 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: ".zip", dataBase64}`, entries under `/`). + +`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"}}} +``` +