M4.79: print presets in the Print dialog and Edit > Print Presets

The Print dialog's Print Preset list loads [Default] or a saved preset
(reading [Custom] once the settings differ) and Save Preset... names the
settings as a preset. Edit > Print Presets... lists [Default] (generated,
protected) and the saved presets with how they differ from it; New... and
Edit... open the preset editor (the Print dialog's sections), Delete,
Import... and Export... use .vcprintpresets files, which File > Open also
imports. Commands print.presets.list/save/delete/import/export; the presets
live in Prefs.print_presets and persist with the preferences.

The open-dialog filter count test counts the new Print presets filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 16:27:24 +07:00
parent ffce1cf686
commit 7c748a5e3c
16 changed files with 961 additions and 33 deletions

View File

@ -613,13 +613,14 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
} }
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch /// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel), flattener presets and PDF presets (imported). /// libraries (which open in the library panel), flattener, PDF and print presets (imported).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> { pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS)) std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters()) .chain(format_filters())
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS))) .chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS))) .chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS))) .chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
} }
/// File → Place dialog filters: "All placeable files", then one per readable format, then text. /// File → Place dialog filters: "All placeable files", then one per readable format, then text.

View File

@ -146,7 +146,7 @@ fn open_exts_cover_every_readable_format() {
} }
let filters: Vec<_> = open_filters().collect(); let filters: Vec<_> = open_filters().collect();
assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(filters.len(), 4 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener presets and PDF presets"); assert_eq!(filters.len(), 5 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets");
} }
#[test] #[test]

View File

@ -47,6 +47,7 @@ pub mod pdfcmds;
mod place; mod place;
pub mod prefscmds; pub mod prefscmds;
pub mod print; pub mod print;
pub mod printpresets;
pub mod rasterfx; pub mod rasterfx;
mod recolor; mod recolor;
pub mod recovery; pub mod recovery;
@ -239,6 +240,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(webexport::specs()); v.extend(webexport::specs());
v.extend(assets::specs()); v.extend(assets::specs());
v.extend(css::specs()); v.extend(css::specs());
v.extend(printpresets::specs());
v v
}) })
} }

View File

@ -51,7 +51,11 @@ fn saved(doc: &Document) -> Value {
/// The settings of `doc` with `p.settings` over them, checked. /// The settings of `doc` with `p.settings` over them, checked.
fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> { fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
let mut v = saved(doc); settings_over(cmd, saved(doc), p)
}
/// The settings `v` (JSON) with `p.settings` over them, checked.
pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result<PrintSettings> {
match p.get("settings") { match p.get("settings") {
None | Some(Value::Null) => {} None | Some(Value::Null) => {}
Some(over @ Value::Object(_)) => merge(&mut v, over), Some(over @ Value::Object(_)) => merge(&mut v, over),

View File

@ -0,0 +1,246 @@
//! Edit → Print Presets: `print.presets.list`, `save`, `delete`, `export` and `import`. The
//! built-in [Default] (the default print settings) is generated in code and protected; the user's
//! presets live in [`crate::Prefs::print_presets`]. The Print dialog's preset list loads them.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_pdf::PrintSettings;
use super::fileio::pdf::changed;
use super::print::settings_over;
use super::*;
/// The built-in preset's name.
pub const DEFAULT_PRESET: &str = "[Default]";
/// The `format` of a print presets file, also its extension.
pub const PRESET_FORMAT: &str = "vcprintpresets";
/// The extensions of print presets files (opening one imports it).
pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
/// A named set of print settings: [Default] or a saved one ([`crate::Prefs::print_presets`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrintPreset {
pub name: String,
#[serde(default)]
pub settings: PrintSettings,
}
/// What `print.presets.export` writes and `print.presets.import` reads.
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
#[serde(default)]
presets: Vec<PrintPreset>,
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "print.presets.list",
"Print Presets",
[],
None,
"{} → {presets: [{name, builtIn, settings (as print.setup's), changed: [{option, value}] (how it differs from [Default])}]} [Default] (the default print settings, protected) first, then the saved presets",
always,
list
),
cmd!(
query "print.presets.save",
"Save Print Preset",
[],
None,
"{name?: (default: a new \"Print Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else [Default]), settings?: {…print.setup settings} (over it)} create or change a saved preset ([Default] is protected) → {name, created, settings}",
always,
save
),
cmd!(query "print.presets.delete", "Delete Print Preset", [], None, "{name} delete a saved preset ([Default] stays) → {deleted: name}", always, delete),
cmd!(
query "print.presets.export",
"Export Print Presets",
[],
None,
"{names?: [preset names, [Default] too] (default: every saved preset), path?} write the presets as a .vcprintpresets file (JSON) → {path, count}; without path → {data: the file's text, count}",
always,
export
),
cmd!(
query "print.presets.import",
"Import Print 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 .vcprintpresets file (as print.presets.export writes) to the saved ones → {imported: [names]}",
always,
import
),
]
}
/// Is `name` the built-in [Default] (also as "Default")?
pub fn is_default(name: &str) -> bool {
let n = name.trim();
n.eq_ignore_ascii_case(DEFAULT_PRESET) || n.eq_ignore_ascii_case("Default")
}
/// `[{option, value}]` for every setting of `set` that differs from `base` (`marks.trim`).
pub fn changes(set: &PrintSettings, base: &PrintSettings) -> Vec<Value> {
let (Ok(v), Ok(base)) = (serde_json::to_value(set), serde_json::to_value(base)) else { return vec![] };
let mut diff = vec![];
changed("", &v, &base, &mut diff);
diff
}
impl Session {
/// Every print preset: [Default], then the saved ones.
pub fn print_presets(&self) -> Vec<PrintPreset> {
let mut v = vec![PrintPreset { name: DEFAULT_PRESET.into(), settings: PrintSettings::default() }];
v.extend(self.prefs.print_presets.iter().cloned());
v
}
/// The settings of the print preset `name` ([Default] or a saved one, any case).
pub fn print_preset_settings(&self, name: &str) -> Option<PrintSettings> {
if is_default(name) {
return Some(PrintSettings::default());
}
self.print_saved_index(name).and_then(|i| self.prefs.print_presets.get(i)).map(|p| p.settings.clone())
}
/// The first free "Print Preset N": the name a new preset gets.
pub fn new_print_preset_name(&self) -> String {
(1..).map(|i| format!("Print Preset {i}")).find(|n| !self.print_preset_taken(n, None)).unwrap_or_default()
}
/// The index of the saved print preset `name` (any case).
fn print_saved_index(&self, name: &str) -> Option<usize> {
self.prefs.print_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
}
/// Whether `name` is [Default]'s or a saved preset's other than the one at `except`.
fn print_preset_taken(&self, name: &str, except: Option<usize>) -> bool {
is_default(name) || self.print_saved_index(name).is_some_and(|i| Some(i) != except)
}
}
fn row(p: PrintPreset, built_in: bool) -> Value {
let changed = changes(&p.settings, &PrintSettings::default());
json!({ "name": p.name, "builtIn": built_in, "settings": p.settings, "changed": changed })
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let rows: Vec<Value> = s.print_presets().into_iter().enumerate().map(|(i, p)| row(p, i == 0)).collect();
Ok(json!({ "presets": rows }))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "print.presets.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_print_preset_name(),
};
if is_default(&name) {
return Err(bad(C, format!("{DEFAULT_PRESET} is protected: save the settings under another name")));
}
let at = s.print_saved_index(&name);
let saved = at.and_then(|i| s.prefs.print_presets.get(i)).cloned();
// A saved preset changes from its own settings unless `preset` says where to start.
let base = match str_param(p, "preset") {
Some(b) => s.print_preset_settings(b).ok_or_else(|| bad(C, format!("no print preset named `{b}` (see print.presets.list)")))?,
None => saved.as_ref().map(|old| old.settings.clone()).unwrap_or_default(),
};
let base = serde_json::to_value(base).map_err(|e| EngineError::Other(e.to_string()))?;
let settings = settings_over(C, base, p)?;
let name = match str_param(p, "newName").map(str::trim) {
Some("") => return Err(bad(C, "`newName` is empty")),
Some(n) if s.print_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
Some(n) => n.to_string(),
None => saved.map_or(name, |old| old.name),
};
let preset = PrintPreset { name: name.clone(), settings: settings.clone() };
match at.and_then(|i| s.prefs.print_presets.get_mut(i)) {
Some(slot) => *slot = preset,
None => s.prefs.print_presets.push(preset),
}
Ok(json!({ "name": name, "created": at.is_none(), "settings": settings }))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "print.presets.delete";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
if is_default(name) {
return Err(bad(C, format!("{DEFAULT_PRESET} is protected and stays")));
}
let i = s.print_saved_index(name).ok_or_else(|| bad(C, format!("no saved print preset named `{name}`")))?;
Ok(json!({ "deleted": s.prefs.print_presets.remove(i).name }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "print.presets.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();
let settings = s.print_preset_settings(n).ok_or_else(|| bad(C, format!("no print preset named `{n}`")))?;
let name = s.print_saved_index(n).and_then(|i| s.prefs.print_presets.get(i)).map_or(DEFAULT_PRESET.to_string(), |q| q.name.clone());
Ok(PrintPreset { name, settings })
};
names.iter().map(find).collect::<Result<Vec<_>>>()?
}
None => s.prefs.print_presets.clone(),
};
if presets.is_empty() {
return Err(bad(C, "no saved presets to export (name [Default] in `names` to export it)"));
}
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<Value> {
const C: &str = "print.presets.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 print presets file: {e}")))?;
if file.format != PRESET_FORMAT {
return Err(bad(C, format!("not a print presets file (format `{}`)", file.format)));
}
// Values a hand-edited file may carry are refused as the print commands refuse them.
for q in &file.presets {
q.settings.check().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 {
// [Default] comes in as a saved copy ("Default 2"…): the built-in one stays.
let name = preset.name.trim().trim_start_matches('[').trim_end_matches(']').trim();
let base = Some(name).filter(|n| !n.is_empty()).unwrap_or("Print Preset").to_string();
match s.print_saved_index(&base).filter(|_| replace) {
Some(i) => {
if let Some(slot) = s.prefs.print_presets.get_mut(i) {
preset.name = slot.name.clone();
imported.push(preset.name.clone());
*slot = preset;
}
}
None => {
preset.name = unique_name(&base, |n| s.print_preset_taken(n, None));
imported.push(preset.name.clone());
s.prefs.print_presets.push(preset);
}
}
}
Ok(json!({ "imported": imported }))
}

