PDF export: options dialog with remembered settings (#196)

* PDF export: Export Adobe PDF options dialog with remembered settings

File > Export PDF... now opens an options dialog (General / Compression /
Marks and Bleeds / Advanced) that exposes only the parameters the engine's
file.exportPdf already supports: PDF standard, image compression, flattener
preset, spreads, document bleed, crop/bleed/page-info marks with weight and
offset, tagged PDF, page range, and the title. Choosing a preset fills the
dependent fields; editing any field switches the preset to Custom.

The last-used settings persist in ui.json (UiState.pdfExport) and are reused
across exports and sessions. Programmatic callers that pass explicit params
(path, options) still export directly without the dialog; cancelling the save
picker writes nothing.

Adds English and ar/pt-br strings, unit tests for the settings serde, the
dialog defaults and preset mapping, the open/confirm flow (stub writer), and
the menu dispatch, plus a ROADMAP note.

* PDF export: rename dialog title to "Export PDF"

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 04:23:32 -04:00
1 parent 0b54cc3d66
commit c657d8545e
8 files changed
+567 -4

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
+1 -1
View File
@@ -1,4 +1,4 @@
//! File › Export: Adobe PDF (Print).
//! File › Export PDF (Print).
use designcraft_pdf::{Marks, PdfOptions, Standard};
use serde_json::{Value, json};
+379
View File
@@ -31,6 +31,22 @@ impl Dialog {
"insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}),
"findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}),
"documentSetup" => json!({}),
"pdfExport" => {
json!({
"preset": "Desktop Printing",
"standard": "none",
"compressImages": false,
"flatten": "",
"spreads": false,
"bleed": true,
"marksCrop": false,
"marksBleed": false,
"marksPageInfo": false,
"marksWeight": "0.25",
"marksOffset": "6",
"tagged": true
})
}
_ => json!({}),
};
if let Value::Object(d) = defaults {
@@ -235,6 +251,22 @@ pub fn open_print(app: &mut DesignApp) {
));
}
/// File › Export PDF…: the "Export PDF" options dialog, seeded from the persisted settings
/// (`UiState.pdf_export`) and the active document. `pages` is per-job (not persisted); `flatten`
/// falls back to the global flattener preset when the persisted value is empty.
pub fn open_pdf_export(app: &mut DesignApp) {
let mut fields = serde_json::to_value(&app.ui.pdf_export).unwrap_or_else(|_| json!({}));
if let Value::Object(m) = &mut fields {
let title = app.session.active().map(|st| st.doc.title.clone()).unwrap_or_default();
m.insert("title".into(), json!(title));
m.insert("pages".into(), json!("all"));
if app.ui.pdf_export.flatten.is_empty() {
m.insert("flatten".into(), json!(app.ui.flattener));
}
}
app.ui.dialog = Some(Dialog::new("pdfExport", fields));
}
fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let printers: Vec<String> = d
.fields
@@ -284,6 +316,99 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
}
/// The presets offered by the Export PDF dialog, in display order. The stored value is the
/// 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"];
/// 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) {
let (standard, compress, crop, bleed, page_info, flatten) = match preset {
"Desktop Printing" => ("none", false, false, false, false, None),
"Commercial Printing" => ("none", true, true, true, false, None),
"Screen and Email" => ("none", true, false, false, false, Some("low")),
"PDF/X-4" => ("x4", false, true, true, false, None),
"PDF/A-2b" => ("a2b", false, false, false, false, None),
_ => return,
};
d.fields.insert("standard".into(), json!(standard));
d.fields.insert("compressImages".into(), json!(compress));
d.fields.insert("marksCrop".into(), json!(crop));
d.fields.insert("marksBleed".into(), json!(bleed));
d.fields.insert("marksPageInfo".into(), json!(page_info));
if let Some(f) = flatten {
d.fields.insert("flatten".into(), json!(f));
}
}
/// File › Export PDF: the "Export PDF" options (General / Compression / Marks and Bleeds /
/// Advanced). Selecting a preset fills the dependent fields; editing any other control flips the
/// preset back to "Custom".
fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let lang = app.ui.language.as_str();
let head = |ui: &mut egui::Ui, t: &str| {
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, t)).font(semibold(12.0)));
};
// Snapshot the non-preset fields so a manual edit can be detected after drawing.
let before = d.fields.clone();
let mut preset_clicked = false;
head(ui, "General");
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Preset:"));
let cur = d.s("preset");
egui::ComboBox::from_id_salt("pdf_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, &cur))).width(200.0).show_ui(
ui,
|ui| {
for p in PDF_EXPORT_PRESETS {
if ui.selectable_label(cur == *p, crate::rtl::widget(ui, crate::i18n::tr(lang, p))).clicked() {
d.fields.insert("preset".into(), json!(*p));
preset_clicked = true;
}
}
},
);
});
if preset_clicked {
let p = d.s("preset");
apply_pdf_preset(d, &p);
}
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Page Range:"));
text_field(ui, d, "pages", 160.0);
});
check(ui, d, "spreads", crate::i18n::tr(lang, "Spreads"));
head(ui, "Compression");
check(ui, d, "compressImages", crate::i18n::tr(lang, "Compress images"));
head(ui, "Marks and Bleeds");
check(ui, d, "bleed", crate::i18n::tr(lang, "Include document bleed"));
check(ui, d, "marksCrop", crate::i18n::tr(lang, "Crop Marks"));
check(ui, d, "marksBleed", crate::i18n::tr(lang, "Bleed Marks"));
check(ui, d, "marksPageInfo", crate::i18n::tr(lang, "Page Information"));
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:"));
text_field(ui, d, "marksWeight", 60.0);
ui.add_space(8.0);
crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:"));
text_field(ui, d, "marksOffset", 60.0);
});
head(ui, "Advanced");
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Standard:"));
translated_combo(lang, ui, d, "standard", &[("none", "None"), ("x4", "PDF/X-4"), ("a2b", "PDF/A-2b")]);
});
check(ui, d, "tagged", crate::i18n::tr(lang, "Tagged PDF"));
// Any edit other than picking a preset means the settings are no longer that preset.
if !preset_clicked && d.fields != before {
d.fields.insert("preset".into(), json!("Custom"));
}
}
/// The options of the selected text frame (the first selected item, or the frame of the caret).
fn selected_frame_options(app: &DesignApp) -> Option<designcraft_doc::TextFrameOptions> {
let st = app.session.active()?;
@@ -1020,6 +1145,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"preferences" => crate::i18n::tr(&app.ui.language, "Preferences"),
"print" => crate::i18n::tr(&app.ui.language, "Print"),
"pdfImport" => crate::i18n::tr(&app.ui.language, "Place PDF"),
"pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"),
"colorSettings" => crate::i18n::tr(&app.ui.language, "Color Settings"),
"layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"),
"keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"),
@@ -1564,6 +1690,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
text_frame_options_dialog(app, ui, &mut d);
}
"documentSetup" => document_setup(app, ui, &mut d),
"pdfExport" => pdf_export(app, ui, &mut d),
"alert" => {
let file = d.s("file");
if !file.is_empty() {
@@ -1807,6 +1934,57 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
app.status(format!("Sent {} page(s) to {}", r["pages"], r["printer"].as_str().unwrap_or("the default printer")));
Ok(r)
}
"pdfExport" => {
// Remember the last-used options; `pages` and `title` are per-job and not persisted.
app.ui.pdf_export.preset = d.s("preset");
app.ui.pdf_export.standard = d.s("standard");
app.ui.pdf_export.compress_images = d.b("compressImages");
app.ui.pdf_export.flatten = d.s("flatten");
app.ui.pdf_export.spreads = d.b("spreads");
app.ui.pdf_export.bleed = d.b("bleed");
app.ui.pdf_export.marks_crop = d.b("marksCrop");
app.ui.pdf_export.marks_bleed = d.b("marksBleed");
app.ui.pdf_export.marks_page_info = d.b("marksPageInfo");
app.ui.pdf_export.marks_weight = d.s("marksWeight");
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 pages = d.s("pages");
if !pages.trim().is_empty() && pages != "all" {
params["pages"] = json!(pages);
}
let title = d.s("title");
if !title.is_empty() {
params["title"] = json!(title);
}
let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") };
match app.services.pick_save.as_mut().and_then(|f| f(&name)) {
Some(path) => {
params["path"] = json!(path);
crate::menus::export_pdf(app, &params)
}
// The user cancelled the save dialog: export nothing.
None => Ok(Value::Null),
}
}
"preferences" => {
app.run(
"prefs.set",
@@ -4033,6 +4211,207 @@ mod tests {
}
}
#[test]
fn pdf_export_defaults() {
let d = Dialog::new("pdfExport", json!({}));
let keys: Vec<&str> = d.fields.iter().map(|(k, _)| k.as_str()).collect();
// serde_json::Map keeps keys sorted.
let expected = [
"bleed",
"compressImages",
"flatten",
"marksBleed",
"marksCrop",
"marksOffset",
"marksPageInfo",
"marksWeight",
"preset",
"spreads",
"standard",
"tagged",
];
assert_eq!(keys, expected);
assert_eq!(d.fields["preset"].as_str(), Some("Desktop Printing"));
assert_eq!(d.fields["standard"].as_str(), Some("none"));
assert_eq!(d.fields["compressImages"].as_bool(), Some(false));
assert_eq!(d.fields["flatten"].as_str(), Some(""));
assert_eq!(d.fields["spreads"].as_bool(), Some(false));
assert_eq!(d.fields["bleed"].as_bool(), Some(true));
assert_eq!(d.fields["marksCrop"].as_bool(), Some(false));
assert_eq!(d.fields["marksBleed"].as_bool(), Some(false));
assert_eq!(d.fields["marksPageInfo"].as_bool(), Some(false));
assert_eq!(d.fields["marksWeight"].as_str(), Some("0.25"));
assert_eq!(d.fields["marksOffset"].as_str(), Some("6"));
assert_eq!(d.fields["tagged"].as_bool(), Some(true));
}
#[test]
fn pdf_export_override_keeps_override() {
let d = Dialog::new("pdfExport", json!({"bleed": false}));
assert_eq!(d.fields["bleed"].as_bool(), Some(false));
assert_eq!(d.fields["preset"].as_str(), Some("Desktop Printing"));
assert_eq!(d.fields["tagged"].as_bool(), Some(true));
}
#[test]
fn pdf_export_preset_high_quality_print_keeps_flatten() {
let mut d = Dialog::new("pdfExport", json!({}));
d.fields.insert("flatten".into(), json!("medium"));
apply_pdf_preset(&mut d, "Desktop Printing");
assert_eq!(d.s("standard"), "none");
assert!(!d.b("compressImages"));
assert!(!d.b("marksCrop"));
assert!(!d.b("marksBleed"));
assert!(!d.b("marksPageInfo"));
assert_eq!(d.s("flatten"), "medium", "a leave-unchanged preset must not touch flatten");
}
#[test]
fn pdf_export_preset_press_quality_keeps_flatten() {
let mut d = Dialog::new("pdfExport", json!({}));
d.fields.insert("flatten".into(), json!("high"));
apply_pdf_preset(&mut d, "Commercial Printing");
assert_eq!(d.s("standard"), "none");
assert!(d.b("compressImages"));
assert!(d.b("marksCrop"));
assert!(d.b("marksBleed"));
assert!(!d.b("marksPageInfo"));
assert_eq!(d.s("flatten"), "high");
}
#[test]
fn pdf_export_preset_smallest_file_size_sets_flatten_low() {
let mut d = Dialog::new("pdfExport", json!({}));
apply_pdf_preset(&mut d, "Screen and Email");
assert_eq!(d.s("standard"), "none");
assert!(d.b("compressImages"));
assert!(!d.b("marksCrop"));
assert!(!d.b("marksBleed"));
assert!(!d.b("marksPageInfo"));
assert_eq!(d.s("flatten"), "low");
}
#[test]
fn pdf_export_preset_pdf_x4_keeps_flatten() {
let mut d = Dialog::new("pdfExport", json!({}));
d.fields.insert("flatten".into(), json!("medium"));
apply_pdf_preset(&mut d, "PDF/X-4");
assert_eq!(d.s("standard"), "x4");
assert!(!d.b("compressImages"));
assert!(d.b("marksCrop"));
assert!(d.b("marksBleed"));
assert!(!d.b("marksPageInfo"));
assert_eq!(d.s("flatten"), "medium");
}
#[test]
fn pdf_export_preset_pdf_a2b_keeps_flatten() {
let mut d = Dialog::new("pdfExport", json!({}));
d.fields.insert("flatten".into(), json!("low"));
apply_pdf_preset(&mut d, "PDF/A-2b");
assert_eq!(d.s("standard"), "a2b");
assert!(!d.b("compressImages"));
assert!(!d.b("marksCrop"));
assert!(!d.b("marksBleed"));
assert!(!d.b("marksPageInfo"));
assert_eq!(d.s("flatten"), "low");
}
#[test]
fn open_pdf_export_seeds_fields_from_settings() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.pdf_export.standard = "x4".into();
app.ui.pdf_export.tagged = false;
app.ui.pdf_export.marks_crop = true;
app.ui.pdf_export.flatten = String::new();
app.ui.flattener = "medium".into();
open_pdf_export(&mut app);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.id, "pdfExport");
assert_eq!(d.s("standard"), "x4");
assert!(!d.b("tagged"));
assert!(d.b("marksCrop"));
assert_eq!(d.s("pages"), "all");
assert_eq!(d.s("flatten"), "medium", "empty flatten falls back to the global flattener");
assert_eq!(d.s("title"), "", "no document → empty title");
// A non-empty persisted flatten is used verbatim, not the global fallback.
app.ui.pdf_export.flatten = "low".into();
app.ui.flattener = "medium".into();
open_pdf_export(&mut app);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.s("flatten"), "low");
}
#[test]
fn pdf_export_preset_custom_changes_nothing() {
let mut d = Dialog::new("pdfExport", json!({}));
d.fields.insert("standard".into(), json!("x4"));
d.fields.insert("compressImages".into(), json!(true));
d.fields.insert("marksCrop".into(), json!(true));
d.fields.insert("marksBleed".into(), json!(true));
d.fields.insert("marksPageInfo".into(), json!(true));
d.fields.insert("flatten".into(), json!("high"));
let before = d.fields.clone();
apply_pdf_preset(&mut d, "Custom");
assert_eq!(d.fields, before);
}
#[test]
fn confirm_pdf_export_writes_file_and_persists_settings() {
use std::sync::{Arc, Mutex};
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({})).unwrap();
let writes: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = writes.clone();
app.services.write = Some(Box::new(move |path: &str, bytes: &[u8]| {
sink.lock().unwrap().push((path.to_string(), bytes.to_vec()));
Ok(())
}));
app.services.pick_save = Some(Box::new(|_name: &str| Some("/tmp/designcraft-test.pdf".to_string())));
app.ui.dialog = Some(Dialog::new("pdfExport", json!({})));
{
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("standard".into(), json!("x4"));
d.fields.insert("bleed".into(), json!(false));
d.fields.insert("marksCrop".into(), json!(true));
d.fields.insert("tagged".into(), json!(false));
d.fields.insert("title".into(), json!("Report"));
}
confirm(&mut app).unwrap();
let got = writes.lock().unwrap();
assert_eq!(got.len(), 1, "confirming the dialog writes exactly one file");
assert_eq!(got[0].0, "/tmp/designcraft-test.pdf");
assert!(got[0].1.starts_with(b"%PDF"), "the written bytes are a PDF");
drop(got);
assert_eq!(app.ui.pdf_export.standard, "x4");
assert!(!app.ui.pdf_export.bleed);
assert!(app.ui.pdf_export.marks_crop);
assert!(!app.ui.pdf_export.tagged);
}
#[test]
fn confirm_pdf_export_cancelled_save_writes_nothing() {
use std::sync::{Arc, Mutex};
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({})).unwrap();
let writes: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = writes.clone();
app.services.write = Some(Box::new(move |path: &str, bytes: &[u8]| {
sink.lock().unwrap().push((path.to_string(), bytes.to_vec()));
Ok(())
}));
app.services.pick_save = Some(Box::new(|_name: &str| None));
app.ui.dialog = Some(Dialog::new("pdfExport", json!({})));
let r = confirm(&mut app).unwrap();
assert_eq!(r, Value::Null);
assert!(writes.lock().unwrap().is_empty(), "cancelling the save picker must not write");
}
use designcraft_doc::{CharAttrs, CharacterStyle};
/// A document with character styles Base (bold) and Emphasis (italic) on the first two letters.
+47
View File
@@ -229,6 +229,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Art", ["Art", "Art", "Art", "Art", "画板"]),
("Based On:", ["Based On:", "Based On:", "Based On:", "Based On:", "基于:"]),
("Bleed", ["Bleed", "Bleed", "Bleed", "Bleed", "出血"]),
("Bleed Marks", ["Beschnittmarken", "Traits de fond perdu", "Marcas de sangrado", "塗り足しマーク", "出血标记"]),
("Body Rows", ["Body Rows", "Body Rows", "Body Rows", "Body Rows", "表体行"]),
("Bottom", ["Bottom", "Bottom", "Bottom", "Bottom", "底部"]),
("Break Link to Style", ["Break Link to Style", "Break Link to Style", "Break Link to Style", "Break Link to Style", "断开与样式的链接"]),
@@ -275,6 +276,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Create Table", ["Create Table", "Create Table", "Create Table", "Create Table", "创建表"]),
("Create Table…", ["Create Table…", "Create Table…", "Create Table…", "Create Table…", "创建表…"]),
("Crop", ["Crop", "Crop", "Crop", "Crop", "裁剪"]),
("Crop Marks", ["Schnittmarken", "Traits de coupe", "Marcas de recorte", "トンボ", "裁切标记"]),
("Crop to:", ["Crop to:", "Crop to:", "Crop to:", "Crop to:", "裁剪到:"]),
("Cursor Key:", ["Cursor Key:", "Cursor Key:", "Cursor Key:", "Cursor Key:", "光标键:"]),
("Default", ["Default", "Default", "Default", "Default", "默认"]),
@@ -294,6 +296,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Duplicate Spread", ["Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "复制跨页"]),
("Email", ["Email", "Email", "Email", "Email", "电子邮件"]),
("Embed", ["Embed", "Embed", "Embed", "Embed", "嵌入"]),
("Export PDF", ["PDF exportieren", "Exporter en PDF", "Exportar PDF", "PDF を書き出し", "导出PDF"]),
("Export XML…", ["Export XML…", "Export XML…", "Export XML…", "Export XML…", "导出XML…"]),
("Fast", ["Fast", "Fast", "Fast", "Fast", "快速"]),
(
@@ -311,6 +314,10 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("Fit Content to Frame", ["Fit Content to Frame", "Fit Content to Frame", "Fit Content to Frame", "Fit Content to Frame", "内容适合框架"]),
("Fitting:", ["Fitting:", "Fitting:", "Fitting:", "Fitting:", "适合:"]),
(
"Flattener preset",
["Voreinstellung für Transparenzreduzierung", "Préréglage d'aplatissement", "Preajuste de acoplado", "透明分割プリセット", "拼合器预设"],
),
("Font Family:", ["Font Family:", "Font Family:", "Font Family:", "Font Family:", "字体系列:"]),
("Font Size:", ["Font Size:", "Font Size:", "Font Size:", "Font Size:", "字体大小:"]),
("Font Style:", ["Font Style:", "Font Style:", "Font Style:", "Font Style:", "字体样式:"]),
@@ -328,6 +335,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Height", ["Height", "Height", "Height", "Height", "高度"]),
("Height:", ["Height:", "Height:", "Height:", "Height:", "高度:"]),
("High Quality", ["High Quality", "High Quality", "High Quality", "High Quality", "高品质"]),
("Desktop Printing", ["Bürodruck", "Impression bureautique", "Impresión de oficina", "オフィス印刷", "办公打印"]),
(
"Horizontal Gridline Every:",
["Horizontal Gridline Every:", "Horizontal Gridline Every:", "Horizontal Gridline Every:", "Horizontal Gridline Every:", "水平网格线间隔:"],
@@ -336,6 +344,16 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Hyphenate", ["Hyphenate", "Hyphenate", "Hyphenate", "Hyphenate", "连字符"]),
("Import Options", ["Import Options", "Import Options", "Import Options", "Import Options", "导入选项"]),
("Import XML…", ["Import XML…", "Import XML…", "Import XML…", "Import XML…", "导入XML…"]),
(
"Include document bleed",
[
"Dokumentbeschnitt einschließen",
"Inclure le fond perdu du document",
"Incluir sangrado del documento",
"ドキュメントの塗り足しを含める",
"包含文档出血",
],
),
("Increment Every:", ["Increment Every:", "Increment Every:", "Increment Every:", "Increment Every:", "递增步长:"]),
("Ink Manager", ["Ink Manager", "Ink Manager", "Ink Manager", "Ink Manager", "油墨管理器"]),
("Insert Column", ["Insert Column", "Insert Column", "Insert Column", "Insert Column", "插入列"]),
@@ -355,6 +373,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Load…", ["Load…", "Load…", "Load…", "Load…", "加载…"]),
("Make Compound", ["Make Compound", "Make Compound", "Make Compound", "Make Compound", "建立复合路径"]),
("Margins", ["Margins", "Margins", "Margins", "Margins", "边距"]),
("Marks and Bleeds", ["Marken und Beschnitt", "Traits et fond perdu", "Marcas y sangrado", "トンボと塗り足し", "标记和出血"]),
("Maximum", ["Maximum", "Maximum", "Maximum", "Maximum", "最大"]),
("Menu Customization", ["Menu Customization", "Menu Customization", "Menu Customization", "Menu Customization", "菜单自定"]),
(
@@ -425,6 +444,9 @@ const TABLE: &[(&str, [&str; 5])] = &[
("OpenType", ["OpenType", "OpenType", "OpenType", "OpenType", "OpenType"]),
("Orientation:", ["Orientation:", "Orientation:", "Orientation:", "Orientation:", "方向:"]),
("Outside", ["Outside", "Outside", "Outside", "Outside", "外侧"]),
("Page Information", ["Seiteninformationen", "Informations sur la page", "Información de página", "ページ情報", "页面信息"]),
("Page Range", ["Seitenbereich", "Plage de pages", "Intervalo de páginas", "ページ範囲", "页面范围"]),
("Page Range:", ["Seitenbereich:", "Plage de pages :", "Intervalo de páginas:", "ページ範囲:", "页面范围:"]),
("Pages:", ["Pages:", "Pages:", "Pages:", "Pages:", "页面:"]),
("Paragraph Composer", ["Paragraph Composer", "Paragraph Composer", "Paragraph Composer", "Paragraph Composer", "段落排版器"]),
(
@@ -432,6 +454,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
["Paragraph Style Options", "Paragraph Style Options", "Paragraph Style Options", "Paragraph Style Options", "段落样式选项"],
),
("Paragraph Style:", ["Paragraph Style:", "Paragraph Style:", "Paragraph Style:", "Paragraph Style:", "段落样式:"]),
("PDF/A-2b", ["PDF/A-2b", "PDF/A-2b", "PDF/A-2b", "PDF/A-2b", "PDF/A-2b"]),
("PDF/X-4", ["PDF/X-4", "PDF/X-4", "PDF/X-4", "PDF/X-4", "PDF/X-4"]),
("Place PDF", ["Place PDF", "Place PDF", "Place PDF", "Place PDF", "置入PDF"]),
("Plain Text", ["Plain Text", "Plain Text", "Plain Text", "Plain Text", "纯文本"]),
("Polygon Settings", ["Polygon Settings", "Polygon Settings", "Polygon Settings", "Polygon Settings", "多边形设置"]),
@@ -441,6 +465,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Preferences", ["Preferences", "Preferences", "Preferences", "Preferences", "首选项"]),
("Prefix:", ["Prefix:", "Prefix:", "Prefix:", "Prefix:", "前缀:"]),
("Preset", ["Preset", "Preset", "Preset", "Preset", "预设"]),
("Preset:", ["Voreinstellung:", "Préréglage :", "Preajuste:", "プリセット:", "预设:"]),
("Commercial Printing", ["Druckerei", "Impression commerciale", "Impresión comercial", "商業印刷", "商业印刷"]),
("Print", ["Print", "Print", "Print", "Print", "打印"]),
("Printer:", ["Printer:", "Printer:", "Printer:", "Printer:", "打印机:"]),
("Printing / Exporting:", ["Printing / Exporting:", "Printing / Exporting:", "Printing / Exporting:", "Printing / Exporting:", "打印/导出:"]),
@@ -508,11 +534,14 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Single-line", ["Single-line", "Single-line", "Single-line", "Single-line", "单行"]),
("Single-line Composer", ["Single-line Composer", "Single-line Composer", "Single-line Composer", "Single-line Composer", "单行排版器"]),
("Size:", ["Size:", "Size:", "Size:", "Size:", "大小:"]),
("Screen and Email", ["Bildschirm und E-Mail", "Écran et e-mail", "Pantalla y correo", "画面とメール", "屏幕和电子邮件"]),
(
"Space Between Footnotes:",
["Space Between Footnotes:", "Space Between Footnotes:", "Space Between Footnotes:", "Space Between Footnotes:", "脚注间距:"],
),
("Speed", ["Speed", "Speed", "Speed", "Speed", "速度"]),
("Standard", ["Standard", "Norme", "Estándar", "標準", "标准"]),
("Standard:", ["Standard:", "Norme :", "Estándar:", "標準:", "标准:"]),
("Star Inset:", ["Star Inset:", "Star Inset:", "Star Inset:", "Star Inset:", "星形内边距:"]),
("Start Page #:", ["Start Page #:", "Start Page #:", "Start Page #:", "Start Page #:", "起始页码:"]),
("Start at:", ["Start at:", "Start at:", "Start at:", "Start at:", "起始编号:"]),
@@ -524,6 +553,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Suffix:", ["Suffix:", "Suffix:", "Suffix:", "Suffix:", "后缀:"]),
("Synchronize", ["Synchronize", "Synchronize", "Synchronize", "Synchronize", "同步"]),
("Tag:", ["Tag:", "Tag:", "Tag:", "Tag:", "标签:"]),
("Tagged PDF", ["Markiertes PDF", "PDF balisé", "PDF etiquetado", "タグ付き PDF", "带标签的 PDF"]),
("Text Frame", ["Text Frame", "Text Frame", "Text Frame", "Text Frame", "文本框架"]),
("Text Frame Options", ["Text Frame Options", "Text Frame Options", "Text Frame Options", "Text Frame Options", "文本框架选项"]),
("Text Message", ["Text Message", "Text Message", "Text Message", "Text Message", "短信"]),
@@ -735,6 +765,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
"Community & Project Links",
["Community & Project Links", "Community & Project Links", "Community & Project Links", "Community & Project Links", "社区与项目链接"],
),
("Compress images", ["Bilder komprimieren", "Compresser les images", "Comprimir imágenes", "画像を圧縮", "压缩图像"]),
("Compression", ["Komprimierung", "Compression", "Compresión", "圧縮", "压缩"]),
("Conditions", ["Conditions", "Conditions", "Conditions", "Conditions", "条件"]),
("Container", ["Container", "Container", "Container", "Container", "容器"]),
("Content", ["Content", "Content", "Content", "Content", "内容"]),
@@ -3002,4 +3034,19 @@ mod tests {
assert_eq!(tr("ja", en), ja);
}
}
#[test]
fn pdf_export_dialog_strings_are_translated() {
// Translated labels in every supported language.
assert_ne!(tr("zh", "Crop Marks"), "Crop Marks");
assert_ne!(tr("de", "Tagged PDF"), "Tagged PDF");
assert_ne!(tr("ar", "Include document bleed"), "Include document bleed");
assert_ne!(tr("pt-br", "Compress images"), "Compress images");
// English returns the key unchanged.
assert_eq!(tr("", "Export PDF"), "Export PDF");
// An unknown string still falls through unchanged.
assert_eq!(tr("de", "Low Quality Print"), "Low Quality Print");
// Fixed standard names stay identical in every language.
assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4");
}
}
+20
View File
@@ -776,6 +776,26 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Ruby", "روبي"),
("Show Note Anchors in Layout View", "إظهار مراسي الملاحظات في عرض التخطيط"),
("Spreads", "صفحات متقابلة"),
("Export PDF", "تصدير PDF"),
("Standard", "معيار"),
("Standard:", "المعيار:"),
("Compress images", "ضغط الصور"),
("Flattener preset", "إعداد تسوية الشفافية المسبق"),
("Include document bleed", "تضمين نزف المستند"),
("Crop Marks", "علامات القص"),
("Bleed Marks", "علامات النزف"),
("Page Information", "معلومات الصفحة"),
("Page Range", "نطاق الصفحات"),
("Page Range:", "نطاق الصفحات:"),
("Tagged PDF", "PDF موسوم"),
("Desktop Printing", "طباعة مكتبية"),
("Commercial Printing", "طباعة تجارية"),
("Screen and Email", "الشاشة والبريد الإلكتروني"),
("PDF/X-4", "PDF/X-4"),
("PDF/A-2b", "PDF/A-2b"),
("Compression", "ضغط"),
("Marks and Bleeds", "العلامات والنزف"),
("Preset:", "الإعداد المسبق:"),
("Substituted Fonts", "الخطوط المستبدلة"),
("Transformations are Totals", "عرض قيم التحويل الإجمالية"),
("Use Black Point Compensation", "استخدام تعويض النقطة السوداء"),
+20
View File
@@ -778,6 +778,26 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Ruby", "Ruby"),
("Show Note Anchors in Layout View", "Mostrar âncoras de notas na exibição de layout"),
("Spreads", "Páginas duplas"),
("Export PDF", "Exportar PDF"),
("Standard", "Padrão"),
("Standard:", "Padrão:"),
("Compress images", "Compactar imagens"),
("Flattener preset", "Predefinição de achatamento"),
("Include document bleed", "Incluir sangria do documento"),
("Crop Marks", "Marcas de corte"),
("Bleed Marks", "Marcas de sangria"),
("Page Information", "Informações da página"),
("Page Range", "Intervalo de páginas"),
("Page Range:", "Intervalo de páginas:"),
("Tagged PDF", "PDF marcado"),
("Desktop Printing", "Impressão de escritório"),
("Commercial Printing", "Impressão comercial"),
("Screen and Email", "Tela e e-mail"),
("PDF/X-4", "PDF/X-4"),
("PDF/A-2b", "PDF/A-2b"),
("Compression", "Compactação"),
("Marks and Bleeds", "Marcas e sangria"),
("Preset:", "Predefinição:"),
("Substituted Fonts", "Fontes substituídas"),
("Transformations are Totals", "Transformações são totais"),
("Use Black Point Compensation", "Usar compensação de ponto preto"),
+78
View File
@@ -109,6 +109,49 @@ fn default_zone() -> f64 {
4.0
}
/// File › Export PDF… options: the values persisted in `UiState.pdf_export` and used as the
/// dialog's defaults. Serialised as `pdfExport` with camelCase field names.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PdfExportSettings {
/// Preset (one of the five presets, or "Custom" after a manual edit).
pub preset: String,
/// PDF standard (`none`, `x4`, `a2b`).
pub standard: String,
/// Compression › Compress images (JPEG).
pub compress_images: bool,
/// Transparency flattener preset (`""` = none, `high`, `medium`, `low`).
pub flatten: String,
pub spreads: bool,
pub bleed: bool,
pub marks_crop: bool,
pub marks_bleed: bool,
pub marks_page_info: bool,
pub marks_weight: String,
pub marks_offset: String,
/// File › Export PDF › Advanced › Tagged PDF.
pub tagged: bool,
}
impl Default for PdfExportSettings {
fn default() -> Self {
PdfExportSettings {
preset: "Desktop Printing".into(),
standard: "none".into(),
compress_images: false,
flatten: "".into(),
spreads: false,
bleed: true,
marks_crop: false,
marks_bleed: false,
marks_page_info: false,
marks_weight: "0.25".into(),
marks_offset: "6".into(),
tagged: true,
}
}
}
/// Persisted UI state.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -149,6 +192,8 @@ pub struct UiState {
pub language: String,
/// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export.
pub flattener: String,
/// File › Export PDF… options (persisted; see `PdfExportSettings`).
pub pdf_export: PdfExportSettings,
/// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4).
#[serde(skip)]
pub separation: Option<u8>,
@@ -252,6 +297,7 @@ impl Default for UiState {
dynamic_spelling: false,
language: String::new(),
flattener: String::new(),
pdf_export: PdfExportSettings::default(),
separation: None,
ink_limit: None,
display_quality: designcraft_render::DisplayQuality::High,
@@ -1124,6 +1170,38 @@ mod tests {
assert_eq!(ui.snap_view().zone_px, 0.0);
}
#[test]
fn pdf_export_settings_round_trip() {
let ui = UiState::default();
let json = serde_json::to_string(&ui).unwrap();
let parsed: UiState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.pdf_export, PdfExportSettings::default());
}
#[test]
fn pdf_export_settings_defaults() {
let settings = PdfExportSettings::default();
assert_eq!(settings.preset, "Desktop Printing");
assert_eq!(settings.standard, "none");
assert!(!settings.compress_images);
assert_eq!(settings.flatten, "");
assert!(!settings.spreads);
assert!(settings.bleed);
assert!(!settings.marks_crop);
assert!(!settings.marks_bleed);
assert!(!settings.marks_page_info);
assert_eq!(settings.marks_weight, "0.25");
assert_eq!(settings.marks_offset, "6");
assert!(settings.tagged);
}
#[test]
fn pdf_export_settings_survive_missing_key() {
let json = r#"{ "language": "en" }"#;
let ui: UiState = serde_json::from_str(json).unwrap();
assert_eq!(ui.pdf_export, PdfExportSettings::default());
}
/// A temporary file named `name` whose bytes aren't a document or a graphic.
fn unreadable_file(name: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}", std::process::id()));
+21 -2
View File
@@ -973,7 +973,14 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) };
Ok(Value::Null)
}
"app.exportPdf" => export_pdf(app, p),
"app.exportPdf" => {
if p.is_null() || p.as_object().is_none_or(|m| m.is_empty()) {
crate::dialogs::open_pdf_export(app);
Ok(Value::Null)
} else {
export_pdf(app, p)
}
}
"app.palette" => {
app.ui.palette = Some(String::new());
Ok(Value::Null)
@@ -1531,7 +1538,7 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result<
/// File › Export PDF…: ask for a path, export through `file.exportPdf` and write the bytes with the
/// platform writer (a download on the web).
fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let path = match p.get("path").and_then(Value::as_str) {
Some(s) => Some(s.to_string()),
@@ -2347,6 +2354,18 @@ mod tests {
}
}
#[test]
fn pdf_export_menu_item_opens_the_dialog_or_exports() {
// No params: the menu item opens the options dialog.
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
activate(&mut app, "app.exportPdf", &Value::Null);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("pdfExport"));
// Explicit options: programmatic callers still export directly, opening no dialog.
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
activate(&mut app, "app.exportPdf", &json!({"path": "/tmp/x.pdf"}));
assert!(app.ui.dialog.is_none());
}
#[test]
fn snap_preferences_are_a_command() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());