Files
designcraft/crates/engine/src/cmd/export_batch.rs
T
4f3bb5340e Pages: parents and alternate layouts; export and print layouts and versions (#1342)
* Pages: more parents and alternate layouts as columns in the Pages panel

Parents
- layout.parents.duplicate copies a parent spread (pages and items) under the next free prefix.
- layout.parents.options also renames the prefix (1-4 characters, no "-") and the name; basedOn is now optional.
- layout.parents.new validates prefix and name and takes basedOn.
- Pages panel: a "+ New Parent" row adds B, C… in one click. The parents' context menu and the panel menu have New Parent…, Duplicate Parent Spread, Parent Options… and Delete Parent, named after the selected parent, or a submenu of the parents when none is selected. A new Parent Options dialog has Prefix, Name and Based on Parent.

Alternate layouts
- Section.layout marks the section that starts a layout; Document::layouts() lists them.
- layout.createAlternate copies one layout (from), not every page; new layout.alternate.duplicate, .delete and .options (rename, resize).
- Each layout starts a spread of its own; sections now follow their pages when pages are inserted or deleted.
- Pages panel: with more than one layout, each is a column side by side with a header (name, size, ▾ menu: create, duplicate, options, delete) and a + column to add one.

Tests cover the commands' happy and error paths, undo, serde, and the panel's menus and columns.

* Pages: address review of parents and alternate layouts

- Deleting a layout's first page keeps the layout's name when another section lands on the same page.
- A parent's full label (prefix-name) must be unique; parents may still share a prefix.
- Alternate layout names round-trip through IDML (Section AlternateLayout / AlternateLayoutLength).
- The Pages panel's selected parent is per document and no longer saved in ui.json.
- Layout ▸ Parent Options… and Alternate Layout Options… open the Pages panel's dialogs.
- The Pages panel computes the layouts once per frame.

* Export: layouts and versions in one export, with presets; print chosen layouts

- file.exportBatch writes the chosen alternate layouts (a file per layout, or all in one PDF) in several versions at once: PDF with any marks, bleed and standard, PNG or JPEG at a resolution. Each version goes in a folder of its own, and file names come from a template ({document}, {layout}, {version}, {date}). Names are made safe and unique. dryRun lists the files; without a dir the files come back as base64 (web).
- Export presets: file.exportPresets, file.saveExportPreset, file.deleteExportPreset, kept in prefs.json. Built-ins: Print + Screen, Press PDF per Layout, Images per Layout, All Layouts in One PDF.
- file.print takes layouts and separateJobs (a job per layout, titled after it).
- File ▸ Export Layouts and Versions… dialog: preset, layouts, versions (add from templates, duplicate, remove, quick PDF choices or custom marks), name template, and a live list of the files it will write. It opens from a layout column's ▾ menu as "Export Layout…". The desktop asks for a folder; the web downloads each file.
- The Print dialog prints chosen layouts, together or separately; the Export PDF dialog fills the page range from a layout.
- crate::io::create_dir_all keeps folder creation inside the automation write root.

* Export: address review of layouts and versions

- An exported image is capped at 80 million pixels, the limit EPUB page images already use.
- The Print dialog stays open and says why when no layout is chosen.
- Clicking a layout column's header leaves Edit Parents before going to the layout's first page.

* Pages, Export: address maintainer review of layouts and versions

- Sections (and the layouts they start) follow a moved page, as they do inserted and deleted
  ones: moving a page across a layout boundary no longer moves the other pages between layouts.
  Delete and move share `settle_sections`.
- Alternate Layout Options resizes only pages whose size changes: renaming a layout no longer
  forces every page to the first page's size.
- Export file and folder names that are Windows device names (CON, NUL, COM1…) get a leading `_`.
- An export without `dir` refuses past 512 MB of files instead of building them all in memory.
- A file that fails partway through an export to a folder says how many were already written.
- Parent Options and Print keep the dialog open, with what was typed and why, when the engine
  refuses.
- A duplicated layout's default name stays within the 100-character limit.
- rustfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pages: Alternate Layout Options sends the size only when it changed

The dialog starts at the layout's first page's size, so the engine couldn't tell a rename from a
resize to that size. The dialog now leaves out a width or height still at its starting value, and
`layout.alternate.options` resizes every page of the layout when a size is given, as before.
The test moves to the UI, where the size was coming from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 06:02:27 +01:00

621 lines
28 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 › Export Layouts and Versions: the document's alternate layouts, one file per layout or
//! all in one, in several versions at once (a press PDF with marks and bleed, a screen PDF, PNG or
//! JPEG images…), each version in a folder of its own, named from a template. A job can be saved
//! as a preset and run again.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, ok, str_param};
use crate::{EngineError, Result, Session};
const ID: &str = "file.exportBatch";
/// The most files one export writes.
const MAX_FILES: usize = 5_000;
/// The most versions in a job.
const MAX_VERSIONS: usize = 24;
/// The most pixels in one exported image (the limit EPUB page images have: an RGBA buffer of
/// about 320 MB before encoding).
const MAX_PIXELS: f64 = 8.0e7;
/// The most characters in a file or folder name.
const MAX_NAME: usize = 120;
/// The most bytes an export returns in its reply (no `dir`): past it, export to a folder.
const MAX_INLINE_BYTES: usize = 512 << 20;
/// The most saved export presets, and the longest preset name.
const MAX_PRESETS: usize = 100;
const MAX_PRESET_NAME: usize = 80;
/// What a version is exported as.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExportFormat {
#[default]
Pdf,
Png,
Jpeg,
}
impl ExportFormat {
pub fn ext(self) -> &'static str {
match self {
ExportFormat::Pdf => "pdf",
ExportFormat::Png => "png",
ExportFormat::Jpeg => "jpg",
}
}
}
/// Printer's marks of a PDF version.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct VersionMarks {
pub crop: bool,
pub bleed: bool,
pub page_info: bool,
}
/// One output of a job: a format and its settings. Its name is its folder.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportVersion {
pub name: String,
pub format: ExportFormat,
/// PDF: the document's bleed.
pub bleed: bool,
/// PDF: printer's marks.
pub marks: VersionMarks,
/// PDF: none | x4 | a2b.
pub standard: String,
/// PDF: spreads rather than pages.
pub spreads: bool,
/// PDF: downsample and compress images.
pub compress_images: bool,
/// PNG / JPEG: pixels per inch.
pub ppi: f64,
/// JPEG: quality, 1–100.
pub quality: u8,
}
impl Default for ExportVersion {
fn default() -> Self {
ExportVersion {
name: "PDF".into(),
format: ExportFormat::Pdf,
bleed: false,
marks: VersionMarks::default(),
standard: "none".into(),
spreads: false,
compress_images: false,
ppi: 150.0,
quality: 85,
}
}
}
/// An export job (and what a preset keeps).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportJob {
/// The layouts to export, by name (empty: all of them).
pub layouts: Vec<String>,
/// PDF versions: all the chosen layouts in one file rather than a file per layout.
pub combine: bool,
/// File name template: {document}, {layout}, {version}, {date}.
pub file_name: String,
pub versions: Vec<ExportVersion>,
/// Each version in a folder of its own (named after it).
pub folders: bool,
}
impl Default for ExportJob {
fn default() -> Self {
ExportJob { layouts: Vec::new(), combine: false, file_name: "{document} - {layout}".into(), versions: vec![], folders: true }
}
}
/// A saved export preset.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportPreset {
pub name: String,
pub job: ExportJob,
}
/// The ready-made versions: a press PDF (crop and bleed marks, bleed), a PDF with bleed and no
/// marks, a screen PDF, and PNG and JPEG images.
pub fn version_templates() -> Vec<ExportVersion> {
let pdf = ExportVersion::default();
vec![
ExportVersion {
name: "PDF Print (marks + bleed)".into(),
bleed: true,
marks: VersionMarks { crop: true, bleed: true, page_info: true },
..pdf.clone()
},
ExportVersion { name: "PDF Bleed (no marks)".into(), bleed: true, ..pdf.clone() },
ExportVersion { name: "PDF Screen".into(), compress_images: true, ..pdf.clone() },
ExportVersion { name: "PNG".into(), format: ExportFormat::Png, ..pdf.clone() },
ExportVersion { name: "JPEG".into(), format: ExportFormat::Jpeg, ..pdf },
]
}
/// The built-in presets, listed before the saved ones; they can't be replaced or deleted.
pub fn builtin_presets() -> Vec<ExportPreset> {
let t = version_templates();
let pick = |names: &[&str]| -> Vec<ExportVersion> { t.iter().filter(|v| names.contains(&v.name.as_str())).cloned().collect() };
vec![
ExportPreset {
name: "Print + Screen".into(),
job: ExportJob { versions: pick(&["PDF Print (marks + bleed)", "PDF Screen", "PNG"]), ..ExportJob::default() },
},
ExportPreset {
name: "Press PDF per Layout".into(),
job: ExportJob { versions: pick(&["PDF Print (marks + bleed)", "PDF Bleed (no marks)"]), ..ExportJob::default() },
},
ExportPreset { name: "Images per Layout".into(), job: ExportJob { versions: pick(&["PNG", "JPEG"]), ..ExportJob::default() } },
ExportPreset {
name: "All Layouts in One PDF".into(),
job: ExportJob {
combine: true,
folders: false,
file_name: "{document}".into(),
versions: pick(&["PDF Bleed (no marks)"]),
..ExportJob::default()
},
},
]
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.exportBatch", "Export Layouts and Versions…", ["File"], None,
"{job?: {layouts?: [layout names] (default all), combine?: bool (PDF: all the layouts in one file), fileName?: \"{document} - {layout}\" (also {version}, {date}), versions: [{name, format: pdf|png|jpeg, bleed?, marks?: {crop?, bleed?, pageInfo?}, standard?: none|x4|a2b, spreads?, compressImages?, ppi?: 150, quality?: 85}], folders?: bool (default true: a folder per version)}, preset?: name (instead of or under `job`), dir?: folder to write into, dryRun?: bool (list the files only), date?: \"YYYY-MM-DD\" (for {date})} → {files: [{path, version, layout, pages, bytes, base64? (no dir)}], warnings}",
has_doc, export_batch),
cmd!(query "file.exportPresets", "Export Presets", [], None,
"{} → {presets: [{name, job, builtIn}], versions: [the ready-made versions]}", super::always, |s, _| {
let mut presets: Vec<Value> = builtin_presets().into_iter().map(|p| json!({"name": p.name, "job": p.job, "builtIn": true})).collect();
presets.extend(s.prefs.export_presets.iter().map(|p| json!({"name": p.name, "job": p.job, "builtIn": false})));
Ok(json!({"presets": presets, "versions": version_templates()}))
}),
cmd!(noundo "file.saveExportPreset", "Save Export Preset", [], None,
"{name, job} — kept with the preferences; a saved preset of that name is replaced, built-in names are refused", super::always, save_preset),
cmd!(noundo "file.deleteExportPreset", "Delete Export Preset", [], None, "{name} — a saved export preset", super::always, |s, p| {
let name = str_param(p, "name").unwrap_or_default().trim().to_string();
let before = s.prefs.export_presets.len();
s.prefs.export_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
if s.prefs.export_presets.len() == before {
return Err(bad("file.deleteExportPreset", format!("no saved export preset `{name}`")));
}
ok()
}),
]
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.saveExportPreset";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(CMD, "missing `name`"))?.to_string();
if name.chars().count() > MAX_PRESET_NAME {
return Err(bad(CMD, format!("a preset name has at most {MAX_PRESET_NAME} characters")));
}
if builtin_presets().iter().any(|b| b.name.eq_ignore_ascii_case(&name)) {
return Err(bad(CMD, format!("`{name}` is a built-in preset")));
}
let job = job_param(CMD, p.get("job").ok_or_else(|| bad(CMD, "missing `job`"))?)?;
let presets = &mut s.prefs.export_presets;
presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
if presets.len() >= MAX_PRESETS {
return Err(bad(CMD, format!("at most {MAX_PRESETS} saved export presets")));
}
presets.push(ExportPreset { name: name.clone(), job });
Ok(json!({"name": name}))
}
/// A job from JSON, checked: versions present and sane, numbers in range.
fn job_param(cmd: &str, v: &Value) -> Result<ExportJob> {
let mut job: ExportJob = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("bad `job`: {e}")))?;
if job.versions.is_empty() {
return Err(bad(cmd, "a job needs at least one version"));
}
if job.versions.len() > MAX_VERSIONS {
return Err(bad(cmd, format!("at most {MAX_VERSIONS} versions")));
}
if job.file_name.chars().count() > 400 {
return Err(bad(cmd, "the file name template is too long"));
}
for v in &mut job.versions {
if !v.ppi.is_finite() {
return Err(bad(cmd, format!("version `{}`: `ppi` is a number", v.name)));
}
v.ppi = v.ppi.clamp(36.0, 1200.0);
v.quality = v.quality.clamp(1, 100);
if designcraft_pdf::Standard::parse(&v.standard).is_none() {
return Err(bad(cmd, format!("version `{}`: unknown standard `{}` (none, x4, a2b)", v.name, v.standard)));
}
}
Ok(job)
}
/// The pages (absolute, 0-based) of each chosen layout, by name; `names` empty: every layout.
pub(crate) fn layout_pages(cmd: &str, d: &designcraft_doc::Document, names: &[String]) -> Result<Vec<(String, Vec<usize>)>> {
let layouts = d.layouts();
if names.is_empty() {
return Ok(layouts.into_iter().map(|l| (l.name, (l.start..l.end).collect())).collect());
}
for n in names {
if !layouts.iter().any(|l| l.name == *n) {
return Err(bad(cmd, format!("no layout `{n}`")));
}
}
// In document order, whatever order they were named in.
Ok(layouts.into_iter().filter(|l| names.contains(&l.name)).map(|l| (l.name, (l.start..l.end).collect())).collect())
}
/// `s` as a file or folder name: no path separators, reserved or control characters, no leading
/// dots, no Windows device name (`CON`, `NUL`… get a leading `_`), at most [`MAX_NAME`]
/// characters; "Untitled" when nothing is left.
pub fn safe_name(s: &str) -> String {
let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { ' ' } else { c }).collect();
let words: Vec<&str> = cleaned.split_whitespace().collect();
let joined = words.join(" ");
let trimmed = joined.trim_start_matches('.').trim();
let capped: String = trimmed.chars().take(MAX_NAME).collect();
let capped = capped.trim_end_matches(['.', ' ']).to_string();
if capped.is_empty() {
"Untitled".into()
} else if crate::file_access::is_device_name(&capped) {
format!("_{capped}")
} else {
capped
}
}
/// `name.ext`, or `name 2.ext`, `name 3.ext`… when `taken` has it (case-insensitively).
fn unique(taken: &mut Vec<String>, name: &str, ext: &str) -> String {
let mut k = 1usize;
loop {
let candidate = if k == 1 { format!("{name}.{ext}") } else { format!("{name} {k}.{ext}") };
if !taken.iter().any(|t| t.eq_ignore_ascii_case(&candidate)) {
taken.push(candidate.clone());
return candidate;
}
k = k.saturating_add(1);
}
}
/// Today as YYYY-MM-DD (UTC), where there is a clock (chrono, through `designcraft_doc::time`).
fn today() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
let (y, date) = designcraft_doc::time::date_parts(designcraft_doc::vars::now().div_euclid(86_400));
format!("{y:04}-{}", date.format("%m-%d"))
}
#[cfg(target_arch = "wasm32")]
String::new()
}
/// One file a job writes.
struct Planned {
/// Relative to the export folder: `version folder/name.ext`.
path: String,
version: usize,
layout: String,
/// Absolute, 0-based.
pages: Vec<usize>,
}
/// The files `job` writes, in order: for each version, a PDF per layout (or one for all), or an
/// image per page.
fn plan(s: &Session, job: &ExportJob, date: &str) -> Result<Vec<Planned>> {
let d = &s.doc()?.doc;
let groups = layout_pages(ID, d, &job.layouts)?;
if groups.iter().all(|(_, p)| p.is_empty()) {
return Err(bad(ID, "the chosen layouts have no pages"));
}
let document = if d.title.trim().is_empty() { "Untitled" } else { d.title.as_str() };
let mut out = Vec::new();
let mut folders: Vec<String> = Vec::new();
for (vi, v) in job.versions.iter().enumerate() {
let folder = if job.folders {
let f = safe_name(&v.name);
// Two versions with one name share no folder.
let f = unique(&mut folders, &f, "").trim_end_matches('.').to_string();
format!("{f}/")
} else {
String::new()
};
let mut taken: Vec<String> = out.iter().filter_map(|p: &Planned| p.path.strip_prefix(folder.as_str()).map(str::to_string)).collect();
let name = |layout: &str| {
safe_name(
&job.file_name.replace("{document}", document).replace("{layout}", layout).replace("{version}", &v.name).replace("{date}", date),
)
};
match v.format {
ExportFormat::Pdf if job.combine => {
let pages: Vec<usize> = groups.iter().flat_map(|(_, p)| p.iter().copied()).collect();
let file = unique(&mut taken, &name("All Layouts"), "pdf");
out.push(Planned { path: format!("{folder}{file}"), version: vi, layout: "All Layouts".into(), pages });
}
ExportFormat::Pdf => {
for (layout, pages) in groups.iter().filter(|(_, p)| !p.is_empty()) {
let file = unique(&mut taken, &name(layout), "pdf");
out.push(Planned { path: format!("{folder}{file}"), version: vi, layout: layout.clone(), pages: pages.clone() });
}
}
ExportFormat::Png | ExportFormat::Jpeg => {
for (layout, pages) in &groups {
let base = name(layout);
for &abs in pages {
let stem = safe_name(&format!("{base} - {}", s.page_label(abs)));
let file = unique(&mut taken, &stem, v.format.ext());
out.push(Planned { path: format!("{folder}{file}"), version: vi, layout: layout.clone(), pages: vec![abs] });
}
}
}
}
if out.len() > MAX_FILES {
return Err(bad(ID, format!("the export would write more than {MAX_FILES} files")));
}
}
Ok(out)
}
/// The `file.exportPdf` parameters of a PDF version for `pages` (absolute, 0-based).
fn pdf_params(v: &ExportVersion, pages: &[usize], title: &str) -> Value {
json!({
"pages": pages.iter().map(|p| p.saturating_add(1)).collect::<Vec<_>>(),
"bleed": v.bleed,
"marks": {"crop": v.marks.crop, "bleed": v.marks.bleed, "pageInfo": v.marks.page_info},
"standard": v.standard,
"spreads": v.spreads,
"compressImages": v.compress_images,
"tagged": true,
"title": title,
})
}
/// The bytes of one planned file.
fn render(s: &Session, v: &ExportVersion, f: &Planned) -> Result<(Vec<u8>, Vec<String>)> {
let d = &s.doc()?.doc;
match v.format {
ExportFormat::Pdf => {
let r = super::export::pdf_report(d, &s.cache, &pdf_params(v, &f.pages, &d.title))?;
Ok((r.bytes, r.warnings))
}
ExportFormat::Png | ExportFormat::Jpeg => {
let abs = f.pages.first().copied().ok_or_else(|| bad(ID, "no page"))?;
let page = d.page(abs).ok_or_else(|| bad(ID, format!("no page {}", abs.saturating_add(1))))?;
let scale = v.ppi / 72.0;
if page.width * scale * page.height * scale > MAX_PIXELS {
return Err(bad(ID, format!("page {} at {} ppi is too large for an image; lower the resolution", s.page_label(abs), v.ppi)));
}
let mut r = designcraft_render::Renderer::new();
let opts = designcraft_render::RenderOptions { printing_only: true, rich_black: s.prefs.rich_black_output, ..Default::default() };
let img = r.render_page(d, &s.cache, abs, scale, false, &opts).ok_or_else(|| bad(ID, "the page couldn't be rendered"))?;
Ok((if v.format == ExportFormat::Png { img.to_png() } else { img.to_jpeg(v.quality) }, vec![]))
}
}
}
/// `e` for the file at `path`, saying how many files were written to disk before it (they stay).
fn partly(e: EngineError, path: &str, written: usize, to_disk: bool) -> EngineError {
if to_disk && written > 0 { bad(ID, format!("{path}: {e}; the {written} file(s) before it were written")) } else { e }
}
/// Write `bytes` at `rel` inside `dir`, making the version's folder.
#[cfg(not(target_arch = "wasm32"))]
fn write_into(dir: &str, rel: &str, bytes: &[u8]) -> Result<String> {
let path = std::path::Path::new(dir).join(rel);
if let Some(parent) = path.parent() {
crate::io::create_dir_all(parent).map_err(EngineError::Other)?;
}
let s = path.to_string_lossy().into_owned();
crate::io::write_file(&s, bytes).map_err(EngineError::Other)?;
Ok(s)
}
#[cfg(target_arch = "wasm32")]
fn write_into(_dir: &str, _rel: &str, _bytes: &[u8]) -> Result<String> {
Err(EngineError::Other("no file system on the web; omit `dir` to get the files".into()))
}
fn export_batch(s: &mut Session, p: &Value) -> Result<Value> {
let job = match (p.get("job"), str_param(p, "preset")) {
(Some(j), _) => job_param(ID, j)?,
(None, Some(name)) => {
let preset = builtin_presets()
.into_iter()
.chain(s.prefs.export_presets.iter().cloned())
.find(|x| x.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| bad(ID, format!("no export preset `{name}`")))?;
job_param(ID, &serde_json::to_value(&preset.job).map_err(|e| bad(ID, e.to_string()))?)?
}
(None, None) => return Err(bad(ID, "give a `job` or a `preset`")),
};
let date = match str_param(p, "date") {
Some(d) => safe_name(d),
None => today(),
};
let dir = str_param(p, "dir").map(str::to_string);
let planned = plan(s, &job, &date)?;
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let mut files = Vec::with_capacity(planned.len());
let mut warnings: Vec<String> = Vec::new();
let mut inline = 0usize;
for f in &planned {
let v = job.versions.get(f.version).ok_or_else(|| bad(ID, "no such version"))?;
let mut entry = json!({
"path": f.path,
"version": v.name,
"layout": f.layout,
"pages": f.pages.iter().map(|p| p.saturating_add(1)).collect::<Vec<_>>(),
});
if !dry {
let (bytes, w) = render(s, v, f).map_err(|e| partly(e, &f.path, files.len(), dir.is_some()))?;
for w in w {
let w = format!("{}: {w}", f.path);
if !warnings.contains(&w) {
warnings.push(w);
}
}
entry["bytes"] = json!(bytes.len());
match &dir {
Some(dir) => entry["path"] = json!(write_into(dir, &f.path, &bytes).map_err(|e| partly(e, &f.path, files.len(), true))?),
None => {
inline = inline.saturating_add(bytes.len());
if inline > MAX_INLINE_BYTES {
return Err(bad(ID, format!("the files come to more than {} MB: give a `dir` to export to", MAX_INLINE_BYTES >> 20)));
}
entry["base64"] = json!(super::base64_encode(&bytes));
}
}
}
files.push(entry);
}
Ok(json!({"files": files, "warnings": warnings}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn two_layouts() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false, "width": 300, "height": 400, "bleed": 9, "title": "Mag"})).unwrap();
s.execute("frame.create", &json!({"rect": [20, 20, 200, 200]})).unwrap();
s.execute("layout.createAlternate", &json!({"name": "Tablet H", "width": 400, "height": 300, "fromName": "Print"})).unwrap();
s
}
fn job(versions: &[&str]) -> Value {
let t = version_templates();
let v: Vec<_> = versions.iter().map(|n| t.iter().find(|x| x.name == *n).unwrap().clone()).collect();
json!({"versions": v})
}
fn paths(r: &Value) -> Vec<String> {
r["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_string()).collect()
}
#[test]
fn a_file_per_layout_and_version_in_folders() {
let mut s = two_layouts();
let r = s.execute(ID, &json!({"job": job(&["PDF Print (marks + bleed)", "PNG"]), "dryRun": true})).unwrap();
assert_eq!(
paths(&r),
[
"PDF Print (marks + bleed)/Mag - Print.pdf",
"PDF Print (marks + bleed)/Mag - Tablet H.pdf",
"PNG/Mag - Print - 1.png",
"PNG/Mag - Print - 2.png",
"PNG/Mag - Tablet H - 1.png",
"PNG/Mag - Tablet H - 2.png",
]
);
assert_eq!(r["files"][1]["pages"], json!([3, 4]));
// One layout on its own, all layouts in one PDF, no folders, another name.
let mut one = job(&["PDF Screen"]);
one["layouts"] = json!(["Tablet H"]);
assert_eq!(paths(&s.execute(ID, &json!({"job": one, "dryRun": true})).unwrap()), ["PDF Screen/Mag - Tablet H.pdf"]);
let mut all = job(&["PDF Screen"]);
all["combine"] = json!(true);
all["folders"] = json!(false);
all["fileName"] = json!("{document} {version} {date}");
let r = s.execute(ID, &json!({"job": all, "dryRun": true, "date": "2026-10-20"})).unwrap();
assert_eq!(paths(&r), ["Mag PDF Screen 2026-10-20.pdf"]);
assert_eq!(r["files"][0]["pages"], json!([1, 2, 3, 4]));
}
#[test]
fn the_export_renders_each_file() {
let mut s = two_layouts();
let r = s.execute(ID, &json!({"job": job(&["PDF Print (marks + bleed)", "PDF Bleed (no marks)", "JPEG"])})).unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 2 + 2 + 4);
let bytes = |i: usize| super::super::file::base64_decode(files[i]["base64"].as_str().unwrap());
for (i, f) in files.iter().enumerate().take(4) {
assert!(bytes(i).starts_with(b"%PDF-"), "{}", f["path"]);
}
assert!(bytes(4).starts_with(&[0xFF, 0xD8]), "a JPEG");
// Marks need room around the bleed: the marked PDF's page is larger.
let size = |b: &[u8]| designcraft_render::pdf_page_size(b, 0).unwrap();
let (marked, bled) = (size(&bytes(0)), size(&bytes(2)));
assert!(marked.0 > bled.0 && marked.1 > bled.1, "{marked:?} {bled:?}");
assert_eq!(designcraft_render::pdf_page_count(&bytes(1)), Some(2));
}
#[test]
fn files_go_into_a_folder_per_version() {
let dir = tempfile::tempdir().unwrap();
let mut s = two_layouts();
let r = s.execute(ID, &json!({"preset": "Print + Screen", "dir": dir.path().to_str().unwrap()})).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 2 + 2 + 4);
for f in ["PDF Print (marks + bleed)/Mag - Print.pdf", "PDF Screen/Mag - Tablet H.pdf", "PNG/Mag - Tablet H - 2.png"] {
assert!(dir.path().join(f).is_file(), "{f}");
}
let pdf = std::fs::read(dir.path().join("PDF Screen/Mag - Print.pdf")).unwrap();
assert!(pdf.starts_with(b"%PDF-"));
}
#[test]
fn names_are_made_safe_and_unique() {
assert_eq!(safe_name("../a/b:c*?"), "a b c");
assert_eq!(safe_name(" ... "), "Untitled");
assert_eq!(safe_name(&"x".repeat(500)).chars().count(), MAX_NAME);
// Windows device names name no file.
for (name, safe) in [("CON", "_CON"), ("nul", "_nul"), ("com1.x", "_com1.x"), ("Console", "Console")] {
assert_eq!(safe_name(name), safe);
}
let mut s = two_layouts();
let mut j = job(&["PNG", "PNG"]);
j["fileName"] = json!("../{document}");
let r = s.execute(ID, &json!({"job": j, "dryRun": true})).unwrap();
let p = paths(&r);
assert!(p.iter().all(|x| !x.contains("..")), "{p:?}");
assert!(p.contains(&"PNG/Mag - 1.png".to_string()) && p.contains(&"PNG/Mag - 1 2.png".to_string()), "{p:?}");
assert!(p.iter().any(|x| x.starts_with("PNG 2/")), "two versions, two folders: {p:?}");
}
#[test]
fn bad_jobs_are_refused() {
let mut s = two_layouts();
for p in [
json!({}),
json!({"job": {"versions": []}}),
json!({"job": {"versions": [{"format": "tiff"}]}}),
json!({"job": {"versions": [{"standard": "x9"}]}}),
json!({"job": {"versions": [{"format": "png"}], "layouts": ["Nope"]}}),
json!({"job": "pdf"}),
json!({"preset": "Nope"}),
] {
assert!(s.execute(ID, &p).is_err(), "{p}");
}
// A huge resolution is capped, not a crash; past 80 million pixels an image is refused.
let mut tall = Session::new();
tall.execute("file.new", &json!({"pages": 1, "width": 3000, "height": 3000})).unwrap();
assert!(tall.execute(ID, &json!({"job": {"versions": [{"format": "png", "ppi": 300}]}})).is_err(), "12500² pixels");
let r = s.execute(ID, &json!({"job": {"versions": [{"format": "png", "ppi": 1e12}], "layouts": ["Print"]}, "dryRun": true})).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 2);
let mut big = Session::new();
big.execute("file.new", &json!({"pages": 1, "width": 10000, "height": 10000})).unwrap();
assert!(big.execute(ID, &json!({"job": {"versions": [{"format": "png", "ppi": 1200}]}})).is_err(), "too many pixels");
}
#[test]
fn presets_are_saved_listed_and_deleted() {
let mut s = two_layouts();
let builtin = s.execute("file.exportPresets", &json!({})).unwrap()["presets"].as_array().unwrap().len();
s.execute("file.saveExportPreset", &json!({"name": "Client", "job": job(&["PNG"])})).unwrap();
s.execute("file.saveExportPreset", &json!({"name": "client", "job": job(&["JPEG"])})).unwrap();
let list = s.execute("file.exportPresets", &json!({})).unwrap();
assert_eq!(list["presets"].as_array().unwrap().len(), builtin + 1, "same name replaces");
let r = s.execute(ID, &json!({"preset": "CLIENT", "dryRun": true})).unwrap();
assert!(paths(&r)[0].ends_with(".jpg"));
assert!(s.execute("file.saveExportPreset", &json!({"name": "Print + Screen", "job": job(&["PNG"])})).is_err());
assert!(s.execute("file.saveExportPreset", &json!({"name": "X", "job": {"versions": []}})).is_err());
assert!(s.execute("file.deleteExportPreset", &json!({"name": "Print + Screen"})).is_err());
s.execute("file.deleteExportPreset", &json!({"name": "Client"})).unwrap();
assert!(s.execute(ID, &json!({"preset": "Client", "dryRun": true})).is_err());
}
}