View File

@ -465,6 +465,10 @@ pub struct Prefs {
/// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set. /// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub web_export_settings: Option<cmd::webexport::WebSettings>, pub web_export_settings: Option<cmd::webexport::WebSettings>,
/// Edit → Print Presets: the user's presets ([Default] isn't stored). A local library, not a
/// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub print_presets: Vec<cmd::printpresets::PrintPreset>,
} }
impl Default for Prefs { impl Default for Prefs {
@ -596,6 +600,7 @@ impl Default for Prefs {
use_compression: false, use_compression: false,
web_export_presets: vec![], web_export_presets: vec![],
web_export_settings: None, web_export_settings: None,
print_presets: vec![],
} }
} }
} }
@ -1210,6 +1215,8 @@ mod tests_previewbounds;
#[cfg(test)] #[cfg(test)]
mod tests_print; mod tests_print;
#[cfg(test)] #[cfg(test)]
mod tests_printpresets;
#[cfg(test)]
mod tests_printpreview; mod tests_printpreview;
#[cfg(test)] #[cfg(test)]
mod tests_proxyitems; mod tests_proxyitems;

View File

@ -0,0 +1,108 @@
//! Edit → Print Presets: [Default] is protected, saved presets round-trip through files and
//! persist with the preferences.
use serde_json::json;
use super::*;
use crate::cmd::printpresets::DEFAULT_PRESET;
fn is_bad_params(r: Result<serde_json::Value>) -> bool {
matches!(r, Err(EngineError::BadParams { .. }))
}
#[test]
fn default_is_listed_first_and_protected() {
let mut s = Session::new();
let v = s.execute("print.presets.list", &json!({})).unwrap();
assert_eq!((v["presets"][0]["name"].clone(), v["presets"][0]["builtIn"].clone()), (json!(DEFAULT_PRESET), json!(true)));
assert_eq!(v["presets"][0]["settings"]["copies"], 1);
assert_eq!(v["presets"][0]["changed"], json!([]));
for name in [DEFAULT_PRESET, "default", " [default] "] {
assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": name, "settings": {"copies": 2}}))), "{name}");
assert!(is_bad_params(s.execute("print.presets.delete", &json!({"name": name}))), "{name}");
}
// Nor can a saved preset take its name.
s.execute("print.presets.save", &json!({"name": "Proofs"})).unwrap();
assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": "Proofs", "newName": "Default"}))));
assert_eq!(s.prefs.print_presets.len(), 1);
assert!(is_bad_params(s.execute("print.presets.delete", &json!({"name": "Nope"}))));
}
#[test]
fn presets_save_change_rename_and_delete() {
let mut s = Session::new();
let v = s.execute("print.presets.save", &json!({"settings": {"media": "a4", "marks": {"trim": true}}})).unwrap();
assert_eq!((v["name"].clone(), v["created"].clone()), (json!("Print Preset 1"), json!(true)));
// A saved preset changes from its own settings; `preset` starts from another one.
let v = s.execute("print.presets.save", &json!({"name": "print preset 1", "settings": {"copies": 3}})).unwrap();
assert_eq!((v["created"].clone(), v["settings"]["media"].clone(), v["settings"]["copies"].clone()), (json!(false), json!("a4"), json!(3)));
let v = s.execute("print.presets.save", &json!({"name": "Plain", "preset": "Print Preset 1", "settings": {"marks": {"trim": false}}})).unwrap();
assert_eq!((v["settings"]["copies"].clone(), v["settings"]["marks"]["trim"].clone()), (json!(3), json!(false)));
let v = s.execute("print.presets.list", &json!({})).unwrap();
let changed: Vec<&str> = v["presets"][1]["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect();
assert_eq!(changed, ["copies", "marks.trim", "media"]);
// Renames keep the settings; bad settings and unknown bases are refused.
s.execute("print.presets.save", &json!({"name": "Plain", "newName": "Draft"})).unwrap();
assert_eq!(s.prefs.print_presets[1].name, "Draft");
assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": "Draft", "newName": "print preset 1"}))));
assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": "X", "settings": {"copies": 0}}))));
assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": "X", "preset": "Nope"}))));
assert!(is_bad_params(s.execute("print.presets.save", &json!({"name": " "}))));
assert_eq!(s.execute("print.presets.delete", &json!({"name": "draft"})).unwrap()["deleted"], "Draft");
assert_eq!(s.prefs.print_presets.len(), 1);
}
#[test]
fn presets_export_and_import_round_trip() {
let mut s = Session::new();
s.execute("print.presets.save", &json!({"name": "Tiles", "settings": {"scaling": "tileFull", "overlap": 18}})).unwrap();
let saved = s.prefs.print_presets.clone();
let data = s.execute("print.presets.export", &json!({"names": ["Tiles", DEFAULT_PRESET]})).unwrap();
assert_eq!(data["count"], 2);
let text = data["data"].as_str().unwrap().to_string();
s.execute("print.presets.delete", &json!({"name": "Tiles"})).unwrap();
let r = s.execute("print.presets.import", &json!({"data": text})).unwrap();
assert_eq!(r["imported"], json!(["Tiles", "Default 2"]), "[Default] comes in as a saved copy");
assert_eq!(s.prefs.print_presets[0], saved[0], "the same preset comes back");
assert_eq!(s.prefs.print_presets[1].settings, vectorcraft_pdf::PrintSettings::default());
// replace: saved presets of the same names are overwritten.
let r = s.execute("print.presets.import", &json!({"data": text, "replace": true})).unwrap();
assert_eq!(r["imported"], json!(["Tiles", "Default 3"]));
assert_eq!(s.prefs.print_presets.len(), 3);
// Through a file, and every saved preset by default.
let path = std::env::temp_dir().join(format!("vc-printpresets-{}.vcprintpresets", std::process::id())).to_string_lossy().to_string();
assert_eq!(s.execute("print.presets.export", &json!({"path": path})).unwrap()["count"], 3);
let mut t = Session::new();
assert_eq!(t.execute("print.presets.import", &json!({"path": path})).unwrap()["imported"].as_array().unwrap().len(), 3);
assert_eq!(t.prefs.print_presets, s.prefs.print_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\": \"vcpdfpresets\", \"presets\": []}"}),
json!({"data": "{\"format\": \"vcprintpresets\", \"presets\": [{\"name\": \"X\", \"settings\": {\"copies\": 5000}}]}"}),
json!({"data": "{\"format\": \"vcprintpresets\", \"presets\": [{\"name\": \"X\", \"settings\": {\"media\": \"quarto\"}}]}"}),
json!({"dataBase64": "!!"}),
json!({}),
] {
assert!(is_bad_params(t.execute("print.presets.import", &bad)), "{bad}");
}
assert_eq!(t.prefs.print_presets.len(), 3);
assert!(is_bad_params(Session::new().execute("print.presets.export", &json!({}))), "nothing saved to export");
assert!(is_bad_params(Session::new().execute("print.presets.export", &json!({"names": ["Nope"]}))));
}
#[test]
fn saved_presets_persist_with_the_preferences() {
let mut s = Session::new();
s.execute("print.presets.save", &json!({"name": "Kept", "settings": {"copies": 2}})).unwrap();
let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
assert_eq!(back.print_presets, s.prefs.print_presets);
// Resetting the preferences keeps them (a library, not a preference).
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.print_presets.len(), 1);
// Preferences saved before print presets existed still load, and none are written while empty.
let old: crate::Prefs = serde_json::from_value(json!({"keyboardIncrement": 2.0})).unwrap();
assert!(old.print_presets.is_empty());
assert!(crate::Prefs::default().to_json().get("printPresets").is_none());
}

