Merge PR #1515: Package dialog PDF options refactor

crates/ui-egui/src/dialogs.rs: kept the Enter-stays-local fix
crates/ui-egui/src/package_dialog.rs: kept the summary wording and the fix; took the PR's pdf option value
crates/ui-egui/src/i18n/*.rs: kept the reworded keys and the PR's PDF Preset rows
Also (the PR's own CI failure): package.rs calls check_pdf_options and pdf_report on wasm, so those two re-exports are no longer wasm-gated (only pdf_export is).
This commit is contained in:
aiongg
2026-10-30 23:29:02 +00:00
16 changed files with 213 additions and 67 deletions
+1
View File
@@ -16,6 +16,7 @@ mod registry;
use flatten::{rasterize_effects, shadow_coverage};
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use pdf::pdf_export;
pub(crate) use pdf::{check_pdf_options, pdf_report};
pub(crate) use pdf::{flatten_ppi, options, print_pdf};
pub use registry::specs;
+52 -35
View File
@@ -169,41 +169,21 @@ pub(crate) fn print_pdf(
/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF
/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it.
pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::ExportReport> {
let st = s.doc()?;
let opts = options(p, st.doc.page_count())?;
pdf_report(&s.doc()?.doc, &s.cache, p)
}
/// [`pdf_export`] of `doc`: `file.package` writes its PDF of the packaged copy with it.
pub(crate) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_compose::Cache, p: &Value) -> Result<designcraft_pdf::ExportReport> {
let opts = options(p, doc.page_count())?;
let ppi = flatten_ppi("file.exportPdf", p)?;
let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?;
let doc: &designcraft_doc::Document = &st.doc;
let (cat, dest) = view_options(p)?;
let mut r = print_pdf(doc, cache, &opts, ppi)?;
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
// Interactive PDF view options.
let mut cat = String::new();
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/FullScreen");
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/UseOutlines");
}
match str_param(p, "pageLayout") {
Some("single") => cat.push_str("/PageLayout/SinglePage"),
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
None => {}
}
if let Some(v) = str_param(p, "view") {
// The first page opens with this view (pages are objects in order, so refer by index 0).
let dest = match v {
"fitPage" => "/Fit",
"fitWidth" => "/FitH null",
"actual" => "/XYZ null null 1",
o => {
return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)")));
}
};
if let Some(first) = first_page_ref(&r.bytes) {
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
}
let mut cat = cat;
if let Some(dest) = dest
&& let Some(first) = first_page_ref(&r.bytes)
{
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
}
if !cat.is_empty() {
match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) {
@@ -213,8 +193,7 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
}
// Flip pages every N seconds (presentation).
if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) {
let n = designcraft_pdf::sheet_spreads(doc, &opts).len();
let dur: Vec<Option<String>> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
let dur: Vec<Option<String>> = (0..spreads.len()).map(|_| Some(format!("/Dur {secs:.2}"))).collect();
match designcraft_pdf::add_page_entries(&r.bytes, &dur) {
Some(b) => r.bytes = b,
None => r.warnings.push("page timings couldn't be set".into()),
@@ -232,6 +211,44 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result<designcraft_pdf::Expo
Ok(r)
}
/// The catalog entries of the interactive view options in `p` (page mode and layout) and the
/// destination the first page opens at (`view`); unknown values are refused.
fn view_options(p: &Value) -> Result<(String, Option<&'static str>)> {
let mut cat = String::new();
if p.get("fullScreen").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/FullScreen");
} else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) {
cat.push_str("/PageMode/UseOutlines");
}
match str_param(p, "pageLayout") {
Some("single") => cat.push_str("/PageLayout/SinglePage"),
Some("continuous") => cat.push_str("/PageLayout/OneColumn"),
Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"),
Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"),
Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"),
Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))),
None => {}
}
// The first page opens with this view (pages are objects in order, so refer by index 0).
let dest = match str_param(p, "view") {
None => None,
Some("fitPage") => Some("/Fit"),
Some("fitWidth") => Some("/FitH null"),
Some("actual") => Some("/XYZ null null 1"),
Some(o) => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))),
};
Ok((cat, dest))
}
/// Refuses PDF options `p` that [`pdf_report`] would refuse for a document of `pages` pages,
/// without exporting: `file.package` checks them before it writes anything.
pub(crate) fn check_pdf_options(p: &Value, pages: usize) -> Result<()> {
options(p, pages)?;
flatten_ppi("file.exportPdf", p)?;
view_options(p)?;
Ok(())
}
#[cfg(test)]
mod tagged_tests {
use serde_json::json;
+14 -5
View File
@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
"{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
has_doc,
package
),
@@ -390,6 +390,17 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) {
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()?;
// The PDF: with the default settings, or with the options of a PDF preset, made as
// file.exportPdf makes it. Read first, so that bad options write nothing.
let pdf = match p.get("pdf") {
None | Some(Value::Null) | Some(Value::Bool(false)) => None,
Some(Value::Bool(true)) => Some(json!({})),
Some(o @ Value::Object(_)) => {
super::export::check_pdf_options(o, st.doc.page_count())?;
Some(o.clone())
}
Some(v) => return Err(bad("file.package", format!("`pdf` is true, false or PDF export options, not {v}"))),
};
let root = OutputRoot::new(dir.clone())?;
let display_title = st.doc.title.clone();
let title = safe_component(&display_title, "Untitled");
@@ -441,11 +452,9 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n";
}
}
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
if let Some(params) = pdf {
let path = package_output_path(&dir, &title, "pdf")?;
let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default())
.map_err(|e| EngineError::Other(e.to_string()))?;
root.write(&path, &bytes)?;
root.write(&path, &super::export::pdf_report(&packed, &s.cache, &params)?.bytes)?;
files.push(path);
}
// The printing instructions first, as the form gave them; then the report: fonts (missing
+52
View File
@@ -25,6 +25,58 @@ fn relocated_packages_rebind_embedded_image_links_on_open() {
std::fs::remove_dir_all(dir).unwrap();
}
/// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the
/// default PDF, and bad options are refused.
#[test]
fn package_pdf_takes_export_options() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-package-pdf-options-{}-{nonce}", std::process::id()));
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap();
let x4 = dir.join("x4");
s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap();
let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what);
assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX"));
let plain = dir.join("plain");
s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap();
assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX"));
// Refused before anything is written: bad options, a bad flattener or view option, and a
// `pdf` that is neither a switch nor options.
for bad in [
json!({"standard": "nope"}),
json!({"flatten": "extreme"}),
json!({"pageLayout": "spiral"}),
json!({"view": "zoomed"}),
json!("print"),
json!(3),
] {
let out = dir.join("bad");
assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}");
assert!(!out.exists(), "{bad}: bad options write nothing");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged
/// PDF the flattening `file.exportPdf` does.
#[test]
fn the_packaged_pdf_is_flattened_as_export_flattens_it() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-package-pdf-flatten-{}-{nonce}", std::process::id()));
let mut s = Session::new();
s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap();
let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap());
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap();
let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap();
let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count();
assert!(images(&exported) > 0, "the export rasterises the transparent object");
assert_eq!(images(&packaged), images(&exported), "so does the package");
std::fs::remove_dir_all(dir).unwrap();
}
/// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the
/// report says the copies differ instead of leaving it to be discovered.
#[test]