From 4c3f24e43d3ec8aa240456a5a252956b14aa4701 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:43 +0200 Subject: [PATCH 01/10] Package: the packaged PDF uses a chosen PDF preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Package dialog's Include PDF (Print) gets a PDF Preset, as InDesign's Select PDF Preset: the Export PDF presets, the last one used first. The packaged PDF always had the default settings. `file.package` takes `pdf: {…file.exportPdf options}` besides true/false, read with the same `export::options` before anything is written, so bad options leave no partial package. The Export PDF dialog's field-to-params code is shared (`pdf_export_params`, `pdf_preset_params`). Found in the Learn Skills Daily course, chapter 27. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/package.rs | 33 +++++++++++++++-- crates/ui-egui/src/dialogs.rs | 54 ++++++++++++++++++---------- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/package_dialog.rs | 49 +++++++++++++++++++++---- 13 files changed, 118 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b0e33a381..28cb3d4e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · The Package dialog's Include PDF (Print) has a PDF Preset (the Export PDF presets, the last one used first), as InDesign's Select PDF Preset; `file.package` takes `pdf: {…file.exportPdf options}`, refusing bad options before writing anything. The packaged PDF always had the default settings. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. - **2026-10-21** · File › Package… opens InDesign's Package dialog: Summary counts the fonts, links and images and inks the package carries, with a warning where some are missing or modified, and Fonts, Links and Images and Colors and Inks list them (Show Problems Only). Create Printing Instructions asks for the contact details and instructions that go into the package's Instructions.txt, and Include IDML / Include PDF (Print) choose the copies it carries. Package… went straight to the folder picker with fixed options. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index f2d33ccfa..69b3ed379 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -202,7 +202,7 @@ pub fn specs() -> Vec { "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 ), @@ -389,6 +389,13 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec) { fn package(s: &mut Session, p: &Value) -> Result { 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 (as + // file.exportPdf's). Read first, so that bad options write nothing. + let pdf = match p.get("pdf") { + Some(o @ Value::Object(_)) => Some(super::export::options(o, st.doc.page_count())?), + Some(Value::Bool(true)) => Some(Default::default()), + _ => None, + }; let root = OutputRoot::new(dir.clone())?; let display_title = st.doc.title.clone(); let title = safe_component(&display_title, "Untitled"); @@ -433,9 +440,9 @@ fn package(s: &mut Session, p: &Value) -> Result { root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?; files.push(path); } - if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { + if let Some(opts) = 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()) + let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &opts) .map_err(|e| EngineError::Other(e.to_string()))?; root.write(&path, &bytes)?; files.push(path); @@ -509,6 +516,26 @@ mod tests { 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")); + assert!(s.execute("file.package", &json!({"dir": dir.join("bad").to_string_lossy(), "pdf": {"standard": "nope"}})).is_err()); + assert!(!dir.join("bad").exists(), "bad options write nothing"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// The package's IDML links its images to the files in the package's Links folder and embeds /// no copies of them. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9182f5843..0f19550bc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -399,6 +399,41 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { /// English name; the caption is translated. const PDF_EXPORT_PRESETS: &[&str] = &["Desktop Printing", "Commercial Printing", "Screen and Email", "PDF/X-4", "PDF/A-2b", "Custom"]; +/// The `file.exportPdf` options the Export PDF dialog's fields stand for (pages and title aside). +fn pdf_export_params(d: &Dialog) -> Value { + let mut params = json!({ + "standard": d.s("standard"), + "compressImages": d.b("compressImages"), + "spreads": d.b("spreads"), + "bleed": d.b("bleed"), + "tagged": d.b("tagged"), + "marks": { + "crop": d.b("marksCrop"), + "bleed": d.b("marksBleed"), + "pageInfo": d.b("marksPageInfo"), + "weight": d.pt("marksWeight").unwrap_or(0.25), + "offset": d.pt("marksOffset").unwrap_or(6.0), + }, + }); + let flatten = d.s("flatten"); + if !flatten.is_empty() { + params["flatten"] = json!(flatten); + } + params +} + +/// The PDF presets a document can be exported with, by name (Custom aside). +pub(crate) fn pdf_presets() -> impl Iterator { + PDF_EXPORT_PRESETS.iter().copied().filter(|p| *p != "Custom") +} + +/// The `file.exportPdf` options of PDF preset `preset`, as the Export PDF dialog sets them. +pub(crate) fn pdf_preset_params(preset: &str) -> Value { + let mut d = Dialog::new("pdfExport", json!({})); + apply_pdf_preset(&mut d, preset); + pdf_export_params(&d) +} + /// Apply a preset's dependent fields. Flatten is only touched by "Screen and Email"; "Custom" /// (and any unknown value) changes nothing. fn apply_pdf_preset(d: &mut Dialog, preset: &str) { @@ -3311,24 +3346,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.ui.pdf_export.marks_offset = d.s("marksOffset"); app.ui.pdf_export.tagged = d.b("tagged"); - let mut params = json!({ - "standard": d.s("standard"), - "compressImages": d.b("compressImages"), - "spreads": d.b("spreads"), - "bleed": d.b("bleed"), - "tagged": d.b("tagged"), - "marks": { - "crop": d.b("marksCrop"), - "bleed": d.b("marksBleed"), - "pageInfo": d.b("marksPageInfo"), - "weight": d.pt("marksWeight").unwrap_or(0.25), - "offset": d.pt("marksOffset").unwrap_or(6.0), - }, - }); - let flatten = d.s("flatten"); - if !flatten.is_empty() { - params["flatten"] = json!(flatten); - } + let mut params = pdf_export_params(&d); let pages = d.s("pages"); if !pages.trim().is_empty() && pages != "all" { params["pages"] = json!(pages); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index eccf6ca89..4eb433c18 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -155,6 +155,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Include PDF (Print)", ["PDF einschließen (Druck)", "Inclure le PDF (impression)", "Incluir PDF (impresión)", "PDF を含める(印刷)", "包括 PDF(打印)"], ), + ("PDF Preset:", ["PDF-Vorgabe:", "Paramètre prédéfini PDF :", "Ajuste preestablecido de PDF:", "PDF プリセット:", "PDF 预设:"]), ("Show Problems Only", ["Nur Probleme anzeigen", "Afficher uniquement les problèmes", "Mostrar solo problemas", "問題のみ表示", "仅显示问题"]), ("Printing Instructions", ["Druckanweisungen", "Instructions d’impression", "Instrucciones de impresión", "印刷指示", "打印说明"]), ("Continue", ["Weiter", "Continuer", "Continuar", "続行", "继续"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 268b799d1..e1fa7080d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("PDF Preset:", "إعداد PDF المسبق:"), ("Packaged {count} files into {dir}", "تم حزم {count} ملفات في {dir}"), ("Continue", "متابعة"), ("Printing Instructions", "تعليمات الطباعة"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index a51121541..2c7d8f090 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("PDF Preset:", "Předvolba PDF:"), ("Continue", "Pokračovat"), ("Printing Instructions", "Pokyny k tisku"), ("Show Problems Only", "Zobrazit jen problémy"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 0d0d49112..70e1e2e47 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("PDF Preset:", "Predefinito PDF:"), ("Packaged {count} files into {dir}", "{count} file raccolti in {dir}"), ("Continue", "Continua"), ("Printing Instructions", "Istruzioni di stampa"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 142f806ac..9bd18a169 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("PDF Preset:", "PDF-ის წინასწარი პარამეტრი:"), ("Packaged {count} files into {dir}", "{count} ფაილი შეიფუთა {dir}-ში"), ("Continue", "გაგრძელება"), ("Printing Instructions", "ბეჭდვის ინსტრუქციები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ca8c99e33..ccf7d68c1 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("PDF Preset:", "Ustawienie predefiniowane PDF:"), ("Packaged {count} files into {dir}", "Spakowano {count} plików do {dir}"), ("Continue", "Kontynuuj"), ("Printing Instructions", "Instrukcje drukowania"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 9c4317506..7f971648a 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("PDF Preset:", "Predefinição de PDF:"), ("Packaged {count} files into {dir}", "{count} arquivos empacotados em {dir}"), ("Continue", "Continuar"), ("Printing Instructions", "Instruções de impressão"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 4888924ee..b555c9c90 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("PDF Preset:", "Стиль PDF:"), ("Packaged {count} files into {dir}", "Упаковано файлов: {count} в {dir}"), ("Continue", "Продолжить"), ("Printing Instructions", "Инструкции для печати"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 88d72cb57..41e6591eb 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("PDF Preset:", "Набір параметрів PDF:"), ("Packaged {count} files into {dir}", "Запаковано файлів: {count} у {dir}"), ("Continue", "Продовжити"), ("Printing Instructions", "Інструкції для друку"), diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 6c00ca060..b2e5cdd75 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -81,9 +81,13 @@ pub fn open(app: &mut DesignApp) { .collect() }) .unwrap_or_default(); + // The packaged PDF's preset: the one last used for Export PDF, as InDesign suggests it. + let last = app.ui.pdf_export.preset.clone(); + let preset = crate::dialogs::pdf_presets().find(|p| *p == last).unwrap_or("Desktop Printing"); app.ui.dialog = Some(Dialog::new( ID, - json!({"page": "summary", "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, "problemsOnly": false}), + json!({"page": "summary", "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, + "pdfPreset": preset, "problemsOnly": false}), )); } @@ -250,6 +254,24 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert(key.into(), json!(on)); } } + // The PDF's preset, as InDesign's Select PDF Preset. + ui.add_enabled_ui(d.b("pdf"), |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + crate::rtl::label(ui, tr("PDF Preset:")); + let cur = d.s("pdfPreset"); + egui::ComboBox::from_id_salt("package_pdf_preset").selected_text(crate::rtl::widget(ui, tr(&cur))).width(180.0).show_ui( + ui, + |ui| { + for p in crate::dialogs::pdf_presets() { + if ui.selectable_label(cur == p, crate::rtl::widget(ui, tr(p))).clicked() { + d.fields.insert("pdfPreset".into(), json!(p)); + } + } + }, + ); + }); + }); } } if matches!(page.as_str(), "fonts" | "links") { @@ -263,9 +285,14 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The `pdf` of `file.package`: false, or the options of the chosen PDF preset. +fn pdf_param(d: &Dialog) -> Value { + if d.b("pdf") { crate::dialogs::pdf_preset_params(&d.s("pdfPreset")) } else { json!(false) } +} + /// Package…: the Printing Instructions first when asked for, else the folder. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { - let (idml, pdf) = (d.b("idml"), d.b("pdf")); + let (idml, pdf) = (d.b("idml"), pdf_param(d)); if d.b("instructions") { app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf}))); return Ok(Value::Null); @@ -316,11 +343,12 @@ fn instructions_text(d: &Dialog) -> String { /// Continue: the folder, with the instructions. pub fn confirm_instructions(app: &mut DesignApp, d: &Dialog) -> Result { let text = instructions_text(d); - package_to_folder(app, d.b("idml"), d.b("pdf"), Some(text).filter(|t| !t.is_empty())) + package_to_folder(app, d.b("idml"), d.fields.get("pdf").cloned().unwrap_or(json!(false)), Some(text).filter(|t| !t.is_empty())) } -/// Ask for the folder to create (" Folder") and package the document into it. -pub fn package_to_folder(app: &mut DesignApp, idml: bool, pdf: bool, instructions: Option<String>) -> Result<Value, String> { +/// Ask for the folder to create ("<title> Folder") and package the document into it. `pdf`: +/// false, true or the PDF's `file.exportPdf` options. +pub fn package_to_folder(app: &mut DesignApp, idml: bool, pdf: Value, instructions: Option<String>) -> Result<Value, String> { let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); let Some(dir) = app.services.pick_save.as_mut().and_then(|f| f(&name)) else { return Ok(Value::Null) }; let mut p = json!({"dir": dir, "idml": idml, "pdf": pdf}); @@ -432,8 +460,8 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } - /// Without instructions, Package… goes straight to the folder; Include PDF adds the PDF and - /// Include IDML off leaves the IDML out. + /// Without instructions, Package… goes straight to the folder; Include PDF adds the PDF, made + /// with the PDF preset chosen, and Include IDML off leaves the IDML out. #[test] fn package_options_choose_the_idml_and_pdf() { let dir = temp_dir("options"); @@ -448,10 +476,17 @@ mod tests { h.run_steps(2); h.get_by_label("Include PDF (Print)").click(); h.run_steps(2); + // The PDF's preset: PDF/X-4. + h.get_by_value("Desktop Printing").click(); + h.run_steps(2); + h.get_by_label("PDF/X-4").click(); + h.run_steps(2); h.get_by_label("Package…").click(); h.run_steps(3); assert!(h.state().ui.dialog.is_none()); assert!(out.join("Packed.pdf").exists() && !out.join("Packed.idml").exists()); + let pdf = std::fs::read(out.join("Packed.pdf")).unwrap(); + assert!(pdf.windows(8).any(|w| w == b"GTS_PDFX"), "the PDF/X-4 preset's PDF"); std::fs::remove_dir_all(dir).unwrap(); } } From 63d3f5a19d1488e6ea9404c1f60d9d2f1bc57ff0 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:59:00 +0200 Subject: [PATCH 02/10] Package the PDF as file.exportPdf makes it; refuse a bad `pdf` file.package now writes its PDF through export's `pdf_report`, so a preset's transparency flattening, the effects and hidden-layer rasterising, the blend space, the view options and the transitions all reach the packaged PDF. A `pdf` that is neither a switch nor export options is refused, as is a bad flattener, before anything is written. Georgian: the printing term for spot inks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/export.rs | 42 +++++++++++++++++---------- crates/engine/src/cmd/package.rs | 50 +++++++++++++++++++++++++------- crates/ui-egui/src/i18n/ka.rs | 4 +-- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 36fd3d52a..ad90d312e 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -710,9 +710,20 @@ fn first_page_ref(pdf: &[u8]) -> Option<usize> { fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { let st = s.doc()?; - let opts = options(p, st.doc.page_count())?; - // Transparency Flattener presets: High / Medium / Low Resolution, or a ppi. - let ppi = match p.get("flatten") { + let r = pdf_report(&st.doc, &s.cache, p)?; + match str_param(p, "path") { + Some(path) => { + write_file(path, &r.bytes)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) + } + None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), + } +} + +/// The Transparency Flattener's resolution `p.flatten` asks for: High, Medium or Low Resolution, +/// or a ppi; `None` when the PDF isn't flattened. +pub(super) fn flatten_ppi(p: &Value) -> Result<Option<f64>> { + Ok(match p.get("flatten") { None | Some(Value::Null) | Some(Value::Bool(false)) => None, Some(Value::String(s)) if s == "high" => Some(300.0), Some(Value::String(s)) if s == "medium" => Some(150.0), @@ -721,17 +732,24 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { Some(v) => Some( v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad("file.exportPdf", "`flatten`: high|medium|low or 36–1200 ppi"))?, ), - }; - let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)); + }) +} + +/// The PDF of `doc` as `file.exportPdf` makes it from options `p`: flattened when asked, placed +/// PDFs with hidden layers and soft effects as images, the blend space, the view options and the +/// page transitions. `file.package` writes its PDF with it too. +pub(super) 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 flattened = flatten_ppi(p)?.map(|ppi| flatten(doc, cache, ppi)); // Placed PDFs with hidden layers go out as images showing just their visible layers. - let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); + let base: &designcraft_doc::Document = flattened.as_ref().map_or(doc, |f| &f.0); let (layered, layer_count) = rasterize_layered(base)?; let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); - let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); + let (fx_doc, fx_count) = rasterize_effects(base2, cache); let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); let boxed = super::interchange::with_pdf_boxes(doc); let doc: &designcraft_doc::Document = &boxed; - let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let r = designcraft_pdf::export_pdf_with_report(doc, cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut r = r; // Transparency Blend Space: the page group of pages with transparency. let spreads = designcraft_pdf::sheet_spreads(doc, &opts); @@ -805,13 +823,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { { r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); } - match str_param(p, "path") { - Some(path) => { - write_file(path, &r.bytes)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), - } + Ok(r) } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 69b3ed379..107b859ae 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -389,12 +389,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 (as - // file.exportPdf's). Read first, so that bad options write nothing. + // 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") { - Some(o @ Value::Object(_)) => Some(super::export::options(o, st.doc.page_count())?), - Some(Value::Bool(true)) => Some(Default::default()), - _ => None, + None | Some(Value::Null) | Some(Value::Bool(false)) => None, + Some(Value::Bool(true)) => Some(json!({})), + Some(o @ Value::Object(_)) => { + super::export::options(o, st.doc.page_count())?; + super::export::flatten_ppi(o)?; + 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(); @@ -440,11 +445,9 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?; files.push(path); } - if let Some(opts) = pdf { + 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, &opts) - .map_err(|e| EngineError::Other(e.to_string()))?; - root.write(&path, &bytes)?; + root.write(&path, &super::export::pdf_report(&packed, &s.cache, ¶ms)?.bytes)?; files.push(path); } // The report: fonts (missing first), links, preflight, the user's instructions. @@ -531,8 +534,33 @@ mod tests { 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")); - assert!(s.execute("file.package", &json!({"dir": dir.join("bad").to_string_lossy(), "pdf": {"standard": "nope"}})).is_err()); - assert!(!dir.join("bad").exists(), "bad options write nothing"); + // Refused before anything is written: bad options, a bad flattener and a `pdf` that is + // neither a switch nor options. + for bad in [json!({"standard": "nope"}), json!({"flatten": "extreme"}), 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(); } diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 9bd18a169..121f8295b 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -41,7 +41,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Create Printing Instructions", "ბეჭდვის ინსტრუქციების შექმნა"), ("Entire Publication", "მთელი პუბლიკაცია"), ("Scope:", "არეალი:"), - ("Spot", "ლაქა"), + ("Spot", "სპოტ-საღებავი"), ("Process", "პროცესი"), ("Source", "წყარო"), ("Installed", "დაყენებული"), @@ -49,7 +49,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "ჩანაცვლებული"), ("Modified", "შეცვლილი"), ("Missing", "აკლია"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "ფერები და მელნები: {process} პროცესის; {spot} ლაქის"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "ფერები და მელნები: {process} პროცესის მელანი; {spot} სპოტ-საღებავი"), ("Images: {embedded} Embedded", "გამოსახულებები: ჩაშენებულია {embedded}"), ( "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", From 9ab1122688ad014c0e53bd27df5d8bc43555069e Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 01:28:37 +0200 Subject: [PATCH 03/10] Package: check the PDF's view options before writing anything `export::check_pdf_options` refuses what `pdf_report` would: the export options, the flattener, and now `pageLayout` and `view`, which `pdf_report` reads up front too. `file.package` checks with it before the output folder is made, so an unknown page layout or view leaves no partial package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/export.rs | 73 ++++++++++++++++++++------------ crates/engine/src/cmd/package.rs | 16 ++++--- 2 files changed, 56 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index ad90d312e..e20ebaae2 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -722,7 +722,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { /// The Transparency Flattener's resolution `p.flatten` asks for: High, Medium or Low Resolution, /// or a ppi; `None` when the PDF isn't flattened. -pub(super) fn flatten_ppi(p: &Value) -> Result<Option<f64>> { +fn flatten_ppi(p: &Value) -> Result<Option<f64>> { Ok(match p.get("flatten") { None | Some(Value::Null) | Some(Value::Bool(false)) => None, Some(Value::String(s)) if s == "high" => Some(300.0), @@ -735,11 +735,49 @@ pub(super) fn flatten_ppi(p: &Value) -> Result<Option<f64>> { }) } +/// 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 => {} + } + 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(super) fn check_pdf_options(p: &Value, pages: usize) -> Result<()> { + options(p, pages)?; + flatten_ppi(p)?; + view_options(p)?; + Ok(()) +} + /// The PDF of `doc` as `file.exportPdf` makes it from options `p`: flattened when asked, placed /// PDFs with hidden layers and soft effects as images, the blend space, the view options and the /// page transitions. `file.package` writes its PDF with it too. pub(super) 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 (mut cat, dest) = view_options(p)?; let flattened = flatten_ppi(p)?.map(|ppi| flatten(doc, cache, ppi)); // Placed PDFs with hidden layers go out as images showing just their visible layers. let base: &designcraft_doc::Document = flattened.as_ref().map_or(doc, |f| &f.0); @@ -760,33 +798,12 @@ pub(super) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_co None => r.warnings.push("the transparency blend space couldn't be set".into()), } } - // 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}]")); - } + // Interactive PDF view options; the first page opens with the view (pages are objects in + // order, so refer by index 0). + 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) { diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 107b859ae..8c72411f9 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -395,8 +395,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { None | Some(Value::Null) | Some(Value::Bool(false)) => None, Some(Value::Bool(true)) => Some(json!({})), Some(o @ Value::Object(_)) => { - super::export::options(o, st.doc.page_count())?; - super::export::flatten_ppi(o)?; + 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}"))), @@ -534,9 +533,16 @@ mod tests { 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 and a `pdf` that is - // neither a switch nor options. - for bad in [json!({"standard": "nope"}), json!({"flatten": "extreme"}), json!("print"), json!(3)] { + // 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"); From 4dbc9674fadc76dbeca858ad99bf7a813b69dc77 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 13:04:38 +0200 Subject: [PATCH 04/10] i18n: keys main added to fr.rs are in the shared table here: keep one The catalogue test refuses a key in both fr.rs and the shared table (CI). The shared row keeps the French; it matches, or takes fr.rs's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n/fr.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..adf8898d7 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -105,7 +105,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Em Dash", "Tiret cadratin"), ("Em Space", "Espace cadratin"), ("Email Address:", "Adresse e-mail :"), - ("Email:", "E-mail :"), ("En Dash", "Tiret demi-cadratin"), ("En Space", "Espace demi-cadratin"), ( @@ -254,7 +253,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"), ("PRs", "PR"), ("Package", "Assemblage"), - ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"), ("Page Break", "Saut de page"), ("Page Options", "Options de page"), ("Page Tool", "Outil Page"), @@ -265,7 +263,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Parent Options…", "Options de gabarit…"), ("Pen Tool", "Outil Plume"), ("Pencil Tool", "Outil Crayon"), - ("Phone:", "Téléphone :"), ("Picas", "Picas"), ("Pin Bar Position", "Épingler la position de la barre"), ("Place with Import Options", "Importer avec les options d'importation"), From c8bc257dcd0b9baa4c931f595e645d3c10a4df5c Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:25:01 +0100 Subject: [PATCH 05/10] Georgian: main's rows where the branch added the same keys again Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n/ka.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 48faae5a2..6a3c28d9e 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("PDF Preset:", "PDF-ის წინასწარი პარამეტრი:"), - ("Packaged {count} files into {dir}", "{count} ფაილი შეიფუთა {dir}-ში"), ("Continue", "გაგრძელება"), ("Printing Instructions", "ბეჭდვის ინსტრუქციები"), ("Show Problems Only", "მხოლოდ პრობლემების ჩვენება"), @@ -42,8 +41,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Create Printing Instructions", "ბეჭდვის ინსტრუქციების შექმნა"), ("Entire Publication", "მთელი პუბლიკაცია"), ("Scope:", "არეალი:"), - ("Spot", "სპოტ-საღებავი"), - ("Process", "პროცესი"), ("Source", "წყარო"), ("Installed", "დაყენებული"), ("Bundled", "ჩაშენებული"), @@ -58,9 +55,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ), ("Fonts: {used} Fonts Used, {missing} Missing", "შრიფტები: გამოყენებულია {used}, აკლია {missing}"), ("Instructions:", "ინსტრუქციები:"), - ("Email:", "ელფოსტა:"), ("Fax:", "ფაქსი:"), - ("Phone:", "ტელეფონი:"), ("Address:", "მისამართი:"), ("Company:", "კომპანია:"), ("Contact:", "საკონტაქტო პირი:"), From e8b27c91f116515ff21e1ba03d6e88b6ff357e14 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:55:15 +0100 Subject: [PATCH 06/10] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/package/tests.rs | 100 ++++++++++++------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs index 124ea19c4..4d6513425 100644 --- a/crates/engine/src/cmd/package/tests.rs +++ b/crates/engine/src/cmd/package/tests.rs @@ -25,57 +25,57 @@ 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(); +/// `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 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. @@ -221,7 +221,7 @@ fn package_writes_document_links_and_report() { 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.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}"); + assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {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(); From 2a01caf0de75908da9830c0da8bf475db7ba2b13 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:59:47 +0100 Subject: [PATCH 07/10] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/package_dialog.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d8d3f7f5a..175dcd5f4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3075,7 +3075,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { style_options_preview(app, &mut d); } // A multi-line field with focus (set by the dialog this frame) takes Enter as a new line. - let multiline = ui.ctx().data_mut(|m| m.remove_temp::<bool>(egui::Id::new(MULTILINE_FOCUS))).unwrap_or(false); + let multiline = ui.ctx().data_mut(|m| m.remove_temp::<bool>(egui::Id::unique(MULTILINE_FOCUS))).unwrap_or(false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 9583d84fe..eada9a244 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -335,7 +335,7 @@ pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } // Enter there makes a new line: the footer mustn't take it for Continue. if lines > 1 && resp.has_focus() { - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(crate::dialogs::MULTILINE_FOCUS), true)); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(crate::dialogs::MULTILINE_FOCUS), true)); } ui.end_row(); } @@ -420,7 +420,7 @@ mod tests { fn harness(app: DesignApp) -> Harness<'static, DesignApp> { Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("package_test_fonts"); + let key = egui::Id::unique("package_test_fonts"); if ui.data(|d| d.get_temp::<bool>(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); From 261f698aa26141d6a03a765e025789ecceb1afe5 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:26:19 +0100 Subject: [PATCH 08/10] Printing instructions: each field named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/package_dialog.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index eada9a244..2225e3d08 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -326,10 +326,11 @@ pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = app.ui.language.clone(); egui::Grid::new("printing_instructions").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { for (key, label, lines) in INSTRUCTION_FIELDS { - crate::rtl::label(ui, crate::i18n::tr(&lang, label)); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, label)); let mut text = d.s(key); let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; - let resp = ui.add(edit.desired_width(320.0)); + // Named by its label, for screen readers. + let resp = ui.add(edit.desired_width(320.0)).labelled_by(name.id); if resp.changed() { d.fields.insert(key.into(), json!(text)); } From cd612b1836a79153f5b6de432092ffbe924cee37 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:04:27 +0100 Subject: [PATCH 09/10] Package: the PDF Preset dropdown named by its label, for screen readers; craft_ui's dropdown Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/package_dialog.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 2225e3d08..536c5e4ac 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -278,9 +278,9 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_enabled_ui(d.b("pdf"), |ui| { ui.horizontal(|ui| { ui.add_space(24.0); - crate::rtl::label(ui, tr("PDF Preset:")); + let name = crate::rtl::label(ui, tr("PDF Preset:")); let cur = d.s("pdfPreset"); - egui::ComboBox::from_id_salt("package_pdf_preset").selected_text(crate::rtl::widget(ui, tr(&cur))).width(180.0).show_ui( + craft_ui::ComboBox::from_id_salt("package_pdf_preset").selected_text(crate::rtl::widget(ui, tr(&cur))).width(180.0).show_ui( ui, |ui| { for p in crate::dialogs::pdf_presets() { @@ -289,7 +289,9 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } }, - ); + ) + .response + .labelled_by(name.id); }); }); } From 559f8f590bea8bf959be6baea03195ce507c1b82 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 15:56:16 +0100 Subject: [PATCH 10/10] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/package_dialog.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 536c5e4ac..a2eac5824 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -280,18 +280,18 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(24.0); let name = crate::rtl::label(ui, tr("PDF Preset:")); let cur = d.s("pdfPreset"); - craft_ui::ComboBox::from_id_salt("package_pdf_preset").selected_text(crate::rtl::widget(ui, tr(&cur))).width(180.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt("package_pdf_preset") + .selected_text(crate::rtl::widget(ui, tr(&cur))) + .width(180.0) + .show_ui(ui, |ui| { for p in crate::dialogs::pdf_presets() { if ui.selectable_label(cur == p, crate::rtl::widget(ui, tr(p))).clicked() { d.fields.insert("pdfPreset".into(), json!(p)); } } - }, - ) - .response - .labelled_by(name.id); + }) + .response + .labelled_by(name.id); }); }); }