View File

@ -1105,3 +1105,40 @@ proptest! {
paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?; paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?;
} }
} }
// ---------- print presets files ----------
/// Import print presets from `data`, then lay out (and print, when short) the rich document with
/// each one imported.
fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "print.presets.import", data, |s, r| {
let list = s.execute("print.presets.list", &json!({})).unwrap_or_default();
for name in r["imported"].as_array().into_iter().flatten() {
let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue };
let settings = json!({ "settings": p["settings"] });
let pages = s.execute("print.preview", &settings).ok().and_then(|v| v["pages"].as_u64());
let _ = s.execute("print.setup", &settings);
if pages.is_some_and(|n| n <= 8) {
let _ = s.execute("file.print", &settings);
}
}
})
}
proptest! {
#![proptest_config(config())]
#[test]
fn print_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcprintpresets\", ", "{\"format\": \"vcprintpresets\", \"presets\": [{\"name\": \"x\", \"settings\": "])) {
print_presets("print presets garbage", &format!("{head}{s}"))?;
}
#[test]
fn mutated_print_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) {
let mut s = rich_session();
s.execute("print.presets.save", &json!({"name": "Tiles", "settings": {"scaling": "tileFull", "scale": {"width": 300, "height": 300}, "marks": {"trim": true}}})).unwrap();
s.execute("print.presets.save", &json!({"name": "Seps", "settings": {"output": {"mode": "separations", "inks": [{"name": "Cyan", "print": false}]}}})).unwrap();
let text = s.execute("print.presets.export", &json!({"names": ["Tiles", "Seps", "[Default]"]})).unwrap()["data"].as_str().unwrap().to_string();
print_presets("mutated print presets", &mutate_text(&text, cut, &edits))?;
}
}

View File

@ -42,6 +42,7 @@ pub mod place;
pub mod placement_options; pub mod placement_options;
mod png_options; mod png_options;
pub mod print; pub mod print;
pub mod print_presets;
mod psd_options; mod psd_options;
pub mod raster_effects; pub mod raster_effects;
pub mod recolor; pub mod recolor;
@ -238,6 +239,8 @@ registry! {
SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC, SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC,
PsdOptions: ["psdOptions"] => png_options::SPEC, PsdOptions: ["psdOptions"] => png_options::SPEC,
Print: [print::KIND] => print::SPEC, Print: [print::KIND] => print::SPEC,
PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC,
PrintPresets: [print_presets::KIND] => print_presets::SPEC,
} }
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@ -372,3 +375,6 @@ mod tests_psd;
#[cfg(test)] #[cfg(test)]
mod tests_print; mod tests_print;
#[cfg(test)]
mod tests_print_presets;

View File

@ -1,4 +1,5 @@
//! File → Print: the Print dialog. The printer (and Setup…) on top, then the sections General, //! File → Print: the Print dialog. The print preset (and Save Preset…) and the printer (and
//! Setup…) on top, then the sections General,
//! Marks and Bleed, Output, Graphics, Color Management, Advanced and Summary, with a preview of the //! Marks and Bleed, Output, Graphics, Color Management, Advanced and Summary, with a preview of the
//! printed page under the section list: the arrows step through the pages, dragging the art moves //! printed page under the section list: the arrows step through the pages, dragging the art moves
//! its placement on the paper. //! its placement on the paper.
@ -9,6 +10,11 @@
//! keeps the settings with the document (`print.setup`) and prints them (`file.print`); Done //! keeps the settings with the document (`print.setup`) and prints them (`file.print`); Done
//! (`discard: true`, then confirm) only keeps them. The preview, the inks and the Summary come //! (`discard: true`, then confirm) only keeps them. The preview, the inks and the Summary come
//! from `print.preview`: the dialog lays nothing out itself. //! from `print.preview`: the dialog lays nothing out itself.
//!
//! `preset` names the print preset whose settings were loaded last: setting it (the list, or
//! `ui.dialog.set`) loads that preset's settings. Save Preset… names the settings as a preset
//! (`print.presets.save`). The same dialog edits a preset (kind `printPreset`, from Edit → Print
//! Presets): a `name` instead of the preset and printer, and OK saves the preset.
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
@ -24,12 +30,31 @@ use super::{DialogSpec, form};
use crate::state::Dialog; use crate::state::Dialog;
use crate::theme::{self, Tokens}; use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets}; use crate::{VectorcraftApp, widgets};
use vectorcraft_engine::cmd::printpresets::DEFAULT_PRESET;
pub(super) const KIND: &str = "print"; pub(super) const KIND: &str = "print";
pub(super) const SPEC: DialogSpec = pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Print".into(), body, confirm, ok: Some("Print"), discard: Some("Done"), ..DialogSpec::FORM }; DialogSpec { heading: |_| "Print".into(), body, confirm, ok: Some("Print"), discard: Some("Done"), ..DialogSpec::FORM };
/// The dialog kind of the preset editor (New / Edit in Edit → Print Presets).
pub(super) const PRESET_KIND: &str = "printPreset";
pub(super) const PRESET_SPEC: DialogSpec = DialogSpec {
heading: |d| if d.str(EDITING).is_empty() { "New Print Preset" } else { "Edit Print 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";
/// The field holding the name typed for Save Preset… (present while that row shows).
const SAVE_AS: &str = "__savePresetAs";
/// The preset whose settings were loaded last (`preset` loads its settings when it differs).
const LOADED: &str = "__loaded";
pub(super) const SECTIONS: [&str; 7] = ["General", "Marks and Bleed", "Output", "Graphics", "Color Management", "Advanced", "Summary"]; pub(super) const SECTIONS: [&str; 7] = ["General", "Marks and Bleed", "Output", "Graphics", "Color Management", "Advanced", "Summary"];
/// The default print settings as JSON: what a field an agent left out reads as. /// The default print settings as JSON: what a field an agent left out reads as.
@ -37,11 +62,11 @@ static DEFAULTS: LazyLock<Value> = LazyLock::new(|| serde_json::to_value(PrintSe
/// The defaults of the settings a dialog of `kind` edits, when it is a Print dialog. /// The defaults of the settings a dialog of `kind` edits, when it is a Print dialog.
pub(super) fn defaults(kind: &str) -> Option<&'static Value> { pub(super) fn defaults(kind: &str) -> Option<&'static Value> {
(kind == KIND).then(|| &*DEFAULTS) (kind == KIND || kind == PRESET_KIND).then(|| &*DEFAULTS)
} }
/// The fields that aren't print settings. /// The fields that aren't print settings.
const NOT_SETTINGS: [&str; 3] = ["printer", "toFile", "discard"]; const NOT_SETTINGS: [&str; 5] = ["printer", "toFile", "discard", "preset", "name"];
/// The Printer list's entries besides the system's printers. /// The Printer list's entries besides the system's printers.
const DEFAULT_PRINTER: &str = "Default Printer"; const DEFAULT_PRINTER: &str = "Default Printer";
@ -55,9 +80,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?; let st = app.session.active().ok_or("no document")?;
// The settings saved with the document, every field filled in (defaults if never set up). // The settings saved with the document, every field filled in (defaults if never set up).
let saved: PrintSettings = st.doc.print_setup.clone().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); let saved: PrintSettings = st.doc.print_setup.clone().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default();
let Value::Object(mut fields) = serde_json::to_value(saved).map_err(|e| e.to_string())? else { let mut fields = fields_of(&saved)?;
return Err("print settings are not an object".into());
};
let printers = crate::print::printers(app); let printers = crate::print::printers(app);
let list = printers["printers"].as_array().map(Vec::as_slice).unwrap_or_default(); let list = printers["printers"].as_array().map(Vec::as_slice).unwrap_or_default();
let default = list.iter().find(|p| p["default"] == true).or(list.first()).and_then(|p| p["name"].as_str()).unwrap_or_default(); let default = list.iter().find(|p| p["default"] == true).or(list.first()).and_then(|p| p["name"].as_str()).unwrap_or_default();
@ -66,10 +89,82 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
fields.insert("__printers".into(), printers); fields.insert("__printers".into(), printers);
fields.insert("__section".into(), json!(SECTIONS[0])); fields.insert("__section".into(), json!(SECTIONS[0]));
fields.insert("__sheet".into(), json!(0)); fields.insert("__sheet".into(), json!(0));
// The saved preset these settings are, if any (else [Default], shown as [Custom] if they differ).
let preset = app.session.prefs.print_presets.iter().find(|p| p.settings == saved).map_or(DEFAULT_PRESET, |p| p.name.as_str());
fields.insert("preset".into(), json!(preset));
fields.insert(LOADED.into(), json!(preset));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(Value::Null) Ok(Value::Null)
} }
/// The settings as dialog fields.
fn fields_of(set: &PrintSettings) -> Result<serde_json::Map<String, Value>, String> {
match serde_json::to_value(set).map_err(|e| e.to_string())? {
Value::Object(fields) => Ok(fields),
_ => Err("print settings are not an object".into()),
}
}
/// Load the settings of the preset `preset` names once it changed (the list, or `ui.dialog.set`).
fn sync_preset(app: &VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
let name = d.str("preset");
if d.kind != KIND || name == d.str(LOADED) {
return Ok(());
}
d.fields.insert(LOADED.into(), json!(name));
let set = app.session.print_preset_settings(&name).ok_or_else(|| format!("no print preset named `{name}`"))?;
d.fields.extend(fields_of(&set)?);
Ok(())
}
/// Save Preset…: save the settings under the typed name and pick that preset.
fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<Value, String> {
let r = app.run("print.presets.save", json!({ "name": d.str(SAVE_AS), "preset": DEFAULT_PRESET, "settings": settings(d) }))?;
d.fields.remove(SAVE_AS);
d.fields.insert("preset".into(), r["name"].clone());
d.fields.insert(LOADED.into(), r["name"].clone());
Ok(r)
}
/// Open the preset editor on the saved preset `name`, or on a new preset starting from `preset`
/// (default: [Default]).
pub fn open_preset(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
let s = |k: &str| params.get(k).and_then(Value::as_str);
let (base, name, editing) = match s("name") {
Some(name) => {
let p = app.session.prefs.print_presets.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim()));
let p = p.ok_or_else(|| format!("no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it)"))?;
(p.name.clone(), p.name.clone(), p.name.clone())
}
None => (s("preset").unwrap_or(DEFAULT_PRESET).to_string(), app.session.new_print_preset_name(), String::new()),
};
let set = app.session.print_preset_settings(&base).ok_or_else(|| format!("no print preset named `{base}`"))?;
let mut fields = fields_of(&set)?;
fields.insert("name".into(), json!(name));
fields.insert(EDITING.into(), json!(editing));
fields.insert("__section".into(), json!(SECTIONS[0]));
fields.insert("__sheet".into(), json!(0));
app.ui.dialog = Some(Dialog { kind: PRESET_KIND.into(), fields });
Ok(Value::Null)
}
/// Save Preset (the preset editor's OK): `print.presets.save`, then back to Edit → Print Presets on
/// it. A new preset may not take the name of another one.
fn confirm_preset(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (name, editing) = (d.str("name"), d.str(EDITING));
let p = if editing.is_empty() {
if app.session.print_preset_settings(&name).is_some() {
return Err(format!("a preset named `{}` exists", name.trim()));
}
json!({ "name": name, "preset": DEFAULT_PRESET, "settings": settings(d) })
} else {
json!({ "name": editing, "newName": name, "settings": settings(d) })
};
let r = app.run("print.presets.save", p)?;
super::print_presets::open(app, r["name"].as_str());
Ok(r)
}
/// The print settings the dialog stands for (`print.setup`'s `settings`). /// The print settings the dialog stands for (`print.setup`'s `settings`).
pub(super) fn settings(d: &Dialog) -> Value { pub(super) fn settings(d: &Dialog) -> Value {
let mut p = form::params(d); let mut p = form::params(d);
@ -82,18 +177,27 @@ pub(super) fn settings(d: &Dialog) -> Value {
} }
/// Print, or Done (`discard`): keep the settings with the document (one undo step when they /// Print, or Done (`discard`): keep the settings with the document (one undo step when they
/// changed), then print them; settings that can't print keep the dialog open, unchanged. /// changed), then print them; settings that can't print keep the dialog open, unchanged. While
/// Save Preset… asks for a name, it saves the preset instead.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut d = d.clone();
// Done's flag holds for this press only: the dialog may stay open. // Done's flag holds for this press only: the dialog may stay open.
if let Some(open) = app.ui.dialog.as_mut() { let done = d.fields.remove("discard").is_some_and(|v| v == true);
open.fields.remove("discard"); let synced = sync_preset(app, &mut d);
app.ui.dialog = Some(d.clone());
synced?;
// While Save Preset… asks for a name, OK (and Enter) saves the preset instead.
if d.fields.contains_key(SAVE_AS) {
let r = save_preset(app, &mut d);
app.ui.dialog = Some(d);
return r;
} }
let settings = settings(d); let settings = settings(&d);
let p = json!({ "settings": settings }); let p = json!({ "settings": settings });
// Laid out first, so a bad range or overlap is refused before anything is kept. // Laid out first, so a bad range or overlap is refused before anything is kept.
app.session.execute("print.preview", &p).map_err(|e| e.to_string())?; app.session.execute("print.preview", &p).map_err(|e| e.to_string())?;
app.run("print.setup", p)?; app.run("print.setup", p)?;
if d.bool("discard") { if done {
app.ui.dialog = None; app.ui.dialog = None;
return Ok(json!({ "settings": settings })); return Ok(json!({ "settings": settings }));
} }
@ -106,7 +210,17 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
printer_row(app, ui, d); if let Err(e) = sync_preset(app, d) {
app.status(e);
}
if d.kind == PRESET_KIND {
row_with(ui, "Preset name:", TOP_LABEL_WIDTH, |ui| {
form::text_edit(ui, d, "name", 288.0);
});
} else {
preset_rows(app, ui, d);
printer_row(app, ui, d);
}
ui.add_space(10.0); ui.add_space(10.0);
let section = current_section(d, &SECTIONS); let section = current_section(d, &SECTIONS);
let preview = preview_of(app, ui.ctx(), &settings(d)); let preview = preview_of(app, ui.ctx(), &settings(d));
@ -126,7 +240,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
"Graphics" => graphics(ui, d), "Graphics" => graphics(ui, d),
"Color Management" => color(ui, d), "Color Management" => color(ui, d),
"Advanced" => advanced(app, ui, d), "Advanced" => advanced(app, ui, d),
"Summary" => summary(ui, d, &preview), "Summary" => summary(ui, d, &preview, app.session.active().is_some()),
_ => general(app, ui, d), _ => general(app, ui, d),
}); });
}); });
@ -134,6 +248,49 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false false
} }
/// The Print Preset row ([Default], the saved presets; [Custom] once the settings differ from the
/// preset's) with 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) {
const CUSTOM: &str = "[Custom]";
let name = d.str("preset");
let current = serde_json::from_value::<PrintSettings>(settings(d)).ok();
let same = current.is_some() && current == app.session.print_preset_settings(&name);
let names: Vec<&str> = std::iter::once(DEFAULT_PRESET).chain(app.session.prefs.print_presets.iter().map(|p| p.name.as_str())).collect();
let mut picked = None;
let asking = d.fields.contains_key(SAVE_AS);
let mut ask = false;
row_with(ui, "Print Preset:", TOP_LABEL_WIDTH, |ui| {
picked =
widgets::dropdown(ui, "print-preset", if same { &name } else { CUSTOM }, &names, 300.0).and_then(|i| names.get(i)).map(|n| n.to_string());
let save = ui.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, "Save Preset…", 96.0)).inner;
ask = save.on_hover_text("Save these settings as a print preset").clicked();
});
if let Some(n) = picked {
// Picking the preset shown again brings its settings back.
d.fields.insert(LOADED.into(), json!(""));
d.fields.insert("preset".into(), json!(n));
if let Err(e) = sync_preset(app, d) {
app.status(e);
}
}
if ask {
d.fields.insert(SAVE_AS.into(), json!(app.session.new_print_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);
}
});
}
}
/// What the Printer list offers. /// What the Printer list offers.
enum Destination { enum Destination {
Printer(String), Printer(String),
@ -233,7 +390,11 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv:
ui.painter().rect_filled(rect, 3.0, t.pasteboard); ui.painter().rect_filled(rect, 3.0, t.pasteboard);
let sheet = pv.as_ref().ok().and_then(|v| current_sheet(d, v)); let sheet = pv.as_ref().ok().and_then(|v| current_sheet(d, v));
let Some((index, count, sheet)) = sheet else { let Some((index, count, sheet)) = sheet else {
let text = pv.as_ref().err().map_or("Nothing to print", |_| "Can't print these settings: see the Summary"); let text = match pv {
_ if app.session.active().is_none() => "Open a document to preview its pages",
Ok(_) => "Nothing to print",
Err(_) => "Can't print these settings: see the Summary",
};
let font = egui::FontId::proportional(11.0); let font = egui::FontId::proportional(11.0);
let galley = ui.painter().layout(text.into(), font, t.text_dim, rect.width() - 16.0); let galley = ui.painter().layout(text.into(), font, t.text_dim, rect.width() - 16.0);
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text_dim); ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text_dim);
@ -568,7 +729,7 @@ fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
/// The section an option is set in, and whether its first key is the section's own object /// The section an option is set in, and whether its first key is the section's own object
/// (`output.mode` → Output › Mode). /// (`output.mode` → Output › Mode).
fn section_of(option: &str) -> (usize, bool) { pub(super) fn section_of(option: &str) -> (usize, bool) {
match option.split('.').next().unwrap_or_default() { match option.split('.').next().unwrap_or_default() {
"marks" | "bleed" => (1, false), "marks" | "bleed" => (1, false),
"output" => (2, true), "output" => (2, true),
@ -580,7 +741,7 @@ fn section_of(option: &str) -> (usize, bool) {
} }
/// `output.mode` → "Output › Mode", `placement.x` → "General › Placement › X". /// `output.mode` → "Output › Mode", `placement.x` → "General › Placement › X".
fn option_label(option: &str) -> String { pub(super) fn option_label(option: &str) -> String {
let (section, own) = section_of(option); let (section, own) = section_of(option);
let keys = option.split('.').skip(usize::from(own)).map(|k| form::humanize(k).trim_end_matches(':').to_string()); let keys = option.split('.').skip(usize::from(own)).map(|k| form::humanize(k).trim_end_matches(':').to_string());
std::iter::once(SECTIONS.get(section).copied().unwrap_or_default().to_string()).chain(keys).collect::<Vec<_>>().join(" › ") std::iter::once(SECTIONS.get(section).copied().unwrap_or_default().to_string()).chain(keys).collect::<Vec<_>>().join(" › ")
@ -615,19 +776,21 @@ pub(super) fn labelled(changed: &mut [Value]) {
} }
} }
fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>) { fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>, has_doc: bool) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let v = match pv { match pv {
Ok(v) => v, Ok(v) => {
heading(ui, "Pages");
let per_copy = v["sheets"].as_array().map_or(0, Vec::len);
ui.label(egui::RichText::new(format!("{} in all, {per_copy} per copy", v["pages"])).color(t.text));
}
// A preset edited without a document has no pages to count.
Err(_) if !has_doc => {}
Err(e) => { Err(e) => {
heading(ui, "Error"); heading(ui, "Error");
ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text)); ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text));
return;
} }
}; }
heading(ui, "Pages");
let per_copy = v["sheets"].as_array().map_or(0, Vec::len);
ui.label(egui::RichText::new(format!("{} in all, {per_copy} per copy", v["pages"])).color(t.text));
heading(ui, "Options"); heading(ui, "Options");
let mut changed = vec![]; let mut changed = vec![];
vectorcraft_engine::cmd::fileio::pdf::changed("", &settings(d), &DEFAULTS, &mut changed); vectorcraft_engine::cmd::fileio::pdf::changed("", &settings(d), &DEFAULTS, &mut changed);
@ -637,6 +800,8 @@ fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>) {
note(ui, "Every option is at its default."); note(ui, "Every option is at its default.");
} }
super::save_pdf::option_rows(ui, &changed, option_label); super::save_pdf::option_rows(ui, &changed, option_label);
heading(ui, "Warnings"); if let Ok(v) = pv {
super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default()); heading(ui, "Warnings");
super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default());
}
} }

View File

@ -0,0 +1,163 @@
//! Edit → Print Presets: every preset in a list ([Default], protected, then the saved ones) with
//! how the selected one differs from [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 Print dialog, kind `printPreset`), whose OK
//! comes back here.
//!
//! Field: `selected` (a preset's name).
use serde_json::{Value, json};
use vectorcraft_engine::cmd::printpresets::{DEFAULT_PRESET, PRESET_EXTS, PRESET_FORMAT, changes};
use super::DialogSpec;
use super::print::{open_preset, option_label, section_of};
use super::save_pdf::option_rows;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Print Presets.
pub const KIND: &str = "printPresets";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Print Presets".into(),
body,
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
},
ok: None,
min_width: 620.0,
..DialogSpec::FORM
};
/// Open the presets manager on `selected` (default: [Default]).
pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) {
app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(DEFAULT_PRESET) })));
}
/// What a button asked for, done once the list and the details are drawn.
pub(super) 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 saved = &app.session.prefs.print_presets;
let selected = d.str("selected");
// 0 is [Default]; saved presets follow.
let i = saved.iter().position(|p| p.name.eq_ignore_ascii_case(selected.trim())).map_or(0, |i| i + 1);
let name = i.checked_sub(1).and_then(|k| saved.get(k)).map_or(DEFAULT_PRESET, |p| p.name.as_str()).to_string();
let builtin = i == 0;
let mut act = None;
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(280.0);
widgets::dim_label(ui, "Presets:");
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("print-presets").min_scrolled_height(240.0).max_height(240.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(240.0);
for (k, n) in std::iter::once(DEFAULT_PRESET).chain(saved.iter().map(|p| p.name.as_str())).enumerate() {
if ui.selectable_label(k == i, n).clicked() {
act = Some(Action::Select(n.to_string()));
}
}
});
});
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…", 60.0).clicked() {
act = Some(Action::Import);
}
if widgets::flat_button(ui, "Export…", 60.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(300.0);
ui.label(egui::RichText::new(&name).color(t.text_strong).strong());
ui.add_space(8.0);
widgets::dim_label(ui, &format!("Differences from {DEFAULT_PRESET}:"));
let settings = i.checked_sub(1).and_then(|k| saved.get(k)).map(|p| &p.settings);
let mut changed = settings.map(|s| changes(s, &Default::default())).unwrap_or_default();
changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()).0);
egui::ScrollArea::vertical().id_salt("print-preset-details").max_height(200.0).show(ui, |ui| {
if changed.is_empty() {
ui.label(egui::RichText::new("None.").color(t.text_dim).size(11.5));
}
option_rows(ui, &changed, option_label);
});
if builtin {
ui.add_space(6.0);
ui.label(egui::RichText::new("[Default] is protected: New… starts an editable copy.").color(t.text_dim).size(11.5));
}
});
});
if let Some(a) = act
&& let Err(e) = run(app, d, a, &name)
{
app.status(e);
}
false
}
fn select(d: &mut Dialog, name: &str) {
d.fields.insert("selected".into(), json!(name));
}
pub(super) 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.print_presets.iter().position(|p| p.name == current);
app.run("print.presets.delete", json!({ "name": current }))?;
// The one above it takes its place ([Default] above the first saved one).
let next = i.and_then(|i| i.checked_sub(1)).and_then(|i| app.session.prefs.print_presets.get(i)).map(|p| p.name.clone());
select(d, next.as_deref().unwrap_or(DEFAULT_PRESET));
}
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 pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() };
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
let r = app.run("print.presets.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, "print.presets.export", PRESET_FORMAT, json!({ "names": [current] }))?;
}
}
Ok(())
}

View File

@ -0,0 +1,145 @@
//! Print presets in the UI: the Print dialog's preset list and Save Preset…, and Edit → Print
//! Presets (New…, Edit…, Delete, Import…, Export…) with its preset editor.
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::printpresets::DEFAULT_PRESET;
use super::print::{KIND, PRESET_KIND, SECTIONS};
use super::print_presets::{self, Action};
use crate::{VectorcraftApp, theme};
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::show(app, ui.ctx()));
out.textures_delta.clear();
}
/// 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();
print_presets::run(app, &mut d, act, current).unwrap();
app.ui.dialog = Some(d);
frame(app);
}
fn kind(app: &VectorcraftApp) -> Option<&str> {
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
}
fn field(app: &VectorcraftApp, k: &str) -> Value {
app.ui.dialog.as_ref().and_then(|d| d.fields.get(k).cloned()).unwrap_or_default()
}
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(k.into(), v);
}
#[test]
fn the_print_dialog_saves_and_loads_presets() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("file.print", json!({})).unwrap();
assert_eq!(field(&app, "preset"), DEFAULT_PRESET, "never set up: [Default]");
// Save Preset… asks for a name; OK (and Enter) then saves the preset, not printing.
set(&mut app, "copies", json!(4));
set(&mut app, "media", json!("a4"));
set(&mut app, "__savePresetAs", json!("Proofs"));
frame(&mut app);
let r = super::confirm(&mut app).unwrap();
assert_eq!(r["name"], "Proofs");
assert_eq!(kind(&app), Some(KIND), "the Print dialog stays open");
assert_eq!(field(&app, "preset"), "Proofs");
assert!(field(&app, "__savePresetAs").is_null());
let saved = &app.session.prefs.print_presets[0].settings;
assert_eq!((saved.copies, saved.media), (4, vectorcraft_pdf::Media::A4));
// Setting `preset` (the list, or an agent) loads that preset's settings.
set(&mut app, "preset", json!(DEFAULT_PRESET));
frame(&mut app);
assert_eq!((field(&app, "copies"), field(&app, "media")), (json!(1), json!("letter")));
set(&mut app, "preset", json!("proofs"));
set(&mut app, "discard", json!(true));
super::confirm(&mut app).unwrap();
let setup = app.session.active().unwrap().doc.print_setup.clone().unwrap();
assert_eq!((setup["copies"].clone(), setup["media"].clone()), (json!(4), json!("a4")), "Done kept the preset's settings");
// The next Print knows the preset its settings are.
app.run("file.print", json!({})).unwrap();
assert_eq!(field(&app, "preset"), "Proofs");
frame(&mut app);
// An unknown preset is refused; [Default]'s name can't be taken.
set(&mut app, "preset", json!("Nope"));
set(&mut app, "discard", json!(true));
assert!(super::confirm(&mut app).is_err());
assert!(field(&app, "discard").is_null(), "Done's press doesn't stick to the dialog");
set(&mut app, "__savePresetAs", json!("[Default]"));
assert!(super::confirm(&mut app).is_err());
assert_eq!(app.session.prefs.print_presets.len(), 1);
}
#[test]
fn the_manager_adds_edits_and_deletes_presets_through_the_editor() {
// Edit → Print Presets works without a document too.
let mut app = VectorcraftApp::new(Session::new(), Default::default());
assert!(crate::menus::menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("ui.printPresetsDialog") && e.enabled));
app.run("ui.printPresetsDialog", json!({})).unwrap();
frame(&mut app);
assert_eq!((kind(&app), field(&app, "selected")), (Some(print_presets::KIND), json!(DEFAULT_PRESET)));
// [Default] can't be edited: New… starts a copy.
assert!(app.run("ui.printPresetDialog", json!({"name": DEFAULT_PRESET})).is_err());
app.run("ui.printPresetDialog", json!({"preset": DEFAULT_PRESET})).unwrap();
assert_eq!((kind(&app), field(&app, "name")), (Some(PRESET_KIND), json!("Print Preset 1")));
for s in SECTIONS {
set(&mut app, "__section", json!(s));
frame(&mut app);
assert_eq!(kind(&app), Some(PRESET_KIND), "{s}");
}
set(&mut app, "name", json!("Tiled"));
set(&mut app, "scaling", json!("tileFull"));
super::confirm(&mut app).unwrap();
assert_eq!((kind(&app), field(&app, "selected")), (Some(print_presets::KIND), json!("Tiled")), "back to the manager");
frame(&mut app);
// A new preset can't take a name in use; Edit… renames.
app.run("ui.printPresetDialog", json!({"preset": "Tiled"})).unwrap();
assert_eq!(field(&app, "scaling"), "tileFull", "New… starts from the selected preset");
set(&mut app, "name", json!("tiled"));
assert!(super::confirm(&mut app).is_err());
assert_eq!(kind(&app), Some(PRESET_KIND), "stays open on a taken name");
app.run("ui.printPresetDialog", json!({"name": "Tiled"})).unwrap();
set(&mut app, "name", json!("Posters"));
set(&mut app, "overlap", json!(9));
super::confirm(&mut app).unwrap();
let p = &app.session.prefs.print_presets;
assert_eq!((p.len(), p[0].name.as_str(), p[0].settings.overlap), (1, "Posters", 9.0));
// Delete selects the one above ([Default]).
press(&mut app, Action::Delete, "Posters");
assert!(app.session.prefs.print_presets.is_empty());
assert_eq!(field(&app, "selected"), DEFAULT_PRESET);
super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "Close");
}
#[test]
fn presets_files_import_and_export() {
let dir = std::env::temp_dir().join(format!("vc-printpresets-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("mine.vcprintpresets").to_string_lossy().to_string();
let picked = file.clone();
let services = crate::Services {
pick_save: Some(Box::new(move |_: &crate::FilePick| 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("print.presets.save", json!({"name": "Mine", "settings": {"copies": 2}})).unwrap();
app.run("ui.printPresetsDialog", json!({"selected": "Mine"})).unwrap();
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.print_presets.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Mine", "Mine 2"]);
assert_eq!(app.session.prefs.print_presets[1].settings.copies, 2);
assert!(app.ui.status.contains("print presets"), "{}", app.ui.status);
let _ = std::fs::remove_dir_all(dir);
}

View File

@ -15,13 +15,14 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs};
const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"];
/// Open bytes of any readable format as a new document (templates open untitled); swatch and /// 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 and PDF presets files are /// graphic style library files open in the library panel and flattener, PDF and print presets
/// imported. /// files are imported.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> { pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
let ext = fileio::extension(name); let ext = fileio::extension(name);
let presets = [ let presets = [
(vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"),
(vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"),
(vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"),
]; ];
if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { 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 r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;

View File

@ -507,7 +507,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.print", "file.print",
"Print…", "Print…",
"Cmd+P", "Cmd+P",
"{} open the Print dialog (dialog `print`: the print.setup settings, printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}", "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}",
), ),
( (
"print.printers", "print.printers",
@ -516,6 +516,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}", "{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}",
), ),
("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"), ("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"),
(
"ui.printPresetsDialog",
"Print Presets…",
"",
"{selected?: preset name} open Edit → Print Presets (dialog `printPresets`, field `selected`): [Default] (protected) and the saved presets, with how the selected one differs from [Default]. New… and Edit… open the preset editor (ui.printPresetDialog); Delete, Import… and Export… run print.presets.delete / import / export",
),
(
"ui.printPresetDialog",
"Print Preset",
"",
"{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets",
),
]; ];
/// Handle a UI command. `None` = not a UI command (the engine handles it). /// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -922,6 +934,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
None => Err("printing isn't available here".into()), None => Err("printing isn't available here".into()),
} }
} }
"ui.printPresetsDialog" => {
crate::dialogs::print_presets::open(app, s("selected").as_deref());
Ok(Value::Null)
}
"ui.printPresetDialog" => crate::dialogs::print::open_preset(app, p),
_ => return None, _ => return None,
}; };
Some(r) Some(r)
@ -1293,7 +1310,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Edit Original", "links.editOriginal"), c("Edit Original", "links.editOriginal"),
Sep, Sep,
c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"),
todo("Print Presets…"), c("Print Presets…", "ui.printPresetsDialog"),
c("PDF Presets…", "ui.pdfPresetsDialog"), c("PDF Presets…", "ui.pdfPresetsDialog"),
cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})), cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})),
Sep, Sep,

