Files
designcraft/crates/engine/src/cmd/package.rs
T
aiongg 832939691c CJK 1.3: documents bring their own fonts
Opening a .designcraft or .idml file from disk (file.open, file.openIdml,
book chapters, recovered documents) loads the fonts in the "Document
Fonts" folder beside it. Their faces come before installed faces of the
same family and style. The files are untrusted: at most 200 are read,
256 MB each (full CJK collections fit) and 512 MB in all; files over a
limit by their listed length are skipped unread, and damaged, oversized
and surplus files are listed in the open result's warnings. Document fonts stay
loaded for the session (the font database is process-wide).

Faces record their source (bundled, installed file, document file,
memory); font.list reports it. File > Package copies the font files the
document uses into the package's Document Fonts folder unless OS/2
fsType restricts the licence, and the report says which fonts weren't
copied and why.
2026-10-07 09:33:52 -04:00

264 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File › Package and Links › Copy Links To: gather a document with the files it uses.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use designcraft_doc::{AssetId, Document};
use designcraft_fonts::{FontFace, FontSource};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
noundo "file.package",
"Package…",
["File"],
None,
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files it uses in Document Fonts/ (unless their licence restricts it), an IDML copy, an optional PDF and a report → {dir, files, report}",
has_doc,
package
),
cmd!(
"links.copyTo",
"Copy Link(s) To…",
[],
None,
"{dir, assets?: [asset ids] (default: all)} — write the placed files to `dir` and relink to the copies → {copied}",
has_doc,
copy_links
),
]
}
/// A file name for asset `a` in a Links folder, unique among `taken`.
fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> String {
let base =
Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("asset-{}", id.0));
unique_name(base, taken)
}
/// `base`, or `base` numbered ("logo 2.png"), unique among `taken` (case-insensitively).
fn unique_name(base: String, taken: &mut Vec<String>) -> String {
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) => (s.to_string(), format!(".{e}")),
None => (base.clone(), String::new()),
};
let mut n = base;
let mut k = 2;
while taken.iter().any(|t| t.eq_ignore_ascii_case(&n)) {
n = format!("{stem} {k}{ext}");
k += 1;
}
taken.push(n.clone());
n
}
/// Write each asset's bytes into `dir`; returns the relinked document and the paths written.
fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec<PathBuf>)> {
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let mut out = d.clone();
let mut taken = Vec::new();
let mut written = Vec::new();
let mut ids: Vec<AssetId> = d.assets.keys().copied().collect();
ids.sort_by_key(|i| i.0);
for id in ids {
if only.is_some_and(|o| !o.contains(&id)) {
continue;
}
let a = &d.assets[&id];
let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken);
let path = dir.join(&name);
std::fs::write(&path, a.data.as_slice()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
let mut na = (**a).clone();
na.link = Some(path.to_string_lossy().to_string());
out.assets.insert(id, Arc::new(na));
written.push(path);
}
Ok((out, written))
}
/// Copy the font file of `face` into `dir` (created when first needed) unless its licence
/// restricts it or it is already there (`copied`: the source files copied) → what the package
/// report adds after the font's name (nothing when copied).
fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String {
let from = match &face.source {
FontSource::Bundled => return " — included with DesignCraft".into(),
FontSource::Memory => return " — not copied: no font file".into(),
FontSource::Installed(p) | FontSource::Document(p) => p,
};
if face.restricted_licence() {
return " — not copied: its licence doesn't allow it".into();
}
if copied.contains(from) {
return String::new();
}
let name = from.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "font".into());
let to = dir.join(unique_name(name, taken));
// Packaging into the folder the font is in: it is there already (and copying a file onto
// itself empties it on some systems).
let same = std::fs::canonicalize(&to).is_ok_and(|t| std::fs::canonicalize(from).is_ok_and(|f| f == t));
match std::fs::create_dir_all(dir).and_then(|_| if same { Ok(0) } else { std::fs::copy(from, &to) }) {
Ok(_) => {
copied.push(from.clone());
files.push(to);
String::new()
}
Err(e) => format!(" — not copied: {e}"),
}
}
fn package(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?);
let st = s.doc()?;
let title = st.doc.title.clone();
let fonts = s.execute("font.list", &json!({}))?;
let links = s.execute("links.list", &json!({}))?;
let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null);
let d = s.doc()?.doc.clone();
let (packed, mut files) = write_links(&d, &dir.join("Links"), None)?;
let doc_path = dir.join(format!("{title}.designcraft"));
std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?;
files.insert(0, doc_path);
// The font files, and what the report says about each font.
let db = designcraft_fonts::FontDb::global();
let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new());
let mut font_lines = Vec::new();
for f in fonts.as_array().into_iter().flatten() {
let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""));
let missing = f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false);
let note =
if missing { " — MISSING".to_string() } else { copy_font(&db.face(family, style), &fonts_dir, &mut copied, &mut taken, &mut files) };
font_lines.push(format!(" {family} {style}{note}\n"));
}
if p.get("idml").and_then(Value::as_bool).unwrap_or(true) {
let path = dir.join(format!("{title}.idml"));
std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
files.push(path);
}
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
let path = dir.join(format!("{title}.pdf"));
let bytes = designcraft_pdf::export_pdf(&packed, &s.cache, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?;
std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
files.push(path);
}
// The report: fonts (missing first), links, preflight, the user's instructions.
let mut r = format!("Package report: {title}\n\n");
if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) {
r += &format!("Instructions\n{t}\n\n");
}
r += "Fonts\n";
r += &font_lines.concat();
r += "\nLinks\n";
for l in links.as_array().into_iter().flatten() {
r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or(""));
}
if let Some(issues) = pre.get("issues").and_then(Value::as_array) {
r += &format!("\nPreflight: {} issue(s)\n", issues.len());
for i in issues {
r += &format!(" {}\n", i.get("message").and_then(Value::as_str).unwrap_or(""));
}
}
let report = dir.join("Instructions.txt");
std::fs::write(&report, &r).map_err(|e| EngineError::Other(format!("{}: {e}", report.display())))?;
files.push(report);
Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>(), "report": r}))
}
fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?);
let only: Option<Vec<AssetId>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect());
let d = s.doc()?.doc.clone();
let (relinked, written) = write_links(&d, &dir, only.as_deref())?;
s.edit(|doc, _| {
doc.assets = relinked.assets;
Ok(())
})?;
Ok(json!({"copied": written.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_writes_document_links_and_report() {
let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let svg = dir.join("logo.svg");
std::fs::write(&svg, r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40"/></svg>"##).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap();
s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap();
let out = dir.join("Pkg");
let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": true, "instructions": "Print on matte."})).unwrap();
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
assert!(files.iter().any(|f| f.ends_with(".designcraft")));
assert!(files.iter().any(|f| f.ends_with(".idml")));
assert!(files.iter().any(|f| f.ends_with(".pdf")));
assert!(out.join("Links").join("logo.svg").exists());
let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap();
assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}");
assert!(report.contains("Preflight:"), "{report}");
// The packaged document links to its Links folder.
let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap();
let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap();
let link = packed.assets.values().next().unwrap().link.clone().unwrap();
assert!(link.contains("Links"), "{link}");
// Copy Links To relinks the open document.
let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap();
assert_eq!(r["copied"], 1);
assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn package_copies_the_fonts_their_licences_allow() {
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
use designcraft_fonts::testing::{font_with, with_fs_type};
const FREE: &str = "DocFont Package Free";
const RESTRICTED: &str = "DocFont Package Restricted";
let dir = std::env::temp_dir().join(format!("dc-package-fonts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER);
std::fs::create_dir_all(&fonts).unwrap();
std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap();
std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap();
// Text in both fonts, and in the bundled default font.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Free Restricted Default"})).unwrap();
for (a, b, family) in [(0, 4, FREE), (5, 15, RESTRICTED)] {
s.execute("text.select", &json!({"story": r["story"], "anchor": a, "focus": b})).unwrap();
s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap();
}
let src = dir.join("src").join("Fonts.designcraft");
s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap();
let mut s = Session::new();
assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 2);
let out = dir.join("Pkg");
let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap();
let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER);
assert!(packed_fonts.join("free.ttf").exists());
assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out");
assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]);
let report = r["report"].as_str().unwrap();
assert!(report.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}");
assert!(report.contains(&format!("{FREE} Regular\n")), "{report}");
assert!(report.contains(&format!("{} Regular — included with DesignCraft", designcraft_fonts::DEFAULT_FAMILY)), "{report}");
// The packaged document opens with its fonts.
let doc = r["files"][0].as_str().unwrap().to_string();
assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 1);
// Packaging into the folder the fonts come from leaves them whole.
let len = std::fs::metadata(fonts.join("free.ttf")).unwrap().len();
s.execute("file.package", &json!({"dir": dir.join("src").to_string_lossy(), "idml": false})).unwrap();
assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len);
let _ = std::fs::remove_dir_all(&dir);
}
}