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:
parent
2925e7f9b5
commit
8d8ca11e50
|
|
@ -1,4 +1,5 @@
|
|||
//! Document Info panel data and Object → Make Pixel Perfect.
|
||||
//! Document Info panel data, its categories and the text report (Document Info › Save…, File →
|
||||
//! Package), and Object → Make Pixel Perfect.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …}",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
|
|
@ -32,6 +33,160 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
]
|
||||
}
|
||||
|
||||
/// Document Info's categories: (id, title), in the order the panel and the report show them.
|
||||
pub const CATEGORIES: [(&str, &str); 11] = [
|
||||
("document", "Document"),
|
||||
("objects", "Objects"),
|
||||
("graphicStyles", "Graphic Styles"),
|
||||
("spotColors", "Spot Colors"),
|
||||
("patterns", "Pattern Objects"),
|
||||
("gradients", "Gradient Swatches"),
|
||||
("symbols", "Symbols"),
|
||||
("fonts", "Fonts"),
|
||||
("fontDetails", "Font Details"),
|
||||
("linkedImages", "Linked Images"),
|
||||
("embeddedImages", "Embedded Images"),
|
||||
];
|
||||
|
||||
/// The object counts of `document.info` and their labels.
|
||||
const OBJECT_LABELS: [(&str, &str); 16] = [
|
||||
("paths", "Paths"),
|
||||
("compoundPaths", "Compound Paths"),
|
||||
("groups", "Groups"),
|
||||
("clipGroups", "Clipping Masks"),
|
||||
("textObjects", "Text Objects"),
|
||||
("images", "Images"),
|
||||
("symbolInstances", "Symbol Instances"),
|
||||
("gradients", "Gradient Objects"),
|
||||
("patterns", "Pattern Objects"),
|
||||
("meshes", "Gradient Meshes"),
|
||||
("blends", "Blends"),
|
||||
("envelopes", "Envelopes"),
|
||||
("repeats", "Repeats"),
|
||||
("opacityMasks", "Opacity Masks"),
|
||||
("liveEffects", "Objects with Effects"),
|
||||
("guides", "Guides"),
|
||||
];
|
||||
|
||||
/// One category of Document Info: its id, title and `(label, value)` rows (a list item has an
|
||||
/// empty value).
|
||||
pub struct Section {
|
||||
pub id: &'static str,
|
||||
pub title: &'static str,
|
||||
pub rows: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// What a font's `fsType` lets a copy of it do.
|
||||
pub fn embedding_label(fs_type: u16) -> &'static str {
|
||||
match fs_type & 0x000f {
|
||||
0x0002 => "embedding not allowed",
|
||||
0x0004 => "embedding for preview and print",
|
||||
0x0008 => "embedding for editing",
|
||||
_ => "embedding allowed",
|
||||
}
|
||||
}
|
||||
|
||||
/// An image object as Document Info lists it: (name, width, height, linked file).
|
||||
type ImageRow = (String, u32, u32, Option<String>);
|
||||
|
||||
/// The Document Info categories of `d` (only `category` when given) from `info` (what
|
||||
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
|
||||
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
|
||||
let names = |k: &str| -> Vec<(String, String)> {
|
||||
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
|
||||
};
|
||||
let u = d.units;
|
||||
CATEGORIES
|
||||
.iter()
|
||||
.filter(|(id, _)| category.is_none_or(|c| c == *id))
|
||||
.map(|&(id, title)| {
|
||||
let rows = match id {
|
||||
"document" => {
|
||||
let mut rows = vec![
|
||||
("Name".to_string(), info["document"]["name"].as_str().unwrap_or_default().to_string()),
|
||||
("Color Mode".into(), info["document"]["colorMode"].as_str().unwrap_or_default().into()),
|
||||
("Units".into(), u.label().into()),
|
||||
("Artboards".into(), d.artboards.len().to_string()),
|
||||
];
|
||||
rows.extend(
|
||||
d.artboards.iter().map(|a| (a.name.clone(), format!("{} × {}", u.format(a.rect.width()), u.format(a.rect.height())))),
|
||||
);
|
||||
rows.push(("Raster Effects".into(), format!("{} ppi", d.raster_effects_ppi)));
|
||||
let counts = [
|
||||
("Swatches", "swatches"),
|
||||
("Character Styles", "characterStyles"),
|
||||
("Paragraph Styles", "paragraphStyles"),
|
||||
("Pattern Swatches", "patterns"),
|
||||
];
|
||||
rows.extend(counts.map(|(label, k)| (label.to_string(), info[k].to_string())));
|
||||
rows
|
||||
}
|
||||
"objects" => {
|
||||
OBJECT_LABELS.iter().filter_map(|(k, label)| Some((label.to_string(), info["objects"][k].as_u64()?.to_string()))).collect()
|
||||
}
|
||||
"graphicStyles" => names("graphicStyleNames"),
|
||||
"spotColors" => names("spotColors"),
|
||||
"patterns" => names("patternNames"),
|
||||
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
|
||||
"symbols" => names("symbols"),
|
||||
"fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(),
|
||||
"fontDetails" => {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
fonts
|
||||
.iter()
|
||||
.map(|(family, style)| {
|
||||
let detail = match db.face(family, style) {
|
||||
Some(f) if f.family.eq_ignore_ascii_case(family) => {
|
||||
let file =
|
||||
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
|
||||
format!("{file}; {}", embedding_label(f.fs_type()))
|
||||
}
|
||||
Some(f) => format!("missing: shown in {} {}", f.family, f.style),
|
||||
None => "missing".into(),
|
||||
};
|
||||
(format!("{family} {style}"), detail)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"linkedImages" => {
|
||||
images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect()
|
||||
}
|
||||
_ => images.iter().filter(|i| i.3.is_none()).map(|(name, w, h, _)| (name.clone(), format!("{w} × {h} px"))).collect(),
|
||||
};
|
||||
Section { id, title, rows }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `sections` as the plain-text report of document `name`: a heading, then each category's title
|
||||
/// and rows.
|
||||
pub fn report_text(name: &str, selection_only: bool, sections: &[Section]) -> String {
|
||||
let mut out = format!("Document Info: {name}\n");
|
||||
if selection_only {
|
||||
out.push_str("(the selection only)\n");
|
||||
}
|
||||
for sec in sections {
|
||||
out.push_str(&format!("\n{}\n", sec.title.to_uppercase()));
|
||||
if sec.rows.is_empty() {
|
||||
out.push_str("None\n");
|
||||
}
|
||||
for (label, value) in &sec.rows {
|
||||
if value.is_empty() {
|
||||
out.push_str(&format!("{label}\n"));
|
||||
} else {
|
||||
out.push_str(&format!("{label}: {value}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text report of the active document (`document.info {format: "text"}` as a string).
|
||||
pub fn report(s: &mut Session, selection_only: bool) -> Result<String> {
|
||||
let v = info(s, &json!({ "selectionOnly": selection_only, "format": "text" }))?;
|
||||
Ok(v["text"].as_str().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
fn paint_kind(p: &Paint) -> Option<&'static str> {
|
||||
match p {
|
||||
Paint::Gradient(_) => Some("gradients"),
|
||||
|
|
@ -41,6 +196,17 @@ fn paint_kind(p: &Paint) -> Option<&'static str> {
|
|||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.info";
|
||||
let category = str_param(p, "category");
|
||||
if let Some(c) = category.filter(|c| !CATEGORIES.iter().any(|(id, _)| id == c)) {
|
||||
let ids: Vec<&str> = CATEGORIES.iter().map(|(id, _)| *id).collect();
|
||||
return Err(bad(C, format!("category `{c}`: one of {}", ids.join(", "))));
|
||||
}
|
||||
let text = match str_param(p, "format") {
|
||||
None | Some("json") => false,
|
||||
Some("text") => true,
|
||||
Some(f) => return Err(bad(C, format!("format `{f}`: text or json"))),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let selection_only = bool_or(p, "selectionOnly", false);
|
||||
|
|
@ -49,6 +215,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut images: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut image_rows: Vec<ImageRow> = vec![];
|
||||
let mut patterns = BTreeSet::new();
|
||||
let mut symbols = BTreeSet::new();
|
||||
let mut spot = BTreeSet::new();
|
||||
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
|
||||
|
|
@ -63,7 +231,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
NodeKind::Compound { .. } => Some("compoundPaths"),
|
||||
NodeKind::Text(t) => {
|
||||
for run in &t.runs {
|
||||
fonts.insert(format!("{} {}", run.style.font_family, run.style.font_style));
|
||||
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone()));
|
||||
}
|
||||
Some("textObjects")
|
||||
}
|
||||
|
|
@ -72,6 +240,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
im.key.clone(),
|
||||
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
|
||||
);
|
||||
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
|
||||
image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone())));
|
||||
Some("images")
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
|
|
@ -94,6 +264,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(k) = paint_kind(paint) {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
}
|
||||
if let Paint::Pattern { pattern, .. } = paint {
|
||||
patterns.insert(pattern.clone());
|
||||
}
|
||||
if let Paint::Solid { swatch: Some(name), .. } = paint
|
||||
&& d.swatch(name).is_some_and(|s| s.spot)
|
||||
{
|
||||
|
|
@ -114,10 +287,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({
|
||||
let font_names: Vec<String> = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect();
|
||||
let mut out = json!({
|
||||
"document": document_summary(d),
|
||||
"objects": counts,
|
||||
"fonts": fonts,
|
||||
"fonts": font_names,
|
||||
"images": images,
|
||||
"symbols": symbols,
|
||||
"spotColors": spot,
|
||||
|
|
@ -127,7 +301,16 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"characterStyles": d.char_styles.len(),
|
||||
"paragraphStyles": d.para_styles.len(),
|
||||
"patterns": d.patterns.len(),
|
||||
}))
|
||||
"patternNames": patterns,
|
||||
});
|
||||
// The document's name: its file's, else its title.
|
||||
out["document"]["name"] = json!(st.title());
|
||||
let sections = sections(d, &out, &fonts, &image_rows, category);
|
||||
if text {
|
||||
return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) }));
|
||||
}
|
||||
out["sections"] = sections.iter().map(|s| json!({ "id": s.id, "title": s.title, "rows": s.rows })).collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn document_summary(d: &Document) -> Value {
|
||||
|
|
@ -205,6 +388,14 @@ mod tests {
|
|||
assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_permissions_read_from_fs_type() {
|
||||
assert_eq!(embedding_label(0), "embedding allowed");
|
||||
assert_eq!(embedding_label(0x0002), "embedding not allowed");
|
||||
assert_eq!(embedding_label(0x0004 | 0x0100), "embedding for preview and print");
|
||||
assert_eq!(embedding_label(0x0008), "embedding for editing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_perfect_snaps_edges_and_strokes() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub mod newdoc;
|
|||
mod object;
|
||||
mod opacitymask;
|
||||
mod overprint;
|
||||
mod package;
|
||||
mod paint;
|
||||
mod panelcmds;
|
||||
mod path;
|
||||
|
|
@ -223,6 +224,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(links::specs());
|
||||
v.extend(pdfcmds::specs());
|
||||
v.extend(fileio::dxf::specs());
|
||||
v.extend(package::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
//! File → Package: a saved document copied into a folder of its own with the files its linked
|
||||
//! images show (`Links/`), the fonts its type uses (`Fonts/`, leaving out fonts whose licence
|
||||
//! doesn't allow embedding) and a text report (the package's contents, then the Document Info
|
||||
//! report). The packaged document links to the copies (Relink); the open document doesn't change.
|
||||
//! Without a folder (the web, agents) the same files come back as a zip archive.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
|
||||
use super::fileio::{create_dir, write_file};
|
||||
use super::*;
|
||||
|
||||
const C: &str = "file.package";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"file.package",
|
||||
"Package",
|
||||
[],
|
||||
None,
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
|
||||
has_doc,
|
||||
package
|
||||
)]
|
||||
}
|
||||
|
||||
/// A file of the package: its path in the package folder (`/` separators) and bytes.
|
||||
type Entry = (String, Vec<u8>);
|
||||
|
||||
/// `name`, else the first free `stem 2.ext`, `stem 3.ext`… (compared without case, as most
|
||||
/// desktop file systems do); the name is taken.
|
||||
fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
|
||||
let p = Path::new(name);
|
||||
let stem = p.file_stem().map_or_else(|| name.to_string(), |s| s.to_string_lossy().into_owned());
|
||||
let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
||||
let out = unique_name(&stem, |s| taken.contains(&format!("{s}{ext}").to_lowercase())) + &ext;
|
||||
taken.insert(out.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
/// A font file's extension from its first bytes.
|
||||
fn font_ext(bytes: &[u8]) -> &'static str {
|
||||
match bytes.get(..4) {
|
||||
Some(b"OTTO") => "otf",
|
||||
Some(b"ttcf") => "ttc",
|
||||
_ => "ttf",
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts (family, style) of the type in the layers and symbols of `d`.
|
||||
fn used_fonts(d: &Document) -> BTreeSet<(String, String)> {
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut add = |n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
|
||||
}
|
||||
};
|
||||
d.walk(&mut add);
|
||||
for s in &d.symbols {
|
||||
s.art.walk(&mut add);
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
/// The options of a package.
|
||||
struct Options {
|
||||
copy_links: bool,
|
||||
links_folder: bool,
|
||||
relink: bool,
|
||||
copy_fonts: bool,
|
||||
report: bool,
|
||||
}
|
||||
|
||||
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = Options {
|
||||
copy_links: bool_or(p, "copyLinks", true),
|
||||
links_folder: bool_or(p, "linksFolder", true),
|
||||
relink: bool_or(p, "relink", true),
|
||||
copy_fonts: bool_or(p, "copyFonts", true),
|
||||
report: bool_or(p, "report", true),
|
||||
};
|
||||
let doc_path = s.doc()?.path.clone().ok_or_else(|| bad(C, "save the document first: Package collects a saved document"))?;
|
||||
let stem = Path::new(&doc_path).file_stem().map_or_else(|| "Untitled".into(), |s| s.to_string_lossy().into_owned());
|
||||
let name = str_param(p, "name").map_or_else(|| format!("{stem} Folder"), str::to_string);
|
||||
if name.trim().is_empty() || name == "." || name == ".." || name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||||
return Err(bad(C, format!("`{name}` can't name a folder")));
|
||||
}
|
||||
let folder = str_param(p, "folder");
|
||||
let root = folder.map(|f| Path::new(f).join(&name));
|
||||
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
|
||||
let st = s.doc()?;
|
||||
let mut entries: Vec<Entry> = vec![];
|
||||
let mut taken = HashSet::new();
|
||||
let mut lines = vec![];
|
||||
|
||||
// The linked files.
|
||||
let (mut copies, mut missing) = (BTreeMap::new(), vec![]);
|
||||
if o.copy_links {
|
||||
let dir = if o.links_folder { "Links/" } else { "" };
|
||||
let mut names = HashSet::new();
|
||||
lines.push("LINKED FILES".to_string());
|
||||
for f in super::links::linked_files(&st.doc, Some(&doc_path)) {
|
||||
let Some(bytes) = f.bytes else {
|
||||
lines.push(format!("{}: not found ({}), not copied", f.name, f.path));
|
||||
missing.push(f.name);
|
||||
continue;
|
||||
};
|
||||
let rel = format!("{dir}{}", free_name(&f.name, if o.links_folder { &mut names } else { &mut taken }));
|
||||
lines.push(format!("{} → {rel}", f.path));
|
||||
copies.insert(f.path, rel.clone());
|
||||
entries.push((rel, bytes));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
// The fonts.
|
||||
let (mut fonts, mut skipped) = (0, vec![]);
|
||||
if o.copy_fonts {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut files = HashSet::new();
|
||||
lines.push("FONTS".to_string());
|
||||
for (family, style) in used_fonts(&st.doc) {
|
||||
let font = format!("{family} {style}");
|
||||
let reason = match db.face(&family, &style) {
|
||||
Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => {
|
||||
let data = f.file_data();
|
||||
let file = f.path().and_then(Path::file_name).map_or_else(
|
||||
|| format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""),
|
||||
|n| n.to_string_lossy().into_owned(),
|
||||
);
|
||||
// A collection, or a style shown in another face's file, is copied once.
|
||||
if files.insert(file.to_lowercase()) {
|
||||
entries.push((format!("Fonts/{file}"), data.to_vec()));
|
||||
fonts += 1;
|
||||
}
|
||||
lines.push(format!("{font} → Fonts/{file}"));
|
||||
continue;
|
||||
}
|
||||
Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding",
|
||||
_ => "not available on this computer",
|
||||
};
|
||||
lines.push(format!("{font}: {reason}, not copied"));
|
||||
skipped.push(json!({ "font": font, "reason": reason }));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
// The document, linking to the copies.
|
||||
let mut doc = (*st.doc).clone();
|
||||
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
|
||||
if let Some(root) = &root
|
||||
&& let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy())
|
||||
{
|
||||
doc = d;
|
||||
}
|
||||
if o.relink && !copies.is_empty() {
|
||||
let copied = |im: &vectorcraft_doc::ImageObject| im.link.as_ref().is_some_and(|l| copies.contains_key(&l.path));
|
||||
doc.update_images(copied, |im| {
|
||||
if let Some(l) = &mut im.link
|
||||
&& let Some(rel) = copies.get(&l.path)
|
||||
{
|
||||
l.path = root.as_ref().map_or_else(|| rel.clone(), |r| r.join(rel).to_string_lossy().into_owned());
|
||||
(l.relative, l.modified) = (Some(rel.clone()), None);
|
||||
}
|
||||
});
|
||||
}
|
||||
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
|
||||
taken.insert(doc_name.to_lowercase());
|
||||
|
||||
// The report.
|
||||
if let Some(info) = info {
|
||||
let report_name = free_name(&format!("{stem} Report.txt"), &mut taken);
|
||||
let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n");
|
||||
lines.iter().for_each(|l| text.push_str(&format!("{l}\n")));
|
||||
text.push_str(&info);
|
||||
entries.push((report_name, text.into_bytes()));
|
||||
}
|
||||
|
||||
let files: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect();
|
||||
let mut out = json!({ "files": files, "links": copies.len(), "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped });
|
||||
match root {
|
||||
Some(root) => {
|
||||
for (rel, bytes) in &entries {
|
||||
let path = root.join(rel);
|
||||
if let Some(dir) = path.parent() {
|
||||
create_dir(&dir.to_string_lossy())?;
|
||||
}
|
||||
write_file(&path.to_string_lossy(), bytes)?;
|
||||
}
|
||||
out["folder"] = json!(root.to_string_lossy());
|
||||
}
|
||||
None => {
|
||||
let named: Vec<(String, &[u8])> = entries.iter().map(|(rel, b)| (format!("{name}/{rel}"), b.as_slice())).collect();
|
||||
let zip = zip(&named)?;
|
||||
out["name"] = json!(format!("{name}.zip"));
|
||||
out["bytes"] = json!(zip.len());
|
||||
out["dataBase64"] = json!(vectorcraft_format::base64_encode(&zip));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- zip archives ----------
|
||||
|
||||
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
|
||||
fn dos_time() -> (u16, u16) {
|
||||
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
|
||||
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
|
||||
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
|
||||
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
|
||||
}
|
||||
|
||||
/// A zip archive of `files` ((path with `/` separators, bytes)), each deflated, or stored when
|
||||
/// that isn't smaller.
|
||||
pub(crate) fn zip(files: &[(String, &[u8])]) -> Result<Vec<u8>> {
|
||||
let too_big = || bad(C, "too large for a zip archive: package to a folder");
|
||||
let io = |e: std::io::Error| EngineError::Other(e.to_string());
|
||||
let (time, date) = dos_time();
|
||||
let (mut out, mut central) = (Vec::new(), Vec::new());
|
||||
for (name, bytes) in files {
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(bytes);
|
||||
let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
enc.write_all(bytes).map_err(io)?;
|
||||
let deflated = enc.finish().map_err(io)?;
|
||||
let (method, data): (u16, &[u8]) = if deflated.len() < bytes.len() { (8, &deflated) } else { (0, bytes) };
|
||||
let offset = u32::try_from(out.len()).map_err(|_| too_big())?;
|
||||
let (packed, size) = (u32::try_from(data.len()).map_err(|_| too_big())?, u32::try_from(bytes.len()).map_err(|_| too_big())?);
|
||||
let name_len = u16::try_from(name.len()).map_err(|_| too_big())?;
|
||||
// Version 2.0, UTF-8 names, method, time, date, CRC-32, sizes, name length, no extra field.
|
||||
let common = [&20u16.to_le_bytes()[..], &0x0800u16.to_le_bytes(), &method.to_le_bytes(), &time.to_le_bytes(), &date.to_le_bytes()].concat();
|
||||
let sums = [crc.sum().to_le_bytes(), packed.to_le_bytes(), size.to_le_bytes()].concat();
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&common);
|
||||
out.extend_from_slice(&sums);
|
||||
out.extend_from_slice(&name_len.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(data);
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&20u16.to_le_bytes());
|
||||
central.extend_from_slice(&common);
|
||||
central.extend_from_slice(&sums);
|
||||
central.extend_from_slice(&name_len.to_le_bytes());
|
||||
// No extra field or comment, disk 0, no attributes.
|
||||
central.extend_from_slice(&[0; 12]);
|
||||
central.extend_from_slice(&offset.to_le_bytes());
|
||||
central.extend_from_slice(name.as_bytes());
|
||||
}
|
||||
let count = u16::try_from(files.len()).map_err(|_| too_big())?;
|
||||
let (start, len) = (u32::try_from(out.len()).map_err(|_| too_big())?, u32::try_from(central.len()).map_err(|_| too_big())?);
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&start.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
//! File → Package…: where the package folder goes, its name, and what it collects (the linked
|
||||
//! files, in a folder of their own or not, relinked or not; the fonts; the report). A document
|
||||
//! never saved is saved first (Save As); unsaved changes are saved before packaging. The web
|
||||
//! downloads the package as a zip.
|
||||
//!
|
||||
//! Fields: `folder` (the location), `name` (the package folder), `copyLinks`, `linksFolder`,
|
||||
//! `relink`, `copyFonts`, `report`. OK runs `file.package` with them, then offers to show the
|
||||
//! package folder (`file.showPackage`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::confirm::ask;
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "package";
|
||||
const LABEL: f32 = 90.0;
|
||||
/// The options: (field, label).
|
||||
const OPTIONS: [(&str, &str); 5] = [
|
||||
("copyLinks", "Copy Links"),
|
||||
("linksFolder", "Collect Links in a Separate Folder"),
|
||||
("relink", "Relink Linked Files to the Document"),
|
||||
("copyFonts", "Copy Fonts Used in the Document"),
|
||||
("report", "Create Report"),
|
||||
];
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Package".into(), body, confirm, ok: Some("Package"), min_width: 440.0, max_width: Some(460.0), ..DialogSpec::FORM };
|
||||
|
||||
/// Is there no file system to write a folder to (the web downloads a zip)?
|
||||
fn downloads(app: &VectorcraftApp) -> bool {
|
||||
app.services.download.is_some()
|
||||
}
|
||||
|
||||
/// Open the dialog for the active document; one never saved is offered Save As first.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let Some(path) = st.path.clone() else {
|
||||
ask(app, "Save the document first?", "Package collects a saved document with its linked files and fonts.", "file.saveAs", json!({}));
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
let p = std::path::Path::new(&path);
|
||||
let stem = p.file_stem().map_or_else(|| "Untitled".into(), |s| s.to_string_lossy().into_owned());
|
||||
let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let mut fields = json!({ "folder": folder, "name": format!("{stem} Folder") });
|
||||
for (k, _) in OPTIONS {
|
||||
fields[k] = json!(true);
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if !downloads(app) {
|
||||
widgets::label_row(ui, "Location:", LABEL, |ui| {
|
||||
form::text(ui, d, "folder", 230.0);
|
||||
if let Some(pick) = app.services.pick_folder.as_mut()
|
||||
&& ui.button("Choose…").clicked()
|
||||
&& let Some(f) = pick()
|
||||
{
|
||||
d.fields.insert("folder".into(), json!(f));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
widgets::label_row(ui, "Folder Name:", LABEL, |ui| {
|
||||
form::text(ui, d, "name", 230.0);
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Options");
|
||||
for (k, label) in OPTIONS {
|
||||
// The link options apply only when the links are copied.
|
||||
let enabled = !matches!(k, "linksFolder" | "relink") || d.bool("copyLinks");
|
||||
let on = d.bool(k);
|
||||
if widgets::check(ui, label, on, enabled) {
|
||||
d.fields.insert(k.into(), json!(!on));
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Fonts whose licence doesn't allow embedding are not copied.").size(11.5).color(t.text_dim));
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut params = Value::Object(d.fields.clone());
|
||||
let web = downloads(app);
|
||||
if web {
|
||||
params["folder"] = Value::Null;
|
||||
} else if d.str("folder").trim().is_empty() {
|
||||
return Err("choose a location for the package".into());
|
||||
}
|
||||
// The package holds the document as saved.
|
||||
if app.session.active().is_some_and(|st| st.is_dirty()) {
|
||||
io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
|
||||
}
|
||||
let r = app.run("file.package", params)?;
|
||||
app.ui.dialog = None;
|
||||
let n = r["files"].as_array().map_or(0, Vec::len);
|
||||
let missing = r["missingLinks"].as_array().map_or(0, Vec::len);
|
||||
let note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() };
|
||||
if web {
|
||||
let (name, data) = (r["name"].as_str().unwrap_or("package.zip"), r["dataBase64"].as_str().unwrap_or_default());
|
||||
let bytes = vectorcraft_format::base64_decode(data).ok_or("the package came back damaged")?;
|
||||
io::write_named(app, None, name, &bytes)?;
|
||||
app.status(format!("Packaged {n} file(s){note}"));
|
||||
return Ok(r);
|
||||
}
|
||||
let folder = r["folder"].as_str().unwrap_or_default().to_string();
|
||||
app.status(format!("Packaged {n} file(s) in {folder}{note}"));
|
||||
let detail = format!("{n} file(s) in {folder}{note}. Show the package folder?");
|
||||
ask(app, "Package created", &detail, "file.showPackage", json!({ "folder": folder }));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// `file.showPackage {folder}`: show a package folder in the file manager.
|
||||
pub fn show_package(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let folder = p.get("folder").and_then(Value::as_str).ok_or("missing `folder`")?.to_string();
|
||||
io::open_in_app(app, &folder)?;
|
||||
Ok(json!({ "folder": folder }))
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
//! File → Package…: the dialog, saving first, the folder or the downloaded zip, Show Package.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder for one test (removed when dropped).
|
||||
struct Folder(std::path::PathBuf);
|
||||
|
||||
impl Folder {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-package-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
fn path(&self, name: &str) -> String {
|
||||
self.0.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Folder {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) -> String {
|
||||
crate::tests_labels::painted_text(app, |app, ui| show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
/// An app writing through `std::fs`, with a saved document holding a placed linked image.
|
||||
fn saved(dir: &Folder) -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.services.write = Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string())));
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(300, 300, image::Rgba([1, 2, 3, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path("pic.png"), &png).unwrap();
|
||||
app.run("file.place", json!({"path": dir.path("pic.png")})).unwrap();
|
||||
app.run("document.save", json!({"path": dir.path("card.vectorcraft")})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsaved_document_is_offered_save_as_first() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
assert!(!crate::menus::enabled(&app, "ui.packageDialog"), "no document");
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("ui.packageDialog", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("__command")), (confirm::KIND, "file.saveAs".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_writes_the_folder_then_offers_to_show_it() {
|
||||
let dir = Folder::new("folder");
|
||||
let mut app = saved(&dir);
|
||||
app.run("ui.packageDialog", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("name"), d.bool("copyFonts")), ("package", "card Folder".to_string(), true));
|
||||
assert_eq!(std::path::Path::new(&d.str("folder")), dir.0.as_path(), "next to the document");
|
||||
let text = frame(&mut app);
|
||||
assert!(text.contains("Collect Links in a Separate Folder") && text.contains("Folder Name:"), "{text}");
|
||||
let out = dir.path("out");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(out));
|
||||
// Unsaved changes are saved first.
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let r = confirm(&mut app).unwrap();
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
let root = std::path::Path::new(&out).join("card Folder");
|
||||
assert!(root.join("card.vectorcraft").is_file() && root.join("Links/pic.png").is_file(), "{r}");
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("__command")), (confirm::KIND, "file.showPackage".to_string()));
|
||||
let opened: Rc<RefCell<Vec<String>>> = Rc::default();
|
||||
let o = opened.clone();
|
||||
app.services.open_file = Some(Box::new(move |p: &str| {
|
||||
o.borrow_mut().push(p.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(opened.borrow().first().map(std::path::PathBuf::from), Some(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_web_downloads_a_zip() {
|
||||
let dir = Folder::new("web");
|
||||
let mut app = saved(&dir);
|
||||
let got: Rc<RefCell<Vec<(String, usize)>>> = Rc::default();
|
||||
let g = got.clone();
|
||||
app.services.download = Some(Box::new(move |name: &str, b: &[u8]| g.borrow_mut().push((name.to_string(), b.len()))));
|
||||
app.run("ui.packageDialog", json!({})).unwrap();
|
||||
assert!(!frame(&mut app).contains("Location:"), "no folder to choose");
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let got = got.borrow();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].0, "card Folder.zip");
|
||||
assert!(got[0].1 > 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_without_a_location_says_so() {
|
||||
let dir = Folder::new("empty");
|
||||
let mut app = saved(&dir);
|
||||
app.run("ui.packageDialog", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(" "));
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.is_some(), "stays open");
|
||||
}
|
||||
|
|
@ -115,6 +115,20 @@ fn is_web(app: &VectorcraftApp) -> bool {
|
|||
app.services.download.is_some()
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, else to a file picked with `name` suggested next to the document (the
|
||||
/// web downloads them as `name`) → where they went.
|
||||
pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name: &str, bytes: &[u8]) -> Result<String, String> {
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let (_, folder) = suggested(app, "");
|
||||
pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })?
|
||||
}
|
||||
};
|
||||
write_to(&mut app.services, &path, bytes)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Where a file goes when no path was given: the suggested name on the web (a download), else the
|
||||
/// save panel's choice.
|
||||
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?}`.
|
||||
|
|
|
|||
20
docs/mcp.md
20
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 `<document> Folder`): `<document>.vectorcraft`, its linked
|
||||
files in `Links/` (relinked: the packaged document points at the copies; the open one doesn't change), the fonts its
|
||||
type uses in `Fonts/` (fonts whose licence doesn't allow embedding are listed in `skippedFonts` instead) and
|
||||
`<document> Report.txt`. Every option defaults to true. Without `folder` (the web, or an agent that wants the bytes) the
|
||||
result carries the same files as a zip (`{name: "<name>.zip", dataBase64}`, entries under `<name>/`).
|
||||
|
||||
`document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`:
|
||||
document, objects, graphicStyles, spotColors, patterns, gradients, symbols, fonts, fontDetails, linkedImages,
|
||||
embeddedImages); `format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?}`) and
|
||||
the package report write.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"file.package","params":{"folder":"/tmp/handoff","copyFonts":false}}}
|
||||
{"name":"run_command","arguments":{"command":"document.info","params":{"format":"text","category":"fontDetails"}}}
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue