diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 0a9defed..7bad9e58 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -306,12 +306,13 @@ fn format_filters() -> impl Iterator impl Iterator { std::iter::once(("All readable files", OPEN_EXTS)) .chain(format_filters()) .chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS))) + .chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS))) } /// File → Place dialog filters: "All placeable files", then one per readable format, then text. diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index fb23b331..a13779b0 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -24,7 +24,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS, - FormatOption { name: "preset", ty: "string", default: "\"VectorCraft Default\"", description: "the PDF preset the other options apply over" }, + FormatOption { + name: "preset", + ty: "string", + default: "\"VectorCraft Default\"", + description: "the PDF preset the other options apply over (built-in or saved: pdf.preset.list)", + }, FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" }, FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" }, FormatOption { @@ -48,7 +53,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\", artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength}, compressText?: true}, marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength}, compressText?: true}, marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", has_doc, export_pdf ), @@ -57,7 +62,7 @@ pub fn specs() -> Vec { "PDF Settings", [], None, - "{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the defaults), warnings}; includeDocument also exports the active document in memory and adds its warnings (pattern strokes, effects left out…)", + "{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (pattern strokes, effects left out…)", always, pdf_settings ), @@ -66,7 +71,12 @@ pub fn specs() -> Vec { /// Every preset name: the built-in ones, then the saved ones ([`crate::Prefs::pdf_presets`]). pub fn presets(s: &Session) -> Vec { - vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(s.prefs.pdf_presets.iter().map(|p| p.name.clone())).collect() + names(&s.prefs.pdf_presets) +} + +/// The built-in preset names, then those of `saved`. +fn names(saved: &[PdfPreset]) -> Vec { + vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(saved.iter().map(|p| p.name.clone())).collect() } /// The preset `name` names: a built-in one (any case; `default` is the app default) or one of @@ -76,10 +86,9 @@ pub fn find_preset(name: &str, saved: &[PdfPreset]) -> Option { } fn preset(cmd: &str, name: &str, saved: &[PdfPreset]) -> Result { - find_preset(name, saved).map(|p| p.settings).ok_or_else(|| { - let names: Vec = vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(saved.iter().map(|p| p.name.clone())).collect(); - bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", names.join(", "))) - }) + find_preset(name, saved) + .map(|p| p.settings) + .ok_or_else(|| bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", names(saved).join(", ")))) } /// The settings of the preset `p` names (default: [`DEFAULT_PRESET`]), before `p`'s options. diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index e56fb656..07da7a6e 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -146,7 +146,7 @@ fn open_exts_cover_every_readable_format() { } let filters: Vec<_> = open_filters().collect(); assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); - assert_eq!(filters.len(), 3 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries and flattener presets"); + assert_eq!(filters.len(), 4 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener presets and PDF presets"); } #[test] diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f725c6a7..4f0fbd4b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -40,6 +40,7 @@ mod panelcmds; mod path; mod pathops; mod patterncmds; +pub mod pdfcmds; mod place; pub mod prefscmds; pub mod rasterfx; @@ -220,6 +221,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(newdoc::specs()); v.extend(place::specs()); v.extend(links::specs()); + v.extend(pdfcmds::specs()); v }) } diff --git a/crates/engine/src/cmd/pdfcmds.rs b/crates/engine/src/cmd/pdfcmds.rs new file mode 100644 index 00000000..9245cd67 --- /dev/null +++ b/crates/engine/src/cmd/pdfcmds.rs @@ -0,0 +1,210 @@ +//! Edit → PDF Presets: `pdf.preset.list`, `save`, `delete`, `export` and `import`. The built-in +//! presets are generated in code ([`vectorcraft_pdf::builtin_presets`]) and read-only; the user's +//! live in [`crate::Prefs::pdf_presets`]. Any of their names works as `preset` wherever PDF +//! options are taken (`document.exportPdf`, `document.export`, a `.ai` save…). + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_pdf::{PdfPreset, builtin_preset, builtin_presets}; + +use super::fileio::pdf::{find_preset, resolve}; +use super::*; + +/// The `format` of a PDF presets file, also its extension. +pub const PRESET_FORMAT: &str = "vcpdfpresets"; + +/// The extensions of PDF presets files (opening one imports it). +pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT]; + +/// What `pdf.preset.export` writes and `pdf.preset.import` reads. +#[derive(Serialize, Deserialize)] +struct PresetFile { + format: String, + #[serde(default)] + presets: Vec, +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "pdf.preset.list", + "PDF Presets", + [], + None, + "{} → {presets: [{name, builtIn, supported (the writer produces its standard), description, settings (as document.pdfSettings)}]} the built-in presets (VectorCraft Default first), then the saved ones; any of these names works as `preset` wherever PDF options are taken", + always, + list + ), + cmd!( + query "pdf.preset.save", + "Save PDF Preset", + [], + None, + "{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …document.exportPdf options} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}", + always, + save + ), + cmd!(query "pdf.preset.delete", "Delete PDF Preset", [], None, "{name} delete a saved preset (built-in ones stay) → {deleted: name}", always, delete), + cmd!( + query "pdf.preset.export", + "Export PDF Presets", + [], + None, + "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcpdfpresets file (JSON) → {path, count}; without path → {data: the file's text, count}", + always, + export + ), + cmd!( + query "pdf.preset.import", + "Import PDF Presets", + [], + None, + "{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else imported ones whose name is taken get a number)} add the presets of a .vcpdfpresets file (as pdf.preset.export writes) to the saved ones → {imported: [names]}", + always, + import + ), + ] +} + +impl Session { + /// Every PDF preset: the built-in ones, then the saved ones. + pub fn pdf_presets(&self) -> Vec { + let mut v = builtin_presets(); + v.extend(self.prefs.pdf_presets.iter().cloned()); + v + } + + /// The first free "PDF Preset N": the name a new preset gets. + pub fn new_pdf_preset_name(&self) -> String { + (1..).map(|i| format!("PDF Preset {i}")).find(|n| !self.pdf_preset_taken(n, None)).unwrap_or_default() + } + + /// The index of the saved PDF preset `name` (any case). + fn pdf_saved_index(&self, name: &str) -> Option { + self.prefs.pdf_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim())) + } + + /// Whether `name` is taken by a built-in PDF preset or a saved one other than the one at + /// `except`. + fn pdf_preset_taken(&self, name: &str, except: Option) -> bool { + builtin_preset(name).is_some() || self.pdf_saved_index(name).is_some_and(|i| Some(i) != except) + } +} + +fn row(p: PdfPreset, built_in: bool) -> Value { + json!({ "name": p.name, "builtIn": built_in, "supported": p.settings.check().is_ok(), "description": p.description, "settings": p.settings }) +} + +fn list(s: &mut Session, _: &Value) -> Result { + let rows = builtin_presets().into_iter().map(|p| row(p, true)).chain(s.prefs.pdf_presets.iter().cloned().map(|p| row(p, false))); + Ok(json!({ "presets": rows.collect::>() })) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "pdf.preset.save"; + let name = match str_param(p, "name").map(str::trim) { + Some("") => return Err(bad(C, "`name` is empty")), + Some(n) => n.to_string(), + None => s.new_pdf_preset_name(), + }; + if builtin_preset(&name).is_some() { + return Err(bad(C, format!("`{name}` is a built-in preset and is read-only: save it under another name"))); + } + let at = s.pdf_saved_index(&name); + let saved = at.and_then(|i| s.prefs.pdf_presets.get(i)).cloned(); + // A saved preset changes from its own settings unless `preset` says where to start. + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + if let (Some(old), None) = (&saved, str_param(p, "preset")) { + q["preset"] = json!(old.name); + } + let mut settings = resolve(C, &q, &s.prefs.pdf_presets)?; + settings.clear_passwords(); + let name = match str_param(p, "newName").map(str::trim) { + Some("") => return Err(bad(C, "`newName` is empty")), + Some(n) if s.pdf_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))), + Some(n) => n.to_string(), + None => saved.as_ref().map_or(name, |old| old.name.clone()), + }; + let description = str_param(p, "description").map(str::to_string).or_else(|| saved.map(|old| old.description)).unwrap_or_default(); + let preset = PdfPreset { name: name.clone(), description, settings: settings.clone() }; + match at.and_then(|i| s.prefs.pdf_presets.get_mut(i)) { + Some(slot) => *slot = preset, + None => s.prefs.pdf_presets.push(preset), + } + Ok(json!({ "name": name, "created": at.is_none(), "settings": settings })) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "pdf.preset.delete"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + if builtin_preset(name).is_some() { + return Err(bad(C, format!("`{name}` is a built-in preset and stays"))); + } + let i = s.pdf_saved_index(name).ok_or_else(|| bad(C, format!("no saved PDF preset named `{name}`")))?; + Ok(json!({ "deleted": s.prefs.pdf_presets.remove(i).name })) +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "pdf.preset.export"; + let presets = match p.get("names").and_then(Value::as_array) { + Some(names) => { + let find = |n: &Value| { + let n = n.as_str().unwrap_or_default(); + find_preset(n, &s.prefs.pdf_presets).ok_or_else(|| bad(C, format!("no PDF preset named `{n}`"))) + }; + names.iter().map(find).collect::>>()? + } + None => s.prefs.pdf_presets.clone(), + }; + if presets.is_empty() { + return Err(bad(C, "no saved presets to export (name built-in ones in `names`)")); + } + let count = presets.len(); + let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?; + match str_param(p, "path") { + Some(path) => { + super::fileio::write_file(path, text.as_bytes())?; + Ok(json!({"path": path, "count": count})) + } + None => Ok(json!({"data": text, "count": count})), + } +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "pdf.preset.import"; + let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => super::fileio::read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?, + _ => return Err(bad(C, "give `path`, `data` or `dataBase64`")), + }; + let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a PDF presets file: {e}")))?; + if file.format != PRESET_FORMAT { + return Err(bad(C, format!("not a PDF presets file (format `{}`)", file.format))); + } + // Values a hand-edited file may carry are refused as the export options refuse them. + for q in &file.presets { + q.settings.check_values().map_err(|e| bad(C, format!("`{}`: {e}", q.name)))?; + } + let replace = bool_or(p, "replace", false); + let mut imported = vec![]; + for mut preset in file.presets { + preset.settings.clear_passwords(); + let base = Some(preset.name.trim()).filter(|n| !n.is_empty()).unwrap_or("PDF Preset").to_string(); + match s.pdf_saved_index(&base).filter(|_| replace) { + Some(i) => { + if let Some(slot) = s.prefs.pdf_presets.get_mut(i) { + preset.name = slot.name.clone(); + imported.push(preset.name.clone()); + *slot = preset; + } + } + None => { + preset.name = unique_name(&base, |n| s.pdf_preset_taken(n, None)); + imported.push(preset.name.clone()); + s.prefs.pdf_presets.push(preset); + } + } + } + Ok(json!({ "imported": imported })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 85d233b8..01eb31a2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -996,6 +996,8 @@ mod tests_pathops; #[cfg(test)] mod tests_pattern; #[cfg(test)] +mod tests_pdfpresets; +#[cfg(test)] mod tests_place; #[cfg(test)] mod tests_prefs; diff --git a/crates/engine/src/tests_pdfpresets.rs b/crates/engine/src/tests_pdfpresets.rs new file mode 100644 index 00000000..b50db34e --- /dev/null +++ b/crates/engine/src/tests_pdfpresets.rs @@ -0,0 +1,137 @@ +//! Edit → PDF Presets: built-in presets are read-only, saved ones apply wherever a preset is named, +//! and presets files round-trip. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::fileio::pdf::DEFAULT_PRESET; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s +} + +fn names(s: &mut Session) -> Vec { + let v = s.execute("pdf.preset.list", &json!({})).unwrap(); + v["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect() +} + +fn pdf(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +fn is_bad_params(r: crate::Result) -> bool { + matches!(r, Err(crate::EngineError::BadParams { .. })) +} + +#[test] +fn built_in_presets_are_listed_and_read_only() { + let mut s = session(); + let v = s.execute("pdf.preset.list", &json!({})).unwrap(); + let rows = v["presets"].as_array().unwrap(); + assert_eq!(rows[0]["name"], DEFAULT_PRESET); + assert!(rows.iter().all(|r| r["builtIn"] == true && !r["description"].as_str().unwrap().is_empty())); + let row = |n: &str| rows.iter().find(|r| r["name"] == n).unwrap_or_else(|| panic!("{n}")); + for n in ["High Quality Print", "Press Quality", "Smallest File Size", "PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] { + assert_eq!(row(n)["builtIn"], true); + } + assert_eq!(row("PDF/X-4:2010")["supported"], false, "PDF/X is not written yet"); + assert_eq!(row(DEFAULT_PRESET)["settings"]["preserveEditing"], true); + // Read-only: neither changed nor deleted, in any case. + assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "press quality", "compatibility": "1.4"})))); + assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "default"})))); + assert!(is_bad_params(s.execute("pdf.preset.delete", &json!({"name": "Smallest File Size"})))); + assert_eq!(names(&mut s).len(), 7); + // Built-in presets apply as named. + let v = s.execute("document.pdfSettings", &json!({"preset": "Smallest File Size"})).unwrap(); + assert_eq!((&v["settings"]["fastWebView"], &v["changed"]), (&json!(true), &json!([])), "{v}"); + assert!(is_bad_params(s.execute("document.exportPdf", &json!({"preset": "PDF/X-4:2010"}))), "PDF/X is refused on export"); +} + +#[test] +fn saved_presets_apply_wherever_a_preset_is_named() { + let mut s = session(); + let r = s + .execute("pdf.preset.save", &json!({"name": "Web", "preset": "Smallest File Size", "compatibility": "1.5", "description": "For the site"})) + .unwrap(); + assert_eq!((&r["name"], &r["created"]), (&json!("Web"), &json!(true))); + assert_eq!(names(&mut s).last().map(String::as_str), Some("Web")); + // document.exportPdf, document.export, serialize and pdfSettings take it. + let v = s.execute("document.exportPdf", &json!({"preset": "web"})).unwrap(); + assert!(pdf(&v).starts_with(b"%PDF-1.5"), "the preset's compatibility"); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fast web view")), "{v}"); + let v = s.execute("document.export", &json!({"format": "pdf", "preset": "Web"})).unwrap(); + assert!(pdf(&v).starts_with(b"%PDF-1.5")); + let v = s.execute("document.serialize", &json!({"format": "pdf", "preset": "Web", "compatibility": "1.6"})).unwrap(); + assert!(pdf(&v).starts_with(b"%PDF-1.6"), "options apply over it"); + let v = s.execute("document.pdfSettings", &json!({"preset": "Web", "thumbnails": true})).unwrap(); + assert_eq!(v["changed"], json!([{"option": "thumbnails", "value": true}]), "{v}"); + assert!(v["presets"].as_array().unwrap().contains(&json!("Web"))); + // Saving it again changes it from its own settings; it can be renamed but not to a taken name. + let r = s.execute("pdf.preset.save", &json!({"name": "Web", "thumbnails": true})).unwrap(); + assert_eq!((&r["created"], &r["settings"]["compatibility"], &r["settings"]["thumbnails"]), (&json!(false), &json!("1.5"), &json!(true))); + assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Press Quality"})))); + assert_eq!(s.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Site"})).unwrap()["name"], "Site"); + assert_eq!(s.prefs.pdf_presets[0].description, "For the site", "kept"); + // Passwords are never stored; new presets get a free name. + s.execute("pdf.preset.save", &json!({"name": "Locked", "security": {"openPassword": "x"}})).unwrap(); + assert!(s.prefs.pdf_presets[1].settings.security.open_password.is_empty()); + assert_eq!(s.execute("pdf.preset.save", &json!({})).unwrap()["name"], "PDF Preset 1"); + assert_eq!(s.execute("pdf.preset.delete", &json!({"name": "locked"})).unwrap()["deleted"], "Locked"); + assert!(is_bad_params(s.execute("document.exportPdf", &json!({"preset": "Locked"}))), "deleted"); +} + +#[test] +fn presets_export_and_import_round_trip() { + let mut s = session(); + s.execute("pdf.preset.save", &json!({"name": "Proof", "preset": "High Quality Print", "marks": {"trim": true}})).unwrap(); + let saved = s.prefs.pdf_presets.clone(); + let data = s.execute("pdf.preset.export", &json!({"names": ["Proof", "Press Quality"]})).unwrap(); + assert_eq!(data["count"], 2); + let text = data["data"].as_str().unwrap().to_string(); + s.execute("pdf.preset.delete", &json!({"name": "Proof"})).unwrap(); + let r = s.execute("pdf.preset.import", &json!({"data": text})).unwrap(); + assert_eq!(r["imported"], json!(["Proof", "Press Quality 2"]), "a built-in name gets a number"); + assert_eq!(s.prefs.pdf_presets[0], saved[0], "the same preset comes back"); + let press = vectorcraft_pdf::builtin_preset("Press Quality").unwrap(); + assert_eq!(s.prefs.pdf_presets[1].settings, press.settings); + // replace: saved presets of the same names are overwritten. + let r = s.execute("pdf.preset.import", &json!({"data": text, "replace": true})).unwrap(); + assert_eq!(r["imported"], json!(["Proof", "Press Quality 3"])); + assert_eq!(s.prefs.pdf_presets.len(), 3); + // Through a file, and every saved preset by default. + let path = std::env::temp_dir().join(format!("vc-pdfpresets-{}.vcpdfpresets", std::process::id())).to_string_lossy().to_string(); + assert_eq!(s.execute("pdf.preset.export", &json!({"path": path})).unwrap()["count"], 3); + let mut t = session(); + assert_eq!(t.execute("pdf.preset.import", &json!({"path": path})).unwrap()["imported"].as_array().unwrap().len(), 3); + assert_eq!(t.prefs.pdf_presets, s.prefs.pdf_presets); + let _ = std::fs::remove_file(&path); + // Not a presets file, or values out of range: refused, nothing added. + for bad in [ + json!({"data": "{}"}), + json!({"data": "{\"format\": \"vcflattener\", \"presets\": []}"}), + json!({"data": "{\"format\": \"vcpdfpresets\", \"presets\": [{\"name\": \"X\", \"settings\": {\"marks\": {\"weight\": 99}}}]}"}), + json!({"dataBase64": "!!"}), + json!({}), + ] { + assert!(is_bad_params(t.execute("pdf.preset.import", &bad)), "{bad}"); + } + assert_eq!(t.prefs.pdf_presets.len(), 3); + assert!(is_bad_params(Session::new().execute("pdf.preset.export", &json!({}))), "nothing saved to export"); +} + +#[test] +fn saved_presets_persist_with_the_preferences() { + let mut s = session(); + s.execute("pdf.preset.save", &json!({"name": "Kept", "compatibility": "1.4"})).unwrap(); + let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.pdf_presets, s.prefs.pdf_presets); + // Resetting the preferences keeps them (a library, not a preference). + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.pdf_presets.len(), 1); + // Preferences saved before PDF presets existed still load. + let old: crate::Prefs = serde_json::from_value(json!({"keyboardIncrement": 2.0})).unwrap(); + assert!(old.pdf_presets.is_empty()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 8fdfdb74..7e4a9b8a 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -563,3 +563,30 @@ proptest! { survive("mutated ai", || vectorcraft_engine::cmd::fileio::load("saved.ai", &b).ok().map(|l| l.doc))?; } } + +// ---------- PDF presets files ---------- + +/// Import PDF presets from `data`, then export with each one imported (and save the .ai). +fn pdf_presets(what: &str, data: &str) -> Result<(), TestCaseError> { + survive_library(what, "pdf.preset.import", data, |s, r| { + for name in r["imported"].as_array().into_iter().flatten() { + let _ = s.execute("document.exportPdf", &json!({"preset": name})); + let _ = s.execute("document.save", &json!({"format": "ai", "preset": name})); + } + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn pdf_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcpdfpresets\", ", "{\"format\": \"vcpdfpresets\", \"presets\": [{\"name\": \"x\", \"settings\": "])) { + pdf_presets("pdf presets garbage", &format!("{head}{s}"))?; + } + + #[test] + fn mutated_pdf_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { + let text = saved("pdf.preset.export", json!({"names": ["VectorCraft Default", "Smallest File Size", "PDF/X-4:2010"]})); + pdf_presets("mutated pdf presets", &mutate_text(&text, cut, &edits))?; + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1853636e..b24e19a4 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -166,6 +166,8 @@ mod tests_import_color; #[cfg(test)] mod tests_import_options; #[cfg(test)] +mod tests_presets; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_stroke; diff --git a/crates/pdf/src/presets.rs b/crates/pdf/src/presets.rs index 9880870c..ebcb2781 100644 --- a/crates/pdf/src/presets.rs +++ b/crates/pdf/src/presets.rs @@ -1,9 +1,13 @@ -//! PDF presets: named [`PdfSettings`]. The built-in ones are generated here (the app default -//! keeps the document editable). +//! PDF presets: named [`PdfSettings`]. The built-in ones are generated here (the app default, +//! which keeps the document editable, plus presets for print, press, small files and the PDF/X +//! standards); the user's own live in the app's preferences. use serde::{Deserialize, Serialize}; -use crate::PdfSettings; +use crate::{ + BleedSettings, Compatibility, CompressionSettings, Downsample, ImageCodec, ImageCompression, JpegQuality, MonoCodec, MonoCompression, + PdfSettings, Standard, +}; /// The built-in preset every PDF export starts from. pub const DEFAULT_PRESET: &str = "VectorCraft Default"; @@ -20,13 +24,75 @@ pub struct PdfPreset { pub settings: PdfSettings, } +/// Colour and greyscale images resampled (bicubic) to `ppi` above `above` ppi, compressed with +/// `codec`. +fn images(ppi: f64, above: f64, codec: ImageCodec, quality: JpegQuality) -> ImageCompression { + ImageCompression { downsample: Downsample::Bicubic, ppi, above_ppi: above, compression: codec, quality } +} + +/// Compression with the same settings for colour and greyscale images, and CCITT Group 4 +/// monochrome images resampled to `mono_ppi` above 1.5 × that. +fn compression(img: ImageCompression, mono_ppi: f64) -> CompressionSettings { + let mono = MonoCompression { downsample: Downsample::Bicubic, ppi: mono_ppi, above_ppi: mono_ppi * 1.5, compression: MonoCodec::CcittG4 }; + CompressionSettings { color: img.clone(), gray: img, mono, compress_text: true } +} + +/// Settings for print: PDF 1.6, images kept at 300 ppi, the document's bleed when `bleed`. +fn print(codec: ImageCodec, bleed: bool) -> PdfSettings { + PdfSettings { + compatibility: Compatibility::Pdf16, + compression: compression(images(300.0, 450.0, codec, JpegQuality::Maximum), 1200.0), + bleed: BleedSettings { use_document: bleed, ..Default::default() }, + ..Default::default() + } +} + +/// A PDF/X preset: print settings for `standard` at PDF version `compatibility`. +fn pdf_x(standard: Standard, compatibility: Compatibility) -> PdfSettings { + PdfSettings { standard, compatibility, ..print(ImageCodec::Auto, true) } +} + /// Every built-in preset, the app default first. pub fn builtin_presets() -> Vec { - vec![PdfPreset { - name: DEFAULT_PRESET.into(), - description: "Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again.".into(), - settings: PdfSettings { preserve_editing: true, ..Default::default() }, - }] + let preset = |name: &str, description: &str, settings| PdfPreset { name: name.into(), description: description.into(), settings }; + vec![ + preset( + DEFAULT_PRESET, + "Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again.", + PdfSettings { preserve_editing: true, ..Default::default() }, + ), + preset("High Quality Print", "For desktop printers and proofs: images stay at print resolution.", print(ImageCodec::Auto, false)), + preset( + "Press Quality", + "For commercial printing: high-resolution images compressed losslessly, the document's bleed.", + print(ImageCodec::Zip, true), + ), + preset( + "Smallest File Size", + "For screens, email and the web: images resampled to 100 ppi and compressed.", + PdfSettings { + compatibility: Compatibility::Pdf16, + fast_web_view: true, + compression: compression(images(100.0, 150.0, ImageCodec::Jpeg, JpegQuality::Medium), 300.0), + ..Default::default() + }, + ), + preset( + "PDF/X-1a:2001", + "Print exchange with CMYK and spot colours only (PDF/X output is not supported yet).", + pdf_x(Standard::PdfX1a, Compatibility::Pdf14), + ), + preset( + "PDF/X-3:2002", + "Print exchange allowing colour-managed colours (PDF/X output is not supported yet).", + pdf_x(Standard::PdfX3, Compatibility::Pdf14), + ), + preset( + "PDF/X-4:2010", + "Print exchange keeping live transparency and layers (PDF/X output is not supported yet).", + pdf_x(Standard::PdfX4, Compatibility::Pdf16), + ), + ] } /// The built-in preset `name` names (any case; `default` is the app default). diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 59677c06..6745c9e9 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -424,6 +424,12 @@ impl PdfSettings { within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%") } + /// Forget the passwords (presets never store them). + pub fn clear_passwords(&mut self) { + self.security.open_password.clear(); + self.security.permissions_password.clear(); + } + /// Options that are accepted but not applied by the writer yet, one warning each. pub fn warnings(&self) -> Vec { let d = Self::default(); diff --git a/crates/pdf/src/tests_presets.rs b/crates/pdf/src/tests_presets.rs new file mode 100644 index 00000000..2a026d38 --- /dev/null +++ b/crates/pdf/src/tests_presets.rs @@ -0,0 +1,22 @@ +//! The built-in PDF presets: generated in code, valid, described, and round-tripping as JSON. + +use crate::*; + +#[test] +fn built_in_presets() { + let all = builtin_presets(); + assert_eq!(all[0].name, DEFAULT_PRESET); + assert!(all[0].settings.preserve_editing, "the app default keeps the document editable"); + assert_eq!(all.len(), 7); + for p in &all { + assert_eq!(p.settings.check_values(), Ok(()), "{}", p.name); + assert!(!p.description.is_empty()); + let back: PdfPreset = serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap(); + assert_eq!(&back, p, "round-trips"); + } + // PDF/X presets name standards the writer doesn't produce yet. + let x4 = builtin_preset("pdf/x-4:2010").unwrap(); + assert!(matches!(x4.settings.check(), Err(PdfError::Unsupported(_)))); + assert_eq!(builtin_preset("default").unwrap().name, DEFAULT_PRESET); + assert!(builtin_preset("Nope").is_none()); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index b971a13b..f8cb41b4 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -32,6 +32,7 @@ pub mod new_color_group; mod new_document; pub mod new_swatch; mod path_ops; +pub mod pdf_presets; pub mod place; mod png_options; pub mod raster_effects; @@ -61,7 +62,7 @@ pub use effect::open as open_effect_dialog; pub use export_as::open as open_export_as; pub use new_document::{open as open_new_document, preset_card}; pub use png_options::open as open_raster_options; -pub use save_pdf::open as open_save_pdf; +pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset}; pub use tools::open_tool_dialog; use crate::state::Dialog; @@ -198,6 +199,8 @@ registry! { RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC, MissingLinks: [missing_links::KIND] => missing_links::SPEC, TextImport: [text_import::KIND] => text_import::SPEC, + PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC, + PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs new file mode 100644 index 00000000..89ae5552 --- /dev/null +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -0,0 +1,316 @@ +//! Edit → PDF Presets: every preset in a list (built-in ones in brackets, read only) with the +//! selected one's description and how its settings differ from the app default beside it; New… +//! (a preset starting from the selected one), Edit… (saved presets), Delete, Import… and Export… +//! (the selected preset) under the list. New… and Edit… open the preset editor (the Save PDF +//! dialog, kind `pdfPreset`), whose OK comes back here. +//! +//! Field: `selected` (a preset's name). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::pdf; +use vectorcraft_engine::cmd::pdfcmds::PRESET_FORMAT; + +use super::DialogSpec; +use super::save_pdf::{open_preset, option_label, section_of}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of PDF Presets. +pub const KIND: &str = "pdfPresets"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "PDF Presets".into(), + body, + confirm: |app, _| { + app.ui.dialog = None; + Ok(Value::Null) + }, + ok: None, + min_width: 680.0, + ..DialogSpec::FORM +}; + +/// Open the presets manager on `selected` (default: the app default). +pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) { + app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(pdf::DEFAULT_PRESET) }))); +} + +/// What a button asked for, done once the list and the details are drawn. +enum Action { + Select(String), + New, + Edit, + Delete, + Import, + Export, +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let presets = app.session.pdf_presets(); + let builtins = presets.len() - app.session.prefs.pdf_presets.len(); + let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(d.str("selected").trim())).unwrap_or(0); + let Some(preset) = presets.get(i) else { return false }; + let builtin = i < builtins; + let mut act = None; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(300.0); + widgets::dim_label(ui, "Presets:"); + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("pdf-presets").min_scrolled_height(260.0).max_height(260.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(260.0); + for (k, p) in presets.iter().enumerate() { + let label = if k < builtins { format!("[{}]", p.name) } else { p.name.clone() }; + if ui.selectable_label(k == i, label).clicked() { + act = Some(Action::Select(p.name.clone())); + } + } + }); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() { + act = Some(Action::New); + } + if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() { + act = Some(Action::Edit); + } + if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() { + act = Some(Action::Delete); + } + if widgets::flat_button(ui, "Import…", 64.0).clicked() { + act = Some(Action::Import); + } + if widgets::flat_button(ui, "Export…", 64.0).on_hover_text("Save the selected preset to a file").clicked() { + act = Some(Action::Export); + } + }); + }); + ui.add_space(18.0); + ui.vertical(|ui| { + ui.set_width(340.0); + ui.label(egui::RichText::new(&preset.name).color(t.text_strong).strong()); + if !preset.description.is_empty() { + ui.add_space(4.0); + ui.label(egui::RichText::new(&preset.description).color(t.text)); + } + ui.add_space(10.0); + details(ui, &t, preset, builtin); + }); + }); + if let Some(a) = act + && let Err(e) = run(app, d, a, &preset.name) + { + app.status(e); + } + false +} + +/// What sets the preset apart: its settings that differ from the app default, and the notes on +/// what Save PDF can't honour or apply yet. +fn details(ui: &mut egui::Ui, t: &Tokens, preset: &vectorcraft_pdf::PdfPreset, builtin: bool) { + let dim = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim).size(11.5)); + widgets::dim_label(ui, &format!("Differences from {}:", pdf::DEFAULT_PRESET)); + let base = vectorcraft_pdf::builtin_preset(pdf::DEFAULT_PRESET).map(|p| p.settings).unwrap_or_default(); + let mut changed = pdf::changes(&preset.settings, &base); + changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default())); + egui::ScrollArea::vertical().id_salt("pdf-preset-details").max_height(180.0).show(ui, |ui| { + if changed.is_empty() { + dim(ui, "None."); + } + for c in &changed { + let value = match &c["value"] { + Value::Bool(b) => if *b { "On" } else { "Off" }.to_string(), + Value::String(s) => s.clone(), + other => other.to_string(), + }; + ui.label(egui::RichText::new(format!("{}: {value}", option_label(c["option"].as_str().unwrap_or_default()))).color(t.text)); + } + }); + ui.add_space(6.0); + if let Err(e) = preset.settings.check() { + dim(ui, &format!("⚠ {e}")); + } + for w in preset.settings.warnings() { + dim(ui, &format!("⚠ {w}")); + } + if builtin { + ui.add_space(6.0); + dim(ui, "Built-in presets are read-only: New… starts an editable copy."); + } +} + +/// Select the preset `name`. +fn select(d: &mut Dialog, name: &str) { + d.fields.insert("selected".into(), json!(name)); +} + +fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> Result<(), String> { + match act { + Action::Select(name) => select(d, &name), + // The editor replaces this dialog; its OK comes back here. + Action::New => { + open_preset(app, &json!({ "preset": current }))?; + *d = app.ui.dialog.take().ok_or("no preset editor")?; + } + Action::Edit => { + open_preset(app, &json!({ "name": current }))?; + *d = app.ui.dialog.take().ok_or("no preset editor")?; + } + Action::Delete => { + let i = app.session.prefs.pdf_presets.iter().position(|p| p.name == current); + app.run("pdf.preset.delete", json!({ "name": current }))?; + // The one above it takes its place (the last built-in one above the first saved one). + let builtins = vectorcraft_pdf::builtin_presets().len(); + let next = app.session.pdf_presets().get((i.unwrap_or(0) + builtins).saturating_sub(1)).map(|p| p.name.clone()); + select(d, &next.unwrap_or_default()); + } + Action::Import => { + // On the web the picked file arrives later and imports through `io::open_bytes`. + if let Some(f) = app.services.open_async.as_mut() { + f(); + return Ok(()); + } + let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + let r = app.run("pdf.preset.import", json!({ "path": path }))?; + if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { + select(d, first); + } + } + Action::Export => { + crate::io::save_command_output(app, "pdf.preset.export", PRESET_FORMAT, json!({ "names": [current] }))?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) + } + + /// Press a manager button on the preset `current`, as the body does. + fn press(app: &mut VectorcraftApp, act: Action, current: &str) { + let mut d = app.ui.dialog.take().unwrap(); + run(app, &mut d, act, current).unwrap(); + app.ui.dialog = Some(d); + frame(app); + } + + #[test] + fn new_edit_and_delete_go_through_the_preset_editor() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("ui.pdfPresetsDialog", json!({"selected": "Press Quality"})).unwrap(); + frame(&mut app); + assert_eq!(kind(&app), Some(KIND)); + // New… opens the editor on a copy of the selected preset; its OK saves it and comes back. + press(&mut app, Action::New, "Press Quality"); + assert_eq!(kind(&app), Some("pdfPreset")); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.str("name"), d.fields["compatibility"].clone()), ("PDF Preset 1".to_string(), json!("1.6"))); + d.fields.insert("name".into(), json!("Press 1.5")); + d.fields.insert("compatibility".into(), json!("1.5")); + d.fields.insert("description".into(), json!("Mine")); + for s in super::super::save_pdf::SECTIONS { + app.ui.dialog.as_mut().unwrap().fields.insert("__section".into(), json!(s)); + frame(&mut app); + } + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["name"], "Press 1.5"); + assert_eq!((kind(&app), app.ui.dialog.as_ref().unwrap().str("selected")), (Some(KIND), "Press 1.5".to_string())); + let saved = &app.session.prefs.pdf_presets[0]; + assert_eq!((saved.settings.compatibility.id(), saved.description.as_str()), ("1.5", "Mine")); + assert!(saved.settings.bleed.use_document, "the rest comes from Press Quality"); + frame(&mut app); + // Edit… renames it; a new preset can't take a taken name. + press(&mut app, Action::Edit, "Press 1.5"); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("__editing"), "Press 1.5"); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Press")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.pdf_presets[0].name, "Press"); + press(&mut app, Action::New, "Press"); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("press")); + assert!(super::super::confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some("pdfPreset"), "stays open on a taken name"); + // Built-in presets can't be edited; Delete selects the preset above. + assert!(open_preset(&mut app, &json!({"name": "Press Quality"})).is_err()); + open(&mut app, Some("Press")); + press(&mut app, Action::Delete, "Press"); + assert!(app.session.prefs.pdf_presets.is_empty()); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("selected"), "PDF/X-4:2010"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "Close"); + } + + #[test] + fn save_pdf_saves_its_settings_as_a_preset() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("ui.savePdfDialog", json!({"preset": "High Quality Print", "compatibility": "1.5"})).unwrap(); + frame(&mut app); + // Save Preset… asks for a name; OK (or Enter) then saves the preset, not the PDF. + app.ui.dialog.as_mut().unwrap().fields.insert("__savePresetAs".into(), json!("Proofs")); + frame(&mut app); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["name"], "Proofs"); + let d = app.ui.dialog.as_ref().expect("Save PDF stays open"); + assert_eq!((d.kind.as_str(), d.str("preset")), ("savePdf", "Proofs".to_string())); + assert!(!d.fields.contains_key("__savePresetAs")); + assert!(d.fields["__presets"].as_array().unwrap().contains(&json!("Proofs"))); + let saved = &app.session.prefs.pdf_presets[0].settings; + assert_eq!(saved.compatibility.id(), "1.5"); + assert_eq!( + saved, + &vectorcraft_pdf::PdfSettings { + compatibility: saved.compatibility, + ..vectorcraft_pdf::builtin_preset("High Quality Print").unwrap().settings + } + ); + // A built-in name is refused and the row stays. + app.ui.dialog.as_mut().unwrap().fields.insert("__savePresetAs".into(), json!("Press Quality")); + assert!(super::super::confirm(&mut app).is_err()); + frame(&mut app); + assert_eq!(app.session.prefs.pdf_presets.len(), 1); + } + + #[test] + fn import_and_export_go_through_files() { + let dir = std::env::temp_dir().join(format!("vc-pdfpresets-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("mine.vcpdfpresets").to_string_lossy().to_string(); + let picked = file.clone(); + let services = crate::Services { + pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("pdf.preset.save", json!({"name": "Mine", "thumbnails": true})).unwrap(); + app.run("ui.pdfPresetsDialog", json!({"selected": "Mine"})).unwrap(); + frame(&mut app); + press(&mut app, Action::Export, "Mine"); + // Opening the file imports it (also how the web's picked file arrives). + let bytes = std::fs::read(&file).unwrap(); + crate::io::open_bytes(&mut app, &file, &bytes, Some(file.clone())).unwrap(); + let names: Vec<&str> = app.session.prefs.pdf_presets.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Mine", "Mine 2"]); + assert!(app.session.prefs.pdf_presets[1].settings.thumbnails); + assert!(app.ui.status.contains("PDF presets"), "{}", app.ui.status); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 34d5cc3f..97ab46e4 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -4,7 +4,10 @@ //! The fields are the `document.exportPdf` options (sections are objects such as `compression`), //! plus `preset`, `range`, `path?` and UI-only `__` keys, so agents fill the dialog with //! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is -//! `document.pdfSettings`. +//! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`). +//! +//! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and +//! `description` instead of the preset and artboards, and OK saves the preset. use std::sync::LazyLock; @@ -25,7 +28,23 @@ pub(super) const KIND: &str = "savePdf"; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save PDF".into(), body, confirm, ok: Some("Save PDF"), ..DialogSpec::FORM }; -const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security", "Summary"]; +/// The dialog kind of the preset editor (New / Edit in Edit → PDF Presets). +pub(super) const PRESET_KIND: &str = "pdfPreset"; + +pub(super) const PRESET_SPEC: DialogSpec = DialogSpec { + heading: |d| if d.str(EDITING).is_empty() { "New PDF Preset" } else { "Edit PDF Preset" }.into(), + body, + confirm: confirm_preset, + ok: Some("Save Preset"), + ..DialogSpec::FORM +}; + +/// The preset editor's field holding the name of the saved preset it edits (empty: a new one). +const EDITING: &str = "__editing"; +/// Save PDF's field holding the name typed for Save Preset… (present while that row shows). +const SAVE_AS: &str = "__savePresetAs"; + +pub(super) const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security", "Summary"]; /// The default settings as JSON: what a field an agent left out reads as. static DEFAULTS: LazyLock = LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default()); @@ -82,7 +101,14 @@ fn params(d: &Dialog) -> Value { } /// Save PDF: write the file; the dialog stays open when that fails (bad range, cancelled save…). +/// While Save Preset… asks for a name, OK (and Enter) saves the preset instead. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.fields.contains_key(SAVE_AS) { + let mut d = d.clone(); + let r = save_preset(app, &mut d); + app.ui.dialog = Some(d); + return r; + } let r = io::export_pdf(app, params(d)); if r.is_ok() { app.ui.dialog = None; @@ -90,6 +116,67 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } +/// The settings as `pdf.preset.save` params (without the artboards and path). +fn preset_params(d: &Dialog) -> Value { + let mut p = form::params(d); + if let Some(o) = p.as_object_mut() { + for k in ["range", "path"] { + o.remove(k); + } + } + p +} + +/// Save Preset…: save the settings under the typed name and pick that preset. +fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result { + let mut p = preset_params(d); + p["name"] = json!(d.str(SAVE_AS)); + let r = app.run("pdf.preset.save", p)?; + d.fields.remove(SAVE_AS); + d.fields.insert("preset".into(), r["name"].clone()); + d.fields.insert("__presets".into(), json!(pdf::presets(&app.session))); + Ok(r) +} + +/// Open the preset editor on the saved preset `name`, or on a new preset starting from `preset` +/// (default: the app default). +pub fn open_preset(app: &mut VectorcraftApp, params: &Value) -> Result { + let s = |k: &str| params.get(k).and_then(Value::as_str); + let (base, name, description, editing) = match s("name") { + Some(name) => { + let p = app.session.prefs.pdf_presets.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim())); + let p = p.ok_or_else(|| format!("no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them)"))?; + (p.name.clone(), p.name.clone(), p.description.clone(), p.name.clone()) + } + None => (s("preset").unwrap_or(pdf::DEFAULT_PRESET).to_string(), app.session.new_pdf_preset_name(), String::new(), String::new()), + }; + let mut fields = settings_fields(app, &json!({ "preset": base }))?; + fields.insert("name".into(), json!(name)); + fields.insert("description".into(), json!(description)); + fields.insert(EDITING.into(), json!(editing)); + fields.insert("__section".into(), json!(SECTIONS[0])); + app.ui.dialog = Some(Dialog { kind: PRESET_KIND.into(), fields }); + Ok(Value::Null) +} + +/// Save Preset (the preset editor's OK): `pdf.preset.save`, then back to Edit → PDF Presets on it. +/// A new preset may not take the name of another one. +fn confirm_preset(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = preset_params(d); + let (name, editing) = (d.str("name"), d.str(EDITING)); + if editing.is_empty() { + if app.session.pdf_presets().iter().any(|q| q.name.eq_ignore_ascii_case(name.trim())) { + return Err(format!("a preset named `{}` exists", name.trim())); + } + } else { + p["name"] = json!(editing); + p["newName"] = json!(name); + } + let r = app.run("pdf.preset.save", p)?; + super::pdf_presets::open(app, r["name"].as_str()); + Ok(r) +} + // ---------- fields by path (`compression.color.ppi`) ---------- fn lookup<'a>(m: &'a Map, path: &str) -> Option<&'a Value> { @@ -204,15 +291,14 @@ fn text(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); - row_with(ui, "Preset:", TOP_LABEL_WIDTH, |ui| { - let presets: Vec<&str> = - d.fields.get("__presets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - let current = d.str("preset"); - let chosen = widgets::dropdown(ui, "pdf-preset", ¤t, &presets, 300.0).and_then(|i| presets.get(i)).map(|p| p.to_string()); - if let Some(name) = chosen { - apply_preset(app, d, &name); - } - }); + let editor = d.kind == PRESET_KIND; + if editor { + row_with(ui, "Preset name:", TOP_LABEL_WIDTH, |ui| { + form::text_edit(ui, d, "name", 288.0); + }); + } else { + preset_rows(app, ui, d); + } row_with(ui, "Standard:", TOP_LABEL_WIDTH, |ui| { let picked = pick::(ui, d, "standard", 170.0, Standard::supported); let standard = choice::(d, "standard").unwrap_or_default(); @@ -257,13 +343,48 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { "Advanced" => advanced(ui, d), "Security" => security(ui, d), "Summary" => summary(app, ui, d), - _ => general(ui, d), + _ => general(ui, d, editor), }); }); }); false } +/// The Preset row (the presets, built-in and saved, and Save Preset…) and, while Save Preset… +/// asks for a name, the row taking it. +fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + row_with(ui, "Preset:", TOP_LABEL_WIDTH, |ui| { + let presets = pdf::presets(&app.session); + let names: Vec<&str> = presets.iter().map(String::as_str).collect(); + let chosen = widgets::dropdown(ui, "pdf-preset", &d.str("preset"), &names, 300.0).and_then(|i| names.get(i)).map(|p| p.to_string()); + if let Some(name) = chosen { + apply_preset(app, d, &name); + } + let asking = d.fields.contains_key(SAVE_AS); + if ui + .add_enabled_ui(!asking, |ui| widgets::flat_button(ui, "Save Preset…", 96.0)) + .inner + .on_hover_text("Save these settings as a preset") + .clicked() + { + d.fields.insert(SAVE_AS.into(), json!(app.session.new_pdf_preset_name())); + } + }); + if d.fields.contains_key(SAVE_AS) { + row_with(ui, "Save as preset:", TOP_LABEL_WIDTH, |ui| { + form::text_edit(ui, d, SAVE_AS, 200.0); + if widgets::flat_button(ui, "Save", 52.0).clicked() + && let Err(e) = save_preset(app, d) + { + app.status(e); + } + if widgets::flat_button(ui, "Cancel", 60.0).clicked() { + d.fields.remove(SAVE_AS); + } + }); + } +} + /// Replace the settings with preset `name`'s (the artboard choice and path stay). fn apply_preset(app: &mut VectorcraftApp, d: &mut Dialog, name: &str) { match settings_fields(app, &json!({ "preset": name })) { @@ -275,7 +396,8 @@ fn apply_preset(app: &mut VectorcraftApp, d: &mut Dialog, name: &str) { } } -fn general(ui: &mut egui::Ui, d: &mut Dialog) { +/// General: the options, then the artboards (Save PDF) or the description (preset editor). +fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { heading(ui, "Options"); let plain = choice::(d, "standard").unwrap_or_default() == Standard::None; flag(ui, d, "preserveEditing", "Preserve editing capabilities", plain); @@ -283,6 +405,13 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog) { flag(ui, d, "fastWebView", "Optimize for fast web view", true); flag(ui, d, "viewAfterSaving", "View PDF after saving", true); flag(ui, d, "createLayers", "Create PDF layers from top-level layers", true); + if editor { + heading(ui, "Description"); + if let Some(text) = widgets::text_field(ui, "pdf-preset-description", Some(&d.str("description")), 460.0, 3) { + d.fields.insert("description".into(), json!(text)); + } + return; + } heading(ui, "Artboards"); let mut all = d.bool("__allArtboards"); ui.horizontal(|ui| { @@ -455,7 +584,7 @@ fn security(ui: &mut egui::Ui, d: &mut Dialog) { } /// The section a changed option belongs to (for the Summary's order). -fn section_of(option: &str) -> usize { +pub(super) fn section_of(option: &str) -> usize { match option.split('.').next().unwrap_or_default() { "compression" => 1, "marks" | "bleed" => 2, @@ -468,7 +597,7 @@ fn section_of(option: &str) -> usize { /// `compression.color.abovePpi` → "Compression › Color › Above Ppi": the section, then the keys /// (without the section's own object), `thumbnails` → "General › Thumbnails". -fn option_label(option: &str) -> String { +pub(super) fn option_label(option: &str) -> String { let section = SECTIONS.get(section_of(option)).copied().unwrap_or(SECTIONS[0]); let mut keys = option.split('.').peekable(); keys.next_if(|k| k.eq_ignore_ascii_case(section)); @@ -507,7 +636,8 @@ fn summary(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut changed: Vec<&Value> = v["changed"].as_array().map(|a| a.iter().collect()).unwrap_or_default(); changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default())); if changed.is_empty() { - note(ui, "Every option is at its default."); + let base = if d.kind == PRESET_KIND { pdf::DEFAULT_PRESET } else { "the preset" }; + note(ui, &format!("Every option matches {base}.")); } for c in changed { let value = match &c["value"] { diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 7769d50d..56de6be3 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -8,13 +8,18 @@ use crate::VectorcraftApp; use crate::dialogs::svg_options; /// Open bytes of any readable format as a new document (templates open untitled); swatch and -/// graphic style library files open in the library panel and flattener presets files are imported. +/// graphic style library files open in the library panel and flattener and PDF presets files are +/// imported. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { let ext = fileio::extension(name); - if vectorcraft_engine::cmd::flatten::PRESET_EXTS.contains(&ext.as_str()) { - let r = app.run("flattener.presets.import", serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; + let presets = [ + (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), + (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), + ]; + if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { + let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - app.status(format!("Imported flattener presets: {}", names.join(", "))); + app.status(format!("Imported {what}: {}", names.join(", "))); return Ok(()); } let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f982f241..106e18f4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -404,6 +404,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", ), + ( + "ui.pdfPresetsDialog", + "PDF Presets…", + "", + "{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export", + ), + ( + "ui.pdfPresetDialog", + "PDF Preset", + "", + "{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -777,6 +789,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::export(app, s("format").as_deref(), s("path"), p), "ui.fileInfoDialog" => crate::dialogs::file_info::open(app), "ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app), + "ui.pdfPresetsDialog" => { + crate::dialogs::pdf_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p), _ => return None, }; Some(r) @@ -1090,7 +1107,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), todo("Print Presets…"), - todo("PDF Presets…"), + c("PDF Presets…", "ui.pdfPresetsDialog"), cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})), Sep, c("Color Settings…", "edit.colorSettings"), diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 0b745969..a34535fb 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -74,7 +74,12 @@ fn swatches_menu_offers_save_swatch_library() { #[test] fn edit_menu_lists_pdf_presets() { let app = app(); - assert!(menus::menu_entries(&app).iter().any(|e| e.path == ["Edit"] && e.label == "PDF Presets…" && !e.enabled)); + // Implemented (M4.44): enabled, and it opens Edit → PDF Presets. + assert!( + menus::menu_entries(&app) + .iter() + .any(|e| e.path == ["Edit"] && e.label == "PDF Presets…" && e.enabled && e.command.as_deref() == Some("ui.pdfPresetsDialog")) + ); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3d891ee1..dcf67f2e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -165,6 +165,15 @@ built-in presets don't change. New, Delete, Import… and Export… are `flatten Opening a `.vcflattener` file with `app.open` imports its presets. In the Flatten Transparency dialog, Save Preset… keeps the dialog's options as a saved preset. +PDF presets: `ui.pdfPresetsDialog {selected?}` opens Edit → PDF Presets (dialog `pdfPresets`, field `selected`: a +preset name). Built-in presets are read-only. New… and Edit… open the preset editor, which `ui.pdfPresetDialog {name? +(a saved preset to edit) | preset? (the preset a new one starts from)}` opens too: dialog `pdfPreset`, the Save PDF +dialog's option fields plus `name` and `description`; `ui.dialog.confirm` runs `pdf.preset.save` and returns to PDF +Presets (a new preset can't take a name in use). Delete, Import… and Export… are `pdf.preset.delete`, +`pdf.preset.import` and `pdf.preset.export`; `ui.dialog.confirm` closes. Opening a `.vcpdfpresets` file with `app.open` +imports its presets. In the Save PDF dialog, Save Preset… shows a name field (`__savePresetAs`): while it shows, +`ui.dialog.confirm` saves the dialog's settings as that preset and selects it instead of writing the PDF. + Flattener Preview: `window.panel {panel: "flattenerPreview"}` shows the panel; `ui.flattenerPreview {highlight?, overprints?, preset?, options?, showOptions?}` sets it (a preset loads its options, `options` adjust them), shows it and refreshes its snapshot of the document, answering as `flattener.preview` does. `ui.inspect` reports the settings diff --git a/docs/mcp.md b/docs/mcp.md index 569deee7..51c06e8f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -183,7 +183,7 @@ PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibili `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused. -`document.pdfSettings` lists the options that differ from the defaults and the warnings without writing a file. +`document.pdfSettings` lists the options that differ from the preset and the warnings without writing a file. Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based), `cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets) @@ -959,3 +959,20 @@ bounds, or the artboard less a 36 pt margin. ```json {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/notes.txt","text":{"removeLineReturns":true,"removeParagraphReturns":true}}}} ``` + +## PDF presets + +`pdf.preset.list` lists the built-in presets (`VectorCraft Default`, which preserves editing, then High Quality Print, +Press Quality, Smallest File Size and the PDF/X presets, `builtIn: true`; `supported: false` where the writer can't +produce the standard yet) and the saved ones, each with its `description` and `settings`. `pdf.preset.save {name?, +newName?, description?, preset?, …document.exportPdf options}` creates or changes a saved preset (a change starts from +the preset's own settings; `newName` renames it; built-in presets are read-only; passwords are never stored); +`pdf.preset.delete {name}` deletes one. `pdf.preset.export {names?, path?}` writes them as a `.vcpdfpresets` JSON file +(without `path` it returns `data`), and `pdf.preset.import {path? | data? | dataBase64?, replace?}` adds a file's +presets (names in use get a number unless `replace`). Saved presets live with the preferences and work as `preset` +in `document.exportPdf`, `document.pdfSettings`, `document.export`/`serialize` with format `pdf` and `.ai` saves. + +```json +{"name":"run_command","arguments":{"command":"pdf.preset.save","params":{"name":"Web","preset":"Smallest File Size","compatibility":"1.5"}}} +{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"preset":"Web","path":"/tmp/web.pdf"}}} +```