Files
vectorcraft/crates/engine/src/cmd/package.rs
T
Juan Manuel Verges SolanasandClaude Opus 5.5 562e09707a Fix #813: type can name an installed version of its font
A font library can hold two versions of one family and style (a new
Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept
only the first one loaded, so type set in the other couldn't be matched
when a PDF or .ai was opened: it came in as outlines. Now every version
loads (the first stays the one family and style resolve to), PDF import
tries the other versions when the glyphs differ, and type set in a version
the family and style alone wouldn't pick names it in CharStyle::font_version
(left out when None, so documents without it are unchanged). Layout,
shaping, the substituted-glyph highlight, Find Font, Package and Document
Info follow it; changing the font clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:26:51 +00:00

261 lines
12 KiB
Rust

//! 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, HashSet};
use std::io::Write;
use std::path::Path;
use serde_json::{Value, json};
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 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 used in super::fonts::used_fonts(&st.doc) {
let font = super::fonts::font_label(&used);
let (family, style, version) = used;
// Found by any of its names, as the canvas draws it (in the version the type names).
let resolved = db
.resolve(&family, &style)
.filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing)
.map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
// A style the family lacks is shown in its closest style, whose file is copied.
let shown = match &resolved {
Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style),
_ => String::new(),
};
let reason = match resolved {
Some((f, _)) if 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}{shown} → Fonts/{file}"));
continue;
}
Some(_) => "its licence doesn't allow embedding",
None => "not available on this computer",
};
lines.push(format!("{font}{shown}: {reason}, not copied"));
skipped.push(json!({ "font": font, "reason": reason }));
}
lines.push(String::new());
}
// The document, linking to the copies.
let mut doc = (*st.doc).clone();
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
if let Some(root) = &root
&& let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy())
{
doc = d;
}
if o.relink && !copies.is_empty() {
let copied = |im: &vectorcraft_doc::ImageObject| im.link.as_ref().is_some_and(|l| copies.contains_key(&l.path));
doc.update_images(copied, |im| {
if let Some(l) = &mut im.link
&& let Some(rel) = copies.get(&l.path)
{
l.path = root.as_ref().map_or_else(|| rel.clone(), |r| r.join(rel).to_string_lossy().into_owned());
(l.relative, l.modified) = (Some(rel.clone()), None);
}
});
}
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
taken.insert(doc_name.to_lowercase());
// The report.
if let Some(info) = info {
let report_name = free_name(&format!("{stem} Report.txt"), &mut taken);
let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n");
lines.iter().for_each(|l| text.push_str(&format!("{l}\n")));
text.push_str(&info);
entries.push((report_name, text.into_bytes()));
}
let files: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect();
let mut out = json!({ "files": files, "links": copies.len(), "fonts": fonts, "missingLinks": missing, "skippedFonts": skipped });
match root {
Some(root) => {
for (rel, bytes) in &entries {
let path = root.join(rel);
if let Some(dir) = path.parent() {
create_dir(&dir.to_string_lossy())?;
}
write_file(&path.to_string_lossy(), bytes)?;
}
out["folder"] = json!(root.to_string_lossy());
}
None => {
let named: Vec<(String, &[u8])> = entries.iter().map(|(rel, b)| (format!("{name}/{rel}"), b.as_slice())).collect();
let zip = zip(&named)?;
out["name"] = json!(format!("{name}.zip"));
out["bytes"] = json!(zip.len());
out["dataBase64"] = json!(vectorcraft_format::base64_encode(&zip));
}
}
Ok(out)
}
// ---------- zip archives ----------
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
fn dos_time() -> (u16, u16) {
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
}
/// A zip archive of `files` ((path with `/` separators, bytes)), each deflated, or stored when
/// that isn't smaller.
pub(crate) fn zip(files: &[(String, &[u8])]) -> Result<Vec<u8>> {
let too_big = || bad(C, "too large for a zip archive: package to a folder");
let io = |e: std::io::Error| EngineError::Other(e.to_string());
let (time, date) = dos_time();
let (mut out, mut central) = (Vec::new(), Vec::new());
for (name, bytes) in files {
let mut crc = flate2::Crc::new();
crc.update(bytes);
let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(bytes).map_err(io)?;
let deflated = enc.finish().map_err(io)?;
let (method, data): (u16, &[u8]) = if deflated.len() < bytes.len() { (8, &deflated) } else { (0, bytes) };
let offset = u32::try_from(out.len()).map_err(|_| too_big())?;
let (packed, size) = (u32::try_from(data.len()).map_err(|_| too_big())?, u32::try_from(bytes.len()).map_err(|_| too_big())?);
let name_len = u16::try_from(name.len()).map_err(|_| too_big())?;
// Version 2.0, UTF-8 names, method, time, date, CRC-32, sizes, name length, no extra field.
let common = [&20u16.to_le_bytes()[..], &0x0800u16.to_le_bytes(), &method.to_le_bytes(), &time.to_le_bytes(), &date.to_le_bytes()].concat();
let sums = [crc.sum().to_le_bytes(), packed.to_le_bytes(), size.to_le_bytes()].concat();
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&common);
out.extend_from_slice(&sums);
out.extend_from_slice(&name_len.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(data);
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes());
central.extend_from_slice(&common);
central.extend_from_slice(&sums);
central.extend_from_slice(&name_len.to_le_bytes());
// No extra field or comment, disk 0, no attributes.
central.extend_from_slice(&[0; 12]);
central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let count = u16::try_from(files.len()).map_err(|_| too_big())?;
let (start, len) = (u32::try_from(out.len()).map_err(|_| too_big())?, u32::try_from(central.len()).map_err(|_| too_big())?);
out.extend_from_slice(&central);
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0; 4]);
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
Ok(out)
}