View File

@ -405,3 +405,14 @@ PDF. With `discard: true` it is Done: it only keeps the settings. Settings that
range of no tiles) answer with an error and keep the dialog open. `file.print {settings?, printer?, toFile?, path?}` range of no tiles) answer with an error and keep the dialog open. `file.print {settings?, printer?, toFile?, path?}`
prints without the dialog; `print.printers` lists the printers and `print.printerSetup {printer?}` opens their system prints without the dialog; `print.printers` lists the printers and `print.printerSetup {printer?}` opens their system
settings (Setup…). settings (Setup…).
Print presets: in the `print` dialog, `preset` names the print preset whose settings were loaded last; setting it
(`ui.dialog.set {field: "preset", value: "Posters"}`) loads that preset's settings, and the list reads `[Custom]` once
they differ. Save Preset… shows a name field (`__savePresetAs`): while it shows, `ui.dialog.confirm` saves the dialog's
settings as that preset (`print.presets.save`) and selects it instead of printing. `ui.printPresetsDialog {selected?}`
opens Edit → Print Presets (dialog `printPresets`, field `selected`); `[Default]` is protected. New… and Edit… open the
preset editor, which `ui.printPresetDialog {name? (a saved preset to edit) | preset? (the preset a new one starts
from)}` opens too: dialog `printPreset`, the Print dialog's settings fields plus `name`; `ui.dialog.confirm` runs
`print.presets.save` and returns to Print Presets (a new preset can't take a name in use). Delete, Import… and
Export… are `print.presets.delete`, `print.presets.import` and `print.presets.export`; `ui.dialog.confirm` closes.
Opening a `.vcprintpresets` file with `app.open` imports its presets.

View File

@ -1506,3 +1506,18 @@ default printer) or, with `toFile`, a `path` or no printing available, saves the
{"name":"run_command","arguments":{"command":"print.preview","params":{"settings":{"copies":2,"output":{"mode":"separations"}}}}} {"name":"run_command","arguments":{"command":"print.preview","params":{"settings":{"copies":2,"output":{"mode":"separations"}}}}}
{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf"}}} {"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf"}}}
``` ```
## Print presets
`print.presets.list` lists `[Default]` (the default print settings, `builtIn: true`, protected) and the saved presets,
each with its `settings` and how they differ from `[Default]` (`changed`). `print.presets.save {name?, newName?,
preset?, settings?}` creates or changes a saved preset (starting from its own settings, or from `preset`, with
`settings` over them) and `print.presets.delete {name}` deletes one; `[Default]` can be neither changed nor deleted.
`print.presets.export {names?, path?}` writes them as a `.vcprintpresets` JSON file (without `path` it returns `data`),
and `print.presets.import {path? | data? | dataBase64?, replace?}` adds a file's presets (a name in use gets a number
unless `replace`). Saved presets live with the preferences. To print with a preset, pass its `settings` to
`print.setup`, `print.preview` or `file.print`.
```json
{"name":"run_command","arguments":{"command":"print.presets.save","params":{"name":"Posters","settings":{"scaling":"tileImageable","overlap":18,"marks":{"trim":true}}}}}
```