From ffce1cf686d8b567f29f20f4e30f6a807a10c69d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 16:10:55 +0700 Subject: [PATCH 1/2] M4.78: File > Print opens the Print dialog and prints through the system The Print dialog (Ctrl/Cmd+P) edits the document's print settings in the sections General, Marks and Bleed, Output, Graphics, Color Management, Advanced and Summary, with a preview of each printed page under the section list (drag the art to move its placement). It drives print.setup, print.preview and file.print; the preview pages now carry their transform, printed area and trim box. Print sends the job's PDF to the chosen printer through the new PrintService (lp on macOS/Linux, the shell print verb on Windows, a hidden frame and the browser's print dialog on the web); without one, or with PDF File, it saves the PDF. Done only keeps the settings. UI commands file.print, print.printers and print.printerSetup. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-web/Cargo.toml | 2 +- apps/vectorcraft-web/src/web.rs | 69 ++- apps/vectorcraft/src/main.rs | 3 + apps/vectorcraft/src/printing.rs | 207 +++++++ crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/engine/src/cmd/print.rs | 4 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_printpreview.rs | 53 ++ crates/pdf/src/print/mod.rs | 15 +- crates/ui-egui/src/dialogs/mod.rs | 5 + crates/ui-egui/src/dialogs/pdf_presets.rs | 11 +- crates/ui-egui/src/dialogs/print.rs | 642 ++++++++++++++++++++++ crates/ui-egui/src/dialogs/save_pdf.rs | 101 ++-- crates/ui-egui/src/dialogs/tests_print.rs | 234 ++++++++ crates/ui-egui/src/io.rs | 4 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 27 +- crates/ui-egui/src/print.rs | 85 +++ docs/control-protocol.md | 13 + docs/mcp.md | 18 + 20 files changed, 1441 insertions(+), 60 deletions(-) create mode 100644 apps/vectorcraft/src/printing.rs create mode 100644 crates/engine/src/tests_printpreview.rs create mode 100644 crates/ui-egui/src/dialogs/print.rs create mode 100644 crates/ui-egui/src/dialogs/tests_print.rs create mode 100644 crates/ui-egui/src/print.rs diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index cedc2282..e0085c32 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -27,4 +27,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage"] } diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 4dc0bb27..63578ace 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -8,6 +8,7 @@ use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::cmd::recovery::RecoveryStore; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; +use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; use vectorcraft_ui_egui::{Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; @@ -174,6 +175,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic })), inbox: Some(inbox), recovery_store: Some(Arc::new(BrowserStore)), + // File → Print: the browser's print dialog. + print: Some(Box::new(BrowserPrint)), ..Default::default() } } @@ -225,11 +228,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into()); let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; - let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); - let opts = web_sys::BlobPropertyBag::new(); - opts.set_type(fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime)); - let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; - let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + let url = blob_url(bytes, fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime))?; let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; a.set_href(&url); a.set_download(&name); @@ -245,3 +244,63 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; Ok(()) } + +/// An object URL of a blob of `bytes` with media type `mime` (revoke it when done). +fn blob_url(bytes: &[u8], mime: &str) -> Result { + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(mime); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js_err)?; + web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err) +} + +/// File → Print in the browser: the job's PDF in a hidden frame, printed through the browser's +/// print dialog (which picks the printer). +struct BrowserPrint; + +/// The frame holding the last print job (the next job replaces it). +const PRINT_FRAME_ID: &str = "vectorcraft-print-frame"; + +impl PrintService for BrowserPrint { + fn printers(&mut self) -> Vec { + // Browsers don't tell pages about printers: their print dialog lists them. + vec![] + } + + fn print(&mut self, job: &PrintJob) -> Result { + print_pdf(job.pdf)?; + Ok(format!("Printing “{}”: pick the printer in the browser's print dialog", job.title)) + } +} + +fn print_pdf(pdf: &[u8]) -> Result<(), String> { + let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + // The previous job's frame and its blob go first. + if let Some(old) = document.get_element_by_id(PRINT_FRAME_ID) { + if let Some(url) = old.get_attribute("src") { + web_sys::Url::revoke_object_url(&url).ok(); + } + old.remove(); + } + let url = blob_url(pdf, "application/pdf")?; + let frame: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js_err)?.dyn_into().map_err(|_| "not a frame")?; + frame.set_id(PRINT_FRAME_ID); + // Out of sight but laid out: browsers don't print a frame that isn't. + let style = frame.style(); + for (k, v) in [("position", "fixed"), ("right", "0"), ("bottom", "0"), ("width", "0"), ("height", "0"), ("border", "0")] { + style.set_property(k, v).map_err(js_err)?; + } + let loaded = frame.clone(); + let onload = wasm_bindgen::closure::Closure::once_into_js(move || { + if let Some(w) = loaded.content_window() { + w.focus().ok(); + if let Err(e) = w.print() { + log::error!("printing failed: {e:?}"); + } + } + }); + frame.set_onload(Some(onload.unchecked_ref())); + frame.set_src(&url); + document.body().ok_or("no body")?.append_child(&frame).map_err(js_err)?; + Ok(()) +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index b544c55f..fa8c93b7 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -10,6 +10,7 @@ mod clipboard; mod control_server; #[cfg(target_os = "macos")] mod native_menu; +mod printing; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; @@ -176,6 +177,8 @@ fn services() -> Services { // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. open_file: Some(Box::new(open_file)), pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))), + // File → Print: the system's printers and print queue. + print: Some(Box::new(printing::SystemPrint)), ..Default::default() } } diff --git a/apps/vectorcraft/src/printing.rs b/apps/vectorcraft/src/printing.rs new file mode 100644 index 00000000..5566e905 --- /dev/null +++ b/apps/vectorcraft/src/printing.rs @@ -0,0 +1,207 @@ +//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and +//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's +//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of +//! the PDF viewer. + +use std::path::PathBuf; +use std::process::Command; + +use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; + +/// The system's printing. +pub struct SystemPrint; + +impl PrintService for SystemPrint { + fn printers(&mut self) -> Vec { + system::printers() + } + + fn print(&mut self, job: &PrintJob) -> Result { + let file = job_file(job.pdf)?; + let r = system::print(job, &file); + let to = job.printer.unwrap_or("the default printer"); + r.map(|note| format!("Sent “{}” to {to}{note}", job.title)) + } + + fn has_setup(&self) -> bool { + true + } + + fn setup(&mut self, printer: Option<&str>) -> Result<(), String> { + system::setup(printer) + } +} + +/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more +/// than an hour old (the print verb reads them after Print returns) are removed first. +fn job_file(pdf: &[u8]) -> Result { + let dir = std::env::temp_dir().join("vectorcraft-print"); + std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?; + for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() { + let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600); + if old { + // Another job may still be reading it: leave it for next time. + let _ = std::fs::remove_file(e.path()); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id())); + std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?; + Ok(path) +} + +/// Run `c` → its output, or its error message. +fn output(c: &mut Command) -> Result { + let program = c.get_program().to_string_lossy().to_string(); + let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?; + if out.status.success() { + return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); + } + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err }) +} + +/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line). +#[cfg(any(test, not(windows)))] +fn lpstat_names(text: &str) -> Vec { + let mut names: Vec = vec![]; + for name in text.lines().filter_map(|l| l.split_whitespace().next()) { + if !names.iter().any(|n| n == name) { + names.push(name.to_string()); + } + } + names +} + +/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system +/// default destination"). +#[cfg(any(test, not(windows)))] +fn lpstat_default(text: &str) -> Option { + let line = text.lines().find(|l| l.contains(':'))?; + let name = line.rsplit_once(':')?.1.trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +/// PowerShell's `\t` lines → the printers. +#[cfg(any(test, windows))] +fn windows_printers(text: &str) -> Vec { + text.lines() + .filter_map(|l| l.trim_end_matches('\r').split_once('\t')) + .filter(|(_, name)| !name.trim().is_empty()) + .map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") }) + .collect() +} + +/// `s` as a PowerShell single-quoted string. +#[cfg(any(test, windows))] +fn ps_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "''")) +} + +#[cfg(not(windows))] +mod system { + use super::*; + + pub fn printers() -> Vec { + // `-e` lists every destination (CUPS 2); older systems answer `-a`. + let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t)); + let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t)); + names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect() + } + + pub fn print(job: &PrintJob, file: &std::path::Path) -> Result { + let mut c = Command::new("lp"); + if let Some(p) = job.printer { + c.args(["-d", p]); + } + c.args(["-t", job.title]).arg(file); + let r = output(&mut c); + // `lp` has spooled its own copy (or failed). + let _ = std::fs::remove_file(file); + r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default()) + } + + pub fn setup(printer: Option<&str>) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let _ = printer; + Command::new("open") + .arg("x-apple.systempreferences:com.apple.Print-Scan-Settings.extension") + .spawn() + .map(|_| ()) + .map_err(|e| e.to_string()) + } + #[cfg(not(target_os = "macos"))] + { + if Command::new("system-config-printer").spawn().is_ok() { + return Ok(()); + } + // CUPS' own pages. + let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}")); + Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string()) + } + } +} + +#[cfg(windows)] +mod system { + use super::*; + + fn powershell(script: &str) -> Command { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut c = Command::new("powershell"); + c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW); + c + } + + pub fn printers() -> Vec { + let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }"; + output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default() + } + + pub fn print(job: &PrintJob, file: &std::path::Path) -> Result { + // The PDF viewer's print verb reads the file after this returns: it stays for a while. + let path = ps_quote(&file.to_string_lossy()); + let script = match job.printer { + Some(p) => format!( + "Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden", + ps_quote(&format!("\"{}\"", p.replace('"', ""))) + ), + None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"), + }; + output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})")) + } + + pub fn setup(_printer: Option<&str>) -> Result<(), String> { + Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lpstat_lists_names_and_the_default() { + let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n"); + assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]); + assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]); + assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet")); + assert_eq!(lpstat_default("no system default destination\n"), None); + } + + #[test] + fn powershell_lines_become_printers() { + let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n"); + assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]); + assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'"); + } + + #[test] + fn job_files_hold_the_pdf() { + let path = job_file(b"%PDF-1.7 test").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test"); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 5a8d91ce..b12177c5 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -239,7 +239,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } /// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`). -fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec) { +pub fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec) { match (v, default) { (Value::Object(o), Value::Object(d)) => { for (k, x) in o { diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index c3b3e203..95982d45 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Print Preview", [], None, - "{settings?: {…print.setup settings} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %]}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, options not applied yet…), settings} without printing", + "{settings?: {…print.setup settings} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, options not applied yet…), settings} without printing", has_doc, preview ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "file.print", "Print", [], - None, + Some("Cmd+P"), "{settings?: {…print.setup settings} (over the document's), path?} print the document as a print-ready PDF, one page per sheet of paper (per tile, per ink, per copy) → {pages, path, bytes, warnings}; no path → {dataBase64, bytes, pages, warnings}. Raster effects print at the document's raster effects resolution; halftone screens and flatness are left to the output device", has_doc, print diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 70eb333a..4ce3bd7c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1210,6 +1210,8 @@ mod tests_previewbounds; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_printpreview; +#[cfg(test)] mod tests_proxyitems; #[cfg(test)] mod tests_rastersettings; diff --git a/crates/engine/src/tests_printpreview.rs b/crates/engine/src/tests_printpreview.rs new file mode 100644 index 00000000..c640212f --- /dev/null +++ b/crates/engine/src/tests_printpreview.rs @@ -0,0 +1,53 @@ +//! `print.preview`'s pages say where the art lands (what the Print dialog's preview draws): the +//! transform from the document onto the page, the region each page prints and its trim box. + +use serde_json::{Value, json}; + +use super::*; + +/// `print.preview {settings}`'s first page. +fn first_sheet(s: &mut Session, settings: Value) -> Value { + s.execute("print.preview", &json!({ "settings": settings })).unwrap()["sheets"][0].clone() +} + +/// `[a, b, c, d, e, f]` applied to (x, y). +fn apply(t: &Value, (x, y): (f64, f64)) -> (f64, f64) { + let n = |i: usize| t[i].as_f64().unwrap(); + (n(0) * x + n(2) * y + n(4), n(1) * x + n(3) * y + n(5)) +} + +fn close(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6 +} + +#[test] +fn pages_place_the_artboard_on_the_trim_box() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("document.setup", &json!({"bleed": 9})).unwrap(); + // Centred on Letter, turned landscape for the wide artboard. + let p = first_sheet(&mut s, json!({})); + assert_eq!((p["width"].clone(), p["height"].clone()), (json!(792.0), json!(612.0))); + assert_eq!(p["trim"], json!([246.0, 206.0, 546.0, 406.0])); + assert!(close(apply(&p["transform"], (0.0, 0.0)), (246.0, 206.0)) && close(apply(&p["transform"], (300.0, 200.0)), (546.0, 406.0))); + assert_eq!(p["area"], json!([-9.0, -9.0, 309.0, 209.0]), "the artboard with the document's bleed"); + // Flipped, the artboard's top-left corner lands on the trim box's bottom-right one. + let p = first_sheet( + &mut s, + json!({"autoRotate": false, "orientation": "landscapeFlipped", "scaling": "custom", "scale": {"width": 50, "height": 50}}), + ); + let t = &p["trim"]; + assert!(close(apply(&p["transform"], (0.0, 0.0)), (t[2].as_f64().unwrap(), t[3].as_f64().unwrap())), "{p}"); + assert_eq!(p["scale"], json!([50.0, 50.0])); + // A tile prints its part of the artboard. + let v = s.execute("print.preview", &json!({"settings": {"scaling": "tileFull", "scale": {"width": 400, "height": 400}}})).unwrap(); + let tiles = v["tiles"][0]["tiles"].as_array().unwrap(); + for (sheet, tile) in v["sheets"].as_array().unwrap().iter().zip(tiles) { + let (a, t) = (&sheet["area"], tile); + for i in 0..4 { + let inside = + if i < 2 { a[i].as_f64().unwrap() >= t[i].as_f64().unwrap() - 1e-6 } else { a[i].as_f64().unwrap() <= t[i].as_f64().unwrap() + 1e-6 }; + assert!(inside, "{a} within {t}"); + } + } +} diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index 6c856c46..1655e884 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -79,6 +79,14 @@ pub struct PageSummary { pub orientation: Orientation, /// Horizontal and vertical scale in percent. pub scale: [f64; 2], + /// Document space → the page (pt, y down from its top-left corner): where the art lands, + /// turned and mirrored as it prints. + pub transform: [f64; 6], + /// The part of the document this page prints (`[x0, y0, x1, y1]`, document space): the + /// artboard with its bleed, or what of it a tile shows. + pub area: [f64; 4], + /// The artboard (trim box) on the page, `[x0, y0, x1, y1]`. + pub trim: [f64; 4], } /// What a job prints, without printing it. @@ -112,8 +120,8 @@ struct Job<'a> { } /// Keep the layers of `nodes` that print as `which` says, shown (hidden ones print with All), -/// down through sublayers. -fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { +/// down through sublayers (also what the Print dialog's preview shows). +pub fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { nodes.retain_mut(|n| { let NodeKind::Layer { printable, .. } = n.kind else { return true }; let keep = match which { @@ -203,6 +211,9 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result png_options::SPEC, SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC, PsdOptions: ["psdOptions"] => png_options::SPEC, + Print: [print::KIND] => print::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -367,3 +369,6 @@ mod tests_save_for_web; #[cfg(test)] mod tests_psd; + +#[cfg(test)] +mod tests_print; diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index 1bb0b8ed..06151bdd 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -11,7 +11,7 @@ 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 super::save_pdf::{open_preset, option_label, option_rows, section_of}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; @@ -122,14 +122,7 @@ fn details(ui: &mut egui::Ui, t: &Tokens, preset: &vectorcraft_pdf::PdfPreset, b 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)); - } + option_rows(ui, &changed, option_label); }); ui.add_space(6.0); if let Err(e) = preset.settings.check() { diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs new file mode 100644 index 00000000..127dd962 --- /dev/null +++ b/crates/ui-egui/src/dialogs/print.rs @@ -0,0 +1,642 @@ +//! File → Print: the Print dialog. 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 +//! printed page under the section list: the arrows step through the pages, dragging the art moves +//! its placement on the paper. +//! +//! The fields are the print settings (`print.setup`'s; sections are objects such as `marks`) plus +//! `printer` (a name from `print.printers`, "" the system's default), `toFile` (save the job as a +//! PDF instead) and UI-only `__` keys, so agents fill the dialog with `ui.dialog.set`. Print (OK) +//! 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 +//! from `print.preview`: the dialog lays nothing out itself. + +use std::sync::{Arc, LazyLock}; + +use serde_json::{Value, json}; +use vectorcraft_color::cms::Intent; +use vectorcraft_geom::Rect as DRect; +use vectorcraft_pdf::{ + Choice, Emulsion, FontDownload, Media, Orientation, Origin, OutputMode, PrintArtboards, PrintImage, PrintLayers, PrintScaling, PrintSettings, +}; + +use super::save_pdf::{TOP_LABEL_WIDTH, choice, current_section, flag, get, heading, length, note, number, pick, row, row_with, set, text}; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +pub(super) const KIND: &str = "print"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Print".into(), body, confirm, ok: Some("Print"), discard: Some("Done"), ..DialogSpec::FORM }; + +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. +static DEFAULTS: LazyLock = LazyLock::new(|| serde_json::to_value(PrintSettings::default()).unwrap_or_default()); + +/// 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> { + (kind == KIND).then(|| &*DEFAULTS) +} + +/// The fields that aren't print settings. +const NOT_SETTINGS: [&str; 3] = ["printer", "toFile", "discard"]; + +/// The Printer list's entries besides the system's printers. +const DEFAULT_PRINTER: &str = "Default Printer"; +const PDF_FILE: &str = "PDF File"; + +/// The preview's size in the section column. +const PREVIEW: egui::Vec2 = egui::vec2(super::save_pdf::LIST_WIDTH - 12.0, 168.0); + +/// Open the Print dialog on the document's print settings and the system's default printer. +pub fn open(app: &mut VectorcraftApp) -> Result { + let st = app.session.active().ok_or("no document")?; + // 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 Value::Object(mut fields) = serde_json::to_value(saved).map_err(|e| e.to_string())? else { + return Err("print settings are not an object".into()); + }; + let printers = crate::print::printers(app); + 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(); + fields.insert("printer".into(), json!(default)); + fields.insert("toFile".into(), json!(!printers["service"].as_bool().unwrap_or(false))); + fields.insert("__printers".into(), printers); + fields.insert("__section".into(), json!(SECTIONS[0])); + fields.insert("__sheet".into(), json!(0)); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(Value::Null) +} + +/// The print settings the dialog stands for (`print.setup`'s `settings`). +pub(super) fn settings(d: &Dialog) -> Value { + let mut p = form::params(d); + if let Some(o) = p.as_object_mut() { + for k in NOT_SETTINGS { + o.remove(k); + } + } + p +} + +/// 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. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + // Done's flag holds for this press only: the dialog may stay open. + if let Some(open) = app.ui.dialog.as_mut() { + open.fields.remove("discard"); + } + let settings = settings(d); + let p = json!({ "settings": settings }); + // 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.run("print.setup", p)?; + if d.bool("discard") { + app.ui.dialog = None; + return Ok(json!({ "settings": settings })); + } + let r = crate::print::run(app, &json!({ "settings": settings, "printer": d.str("printer"), "toFile": d.bool("toFile") }))?; + app.ui.dialog = None; + Ok(r) +} + +// ---------- the dialog ---------- + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + printer_row(app, ui, d); + ui.add_space(10.0); + let section = current_section(d, &SECTIONS); + let preview = preview_of(app, ui.ctx(), &settings(d)); + ui.horizontal_top(|ui| { + super::save_pdf::section_frame(ui, |ui| { + super::save_pdf::section_list(ui, d, &SECTIONS, section); + ui.add_space(10.0); + page_preview(app, ui, d, &preview); + }); + ui.add_space(14.0); + ui.vertical(|ui| { + ui.set_width(500.0); + ui.label(egui::RichText::new(section).font(theme::semibold(14.0)).color(t.text_strong)); + egui::ScrollArea::vertical().id_salt(("print", section)).max_height(360.0).auto_shrink([false, false]).show(ui, |ui| match section { + "Marks and Bleed" => super::save_pdf::marks_and_bleeds(app, ui, d), + "Output" => output(ui, d, &preview), + "Graphics" => graphics(ui, d), + "Color Management" => color(ui, d), + "Advanced" => advanced(app, ui, d), + "Summary" => summary(ui, d, &preview), + _ => general(app, ui, d), + }); + }); + }); + false +} + +/// What the Printer list offers. +enum Destination { + Printer(String), + /// The system's default printer (the web: the browser's print dialog). + Default, + File, +} + +/// The Printer row: the system's printers (or its default one), PDF File; Setup…. +fn printer_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let printers = d.fields.get("__printers"); + let names: Vec = printers + .and_then(|p| p["printers"].as_array()) + .map(|a| a.iter().filter_map(|p| p["name"].as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + let service = printers.and_then(|p| p["service"].as_bool()).unwrap_or(false); + let setup = printers.and_then(|p| p["setup"].as_bool()).unwrap_or(false); + let mut entries: Vec = names.iter().cloned().map(Destination::Printer).collect(); + if service && names.is_empty() { + entries.push(Destination::Default); + } + entries.push(Destination::File); + let label = |e: &Destination| match e { + Destination::Printer(name) => name.clone(), + Destination::Default => DEFAULT_PRINTER.into(), + Destination::File => PDF_FILE.into(), + }; + let labels: Vec = entries.iter().map(label).collect(); + let printer = d.str("printer"); + let current = if d.bool("toFile") { + PDF_FILE + } else if printer.is_empty() { + DEFAULT_PRINTER + } else { + printer.as_str() + }; + row_with(ui, "Printer:", TOP_LABEL_WIDTH, |ui| { + let options: Vec<&str> = labels.iter().map(String::as_str).collect(); + match widgets::dropdown(ui, "print-printer", current, &options, 300.0).and_then(|i| entries.get(i)) { + Some(Destination::Printer(name)) => { + d.fields.insert("printer".into(), json!(name)); + d.fields.insert("toFile".into(), json!(false)); + } + Some(Destination::Default) => { + d.fields.insert("printer".into(), json!("")); + d.fields.insert("toFile".into(), json!(false)); + } + Some(Destination::File) => { + d.fields.insert("toFile".into(), json!(true)); + } + None => {} + } + let to_file = d.bool("toFile"); + let tip = if setup { "The system's settings of this printer" } else { "No printer settings to open here" }; + if ui.add_enabled_ui(setup && !to_file, |ui| widgets::flat_button(ui, "Setup…", 72.0)).inner.on_hover_text(tip).clicked() + && let Err(e) = app.run("print.printerSetup", json!({ "printer": d.str("printer") })) + { + app.status(e); + } + }); +} + +/// `print.preview` of `settings` (an error when they can't print), cached until they or the +/// document change. +fn preview_of(app: &mut VectorcraftApp, ctx: &egui::Context, settings: &Value) -> Arc> { + let doc = app.session.active().map_or((0, 0), |s| (s.uid, s.revision)); + let stamp = egui::Id::new((settings.to_string(), doc)); + let key = egui::Id::new("print-preview"); + if let Some((s, v)) = ctx.data(|m| m.get_temp::<(egui::Id, Arc>)>(key)) + && s == stamp + { + return v; + } + let v = Arc::new(app.session.execute("print.preview", &json!({ "settings": settings })).map_err(|e| e.to_string())); + ctx.data_mut(|m| m.insert_temp(key, (stamp, v.clone()))); + v +} + +/// A `[x0, y0, x1, y1]` array as a rectangle. +fn rect_of(v: &Value) -> Option { + let n = |i: usize| v.get(i).and_then(Value::as_f64); + Some(DRect::new(n(0)?, n(1)?, n(2)?, n(3)?)) +} + +/// The page `__sheet` shows (clamped to the pages there are) and how many there are. +fn current_sheet<'a>(d: &Dialog, pv: &'a Value) -> Option<(usize, usize, &'a Value)> { + let sheets = pv["sheets"].as_array()?; + let i = (d.f64("__sheet", 0.0).max(0.0) as usize).min(sheets.len().checked_sub(1)?); + Some((i, sheets.len(), sheets.get(i)?)) +} + +/// The preview: the page with its imageable area (dashed) and the art as it prints; ◀ ▶ step +/// through the pages; dragging the art moves the placement (not when tiling). +fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(PREVIEW, egui::Sense::drag()); + ui.painter().rect_filled(rect, 3.0, t.pasteboard); + let sheet = pv.as_ref().ok().and_then(|v| current_sheet(d, v)); + 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 font = egui::FontId::proportional(11.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); + return; + }; + let size = egui::vec2(sheet["width"].as_f64().unwrap_or(0.0) as f32, sheet["height"].as_f64().unwrap_or(0.0) as f32); + let fit = ((rect.width() - 16.0) / size.x).min((rect.height() - 16.0) / size.y); + if !(fit.is_finite() && fit > 0.0) { + return; + } + let page = egui::Rect::from_center_size(rect.center(), size * fit); + let to_screen = |x: f64, y: f64| page.min + egui::vec2(x as f32, y as f32) * fit; + let painter = ui.painter(); + painter.rect_filled(page.translate(egui::vec2(2.0, 2.0)), 0.0, egui::Color32::from_black_alpha(60)); + painter.rect_filled(page, 0.0, egui::Color32::WHITE); + // Dragging moves the art (and its trim box) on the paper; the placement changes on release. + let tiling = choice::(d, "scaling").is_some_and(PrintScaling::tiles); + let drag_id = egui::Id::new("print-preview-drag"); + let mut offset = ui.ctx().data(|m| m.get_temp::(drag_id)).unwrap_or_default(); + if !tiling && resp.dragged() { + offset += resp.drag_delta(); + ui.ctx().data_mut(|m| m.insert_temp(drag_id, offset)); + } + let coeffs: Vec = sheet["transform"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let clip = ui.painter().with_clip_rect(page); + if let ([a, b, c, dd, e, f], Some(area)) = (coeffs.as_slice(), rect_of(&sheet["area"])) { + let px = (f64::from(page.width().max(page.height()) * ui.ctx().pixels_per_point())).min(1024.0); + let layers = choice::(d, "printLayers").unwrap_or(PrintLayers::VisiblePrintable); + if let Some(tex) = art_texture(app, ui.ctx(), layers, area, px) { + let at = |x: f64, y: f64| to_screen(a * x + c * y + e, b * x + dd * y + f) + offset; + let mut mesh = egui::Mesh::with_texture(tex.id()); + for (p, uv) in [ + ((area.x0, area.y0), (0.0, 0.0)), + ((area.x1, area.y0), (1.0, 0.0)), + ((area.x1, area.y1), (1.0, 1.0)), + ((area.x0, area.y1), (0.0, 1.0)), + ] { + mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1), uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + clip.add(mesh); + } + } + if resp.drag_stopped() && offset != egui::Vec2::ZERO { + move_placement(d, sheet, (f64::from(offset.x / fit), f64::from(offset.y / fit))); + } + if !resp.dragged() { + ui.ctx().data_mut(|m| m.remove::(drag_id)); + } + // The imageable area inside the device's margin, and the artboard's edge. + let margin = get(d, "margin").as_f64().unwrap_or(0.0) as f32 * fit; + if margin > 0.5 { + let r = page.shrink(margin); + let stroke = egui::Stroke::new(1.0, t.text_dim); + let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + clip.extend(egui::Shape::dashed_line(&corners, stroke, 3.0, 2.0)); + } + if let Some(trim) = rect_of(&sheet["trim"]) { + let r = egui::Rect::from_min_max(to_screen(trim.x0, trim.y0), to_screen(trim.x1, trim.y1)).translate(offset); + clip.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); + } + let resp = if tiling { resp } else { resp.on_hover_cursor(egui::CursorIcon::Grab) }; + resp.on_hover_text(if tiling { "The page as it prints" } else { "Drag the art to move it on the paper" }); + // ◀ n of N ▶, then what the page is. + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if widgets::icon_button_enabled(ui, "chevron-left", "Previous page", false, index > 0, 20.0).clicked() { + d.fields.insert("__sheet".into(), json!(index - 1)); + } + let text = format!("{} of {count}", index + 1); + ui.add_sized([PREVIEW.x - 44.0, 20.0], egui::Label::new(egui::RichText::new(text).size(11.5).color(t.text))); + if widgets::icon_button_enabled(ui, "chevron-right", "Next page", false, index + 1 < count, 20.0).clicked() { + d.fields.insert("__sheet".into(), json!(index + 1)); + } + }); + let mut what = vec![]; + match sheet["artboard"].as_u64() { + Some(a) => what.push(format!("Artboard {}", a + 1)), + None => what.push("All the art".into()), + } + if let Some(tile) = sheet["tile"].as_u64() { + what.push(format!("tile {tile}")); + } + if let Some(ink) = sheet["ink"].as_str() { + what.push(ink.to_string()); + } + let unit = app.session.general_unit(); + let scale = sheet["scale"].as_array().map(|s| s.iter().filter_map(Value::as_f64).collect::>()).unwrap_or_default(); + let scale = match scale.as_slice() { + [w, h] if (w - h).abs() < 1e-6 => format!("{}%", round2(*w)), + [w, h] => format!("{}% × {}%", round2(*w), round2(*h)), + _ => String::new(), + }; + let paper = format!("{} × {}", unit.number(f64::from(size.x)), unit.format(f64::from(size.y))); + for line in [what.join(" · "), format!("{paper} · {scale}")] { + ui.label(egui::RichText::new(line).size(11.0).color(t.text_dim)); + } +} + +/// Move the placement so the art of `sheet` moves `(dx, dy)` points on the page: the move turned +/// back onto the paper (the page may be turned or mirrored), at the art's scale. +pub(super) fn move_placement(d: &mut Dialog, sheet: &Value, (dx, dy): (f64, f64)) { + let n = |k: &str, i: usize| sheet[k].get(i).and_then(Value::as_f64); + let (Some(a), Some(b), Some(c), Some(e)) = (n("transform", 0), n("transform", 1), n("transform", 2), n("transform", 3)) else { return }; + let (Some(sx), Some(sy)) = (n("scale", 0), n("scale", 1)) else { return }; + let (sx, sy) = (sx / 100.0, sy / 100.0); + if !(sx > 0.0 && sy > 0.0) { + return; + } + // The transform's linear part is the page's turn times the scale, and the turn's inverse is + // its transpose: the columns over the scale. + let x = get(d, "placement.x").as_f64().unwrap_or(0.0) + (a * dx + b * dy) / sx; + let y = get(d, "placement.y").as_f64().unwrap_or(0.0) + (c * dx + e * dy) / sy; + set(d, "placement.x", json!(round2(x))); + set(d, "placement.y", json!(round2(y))); +} + +fn round2(v: f64) -> f64 { + (v * 100.0).round() / 100.0 +} + +/// The art of `area` as it prints (only the layers that print), `px` pixels on its longest side; +/// cached until the document, the layers or the size change. +fn art_texture(app: &mut VectorcraftApp, ctx: &egui::Context, layers: PrintLayers, area: DRect, px: f64) -> Option { + let st = app.session.active()?; + let bits = [area.x0, area.y0, area.x1, area.y1].map(f64::to_bits); + let stamp = egui::Id::new((st.uid, st.revision, layers, bits, px.round() as u32)); + let key = egui::Id::new("print-preview-art"); + if let Some((s, tex)) = ctx.data(|m| m.get_temp::<(egui::Id, egui::TextureHandle)>(key)) + && s == stamp + { + return Some(tex); + } + let mut doc = (*st.doc).clone(); + vectorcraft_pdf::keep_layers(&mut doc.layers, layers); + let tex = widgets::region_texture(ctx, &mut app.canvas.renderer, "print-preview-art", &doc, area, px); + ctx.data_mut(|m| m.insert_temp(key, (stamp, tex.clone()))); + Some(tex) +} + +// ---------- sections ---------- + +/// A whole number at `path` within `range`. +fn whole(ui: &mut egui::Ui, d: &mut Dialog, path: &str, range: std::ops::RangeInclusive, enabled: bool) { + let v = get(d, path).as_f64().unwrap_or(*range.start()); + ui.add_enabled_ui(enabled, |ui| { + if let Some(x) = widgets::plain_field(ui, path, v, "", 0, 56.0) { + set(d, path, json!(x.round().clamp(*range.start(), *range.end()) as u64)); + } + }); +} + +fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let unit = app.session.general_unit(); + row(ui, "Copies:", |ui| { + whole(ui, d, "copies", 1.0..=f64::from(vectorcraft_pdf::MAX_COPIES), true); + ui.add_space(12.0); + let copies = get(d, "copies").as_u64().unwrap_or(1); + flag(ui, d, "collate", "Collate", copies > 1); + flag(ui, d, "reverse", "Reverse Order", true); + }); + heading(ui, "Artboards"); + let artboards = choice::(d, "artboards").unwrap_or(PrintArtboards::All); + ui.horizontal(|ui| { + for (a, label) in [(PrintArtboards::All, "All"), (PrintArtboards::Range, "Range:")] { + if widgets::radio(ui, label, artboards == a, true) { + set(d, "artboards", json!(a.id())); + } + } + let mut range = get(d, "range").as_str().unwrap_or_default().to_string(); + let edit = egui::TextEdit::singleline(&mut range).desired_width(140.0).hint_text("1-3, 5"); + if ui.add_enabled(artboards == PrintArtboards::Range, edit).changed() { + set(d, "range", json!(range)); + } + }); + if widgets::radio(ui, "Ignore Artboards (all the art as one page)", artboards == PrintArtboards::Ignore, true) { + set(d, "artboards", json!(PrintArtboards::Ignore.id())); + } + flag(ui, d, "skipBlank", "Skip Blank Artboards", artboards != PrintArtboards::Ignore); + heading(ui, "Media"); + row(ui, "Size:", |ui| { + pick::(ui, d, "media", 160.0, |_| true); + }); + // A paper size shows its size; Custom takes one. + let media = choice::(d, "media").unwrap_or_default(); + row(ui, "Width:", |ui| match media.size() { + Some((w, h)) => { + ui.add_enabled_ui(false, |ui| widgets::num_field(ui, "print-media-w", Some(w), unit, 80.0)); + ui.label("Height:"); + ui.add_enabled_ui(false, |ui| widgets::num_field(ui, "print-media-h", Some(h), unit, 80.0)); + } + None => { + length(ui, d, "width", unit, true); + ui.label("Height:"); + length(ui, d, "height", unit, true); + } + }); + heading(ui, "Orientation"); + flag(ui, d, "autoRotate", "Auto-Rotate (turn the paper to each artboard)", true); + let auto = get(d, "autoRotate").as_bool() == Some(true); + row(ui, "Orientation:", |ui| { + ui.add_enabled_ui(!auto, |ui| pick::(ui, d, "orientation", 160.0, |_| true)); + }); + flag(ui, d, "transverse", "Transverse (the page a quarter turn on the paper)", true); + heading(ui, "Options"); + row(ui, "Print Layers:", |ui| { + pick::(ui, d, "printLayers", 220.0, |_| true); + }); + let scaling = choice::(d, "scaling").unwrap_or(PrintScaling::None); + let tiles = scaling.tiles(); + row(ui, "Placement:", |ui| { + ui.add_enabled_ui(!tiles, |ui| { + let origin = choice::(d, "placement.origin").unwrap_or_default(); + let current = Origin::ALL.iter().position(|o| *o == origin).unwrap_or(4); + if let Some(o) = widgets::reference_point(ui, current).and_then(|i| Origin::ALL.get(i)) { + set(d, "placement.origin", json!(o.id())); + } + ui.label("X:"); + length(ui, d, "placement.x", unit, true); + ui.label("Y:"); + length(ui, d, "placement.y", unit, true); + }); + }); + row(ui, "Scaling:", |ui| { + pick::(ui, d, "scaling", 180.0, |_| true); + }); + row(ui, "Scale:", |ui| { + let on = scaling == PrintScaling::Custom || tiles; + ui.label("W:"); + number(ui, d, "scale.width", "%", on); + ui.label("H:"); + number(ui, d, "scale.height", "%", on); + }); + row(ui, "Overlap:", |ui| length(ui, d, "overlap", unit, tiles)); + row(ui, "Tile Range:", |ui| text(ui, d, "tileRange", tiles)); + if tiles { + note(ui, "Tiles are numbered across, then down; leave the range empty for every tile."); + } +} + +fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { + row(ui, "Mode:", |ui| { + pick::(ui, d, "output.mode", 160.0, |_| true); + }); + row(ui, "Emulsion:", |ui| { + pick::(ui, d, "output.emulsion", 160.0, |_| true); + }); + row(ui, "Image:", |ui| { + pick::(ui, d, "output.image", 160.0, |_| true); + }); + let separations = choice::(d, "output.mode") == Some(OutputMode::Separations); + flag(ui, d, "output.spotsToProcess", "Convert All Spot Colors to Process", separations); + heading(ui, "Document Ink Options"); + let inks = pv.as_ref().ok().and_then(|v| v["inks"].as_array()).map(Vec::as_slice).unwrap_or_default(); + if !separations || inks.is_empty() { + note(ui, "Separations list the document's inks here: one page per ink that prints."); + return; + } + let t = Tokens::get(ui.ctx()); + egui::Grid::new("print-inks").num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| { + for h in ["Print", "Ink", "Frequency", "Angle"] { + ui.label(egui::RichText::new(h).size(11.5).color(t.text_dim)); + } + ui.end_row(); + for ink in inks { + let name = ink["name"].as_str().unwrap_or_default(); + let on = ink["print"].as_bool().unwrap_or(true); + if widgets::check(ui, "", on, true) { + set_ink(d, name, "print", json!(!on)); + } + let spot = if ink["spot"] == true { " (spot)" } else { "" }; + ui.label(egui::RichText::new(format!("{name}{spot}")).color(t.text)); + for (k, suffix) in [("frequency", " lpi"), ("angle", "°")] { + let v = ink[k].as_f64().unwrap_or(0.0); + if let Some(x) = widgets::plain_field(ui, ("print-ink", name, k), v, suffix, 2, 72.0) { + set_ink(d, name, k, json!(x)); + } + } + ui.end_row(); + } + }); + note(ui, "Frequencies and angles are the output device's to apply."); +} + +/// Set option `key` of ink `name` in `output.inks` (the ink gets its own entry the first time). +fn set_ink(d: &mut Dialog, name: &str, key: &str, value: Value) { + let mut inks = get(d, "output.inks").as_array().cloned().unwrap_or_default(); + match inks.iter_mut().find(|i| i["name"] == name) { + Some(ink) => ink[key] = value, + None => { + let mut ink = json!({ "name": name, "print": true }); + ink[key] = value; + inks.push(ink); + } + } + set(d, "output.inks", Value::Array(inks)); +} + +fn graphics(ui: &mut egui::Ui, d: &mut Dialog) { + heading(ui, "Paths"); + flag(ui, d, "graphics.autoFlatness", "Automatic flatness", true); + let auto = get(d, "graphics.autoFlatness").as_bool() == Some(true); + row(ui, "Flatness:", |ui| { + number(ui, d, "graphics.flatness", "", !auto); + ui.label(egui::RichText::new("0.2 (quality) to 100 (speed)").size(11.5).color(Tokens::get(ui.ctx()).text_dim)); + }); + heading(ui, "Fonts"); + row(ui, "Download:", |ui| { + pick::(ui, d, "graphics.fonts", 160.0, |_| true); + }); +} + +fn color(ui: &mut egui::Ui, d: &mut Dialog) { + row(ui, "Rendering intent:", |ui| { + let current = serde_json::from_value::(get(d, "color.intent").clone()).unwrap_or_default(); + let labels: Vec<&str> = Intent::ALL.iter().map(Intent::label).collect(); + if let Some(i) = widgets::dropdown(ui, "color.intent", current.label(), &labels, 200.0).and_then(|i| Intent::ALL.get(i)) { + set(d, "color.intent", serde_json::to_value(i).unwrap_or_default()); + } + }); + note(ui, "How colours outside the press's gamut are separated."); + flag(ui, d, "color.preserveNumbers", "Preserve CMYK Numbers", true); + note(ui, "CMYK colours print with their own values; off, they go through the colour settings too."); +} + +fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + heading(ui, "Printer"); + row(ui, "Unprintable margin:", |ui| length(ui, d, "margin", app.session.general_unit(), true)); + note(ui, "The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed."); +} + +/// The section an option is set in, and whether its first key is the section's own object +/// (`output.mode` → Output › Mode). +fn section_of(option: &str) -> (usize, bool) { + match option.split('.').next().unwrap_or_default() { + "marks" | "bleed" => (1, false), + "output" => (2, true), + "graphics" => (3, true), + "color" => (4, true), + "margin" => (5, false), + _ => (0, false), + } +} + +/// `output.mode` → "Output › Mode", `placement.x` → "General › Placement › X". +fn option_label(option: &str) -> String { + let (section, own) = section_of(option); + 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::>().join(" › ") +} + +/// Changed options (`{option, value}`) with their choices as the dialog names them +/// (`tileFull` → "Tile Full Pages"). +pub(super) fn labelled(changed: &mut [Value]) { + fn label(id: &str) -> Option<&'static str> { + T::IDS.iter().position(|i| *i == id).and_then(|i| T::LABELS.get(i)).copied() + } + for c in changed { + let Some(id) = c["value"].as_str() else { continue }; + let named = match c["option"].as_str().unwrap_or_default() { + "artboards" => label::(id), + "media" => label::(id), + "orientation" => label::(id), + "printLayers" => label::(id), + "placement.origin" => label::(id), + "scaling" => label::(id), + "marks.kind" => label::(id), + "output.mode" => label::(id), + "output.emulsion" => label::(id), + "output.image" => label::(id), + "graphics.fonts" => label::(id), + "color.intent" => serde_json::from_value::(c["value"].clone()).ok().map(|i| i.label()), + _ => None, + }; + if let Some(l) = named { + c["value"] = json!(l); + } + } +} + +fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { + let t = Tokens::get(ui.ctx()); + let v = match pv { + Ok(v) => v, + Err(e) => { + heading(ui, "Error"); + 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"); + let mut changed = vec![]; + vectorcraft_engine::cmd::fileio::pdf::changed("", &settings(d), &DEFAULTS, &mut changed); + changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()).0); + labelled(&mut changed); + if changed.is_empty() { + note(ui, "Every option is at its default."); + } + super::save_pdf::option_rows(ui, &changed, option_label); + heading(ui, "Warnings"); + super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default()); +} diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 2db8a3d1..5c2c6dec 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -58,11 +58,11 @@ const DOCUMENT_PROFILE: &str = "Document profile"; /// The Output Intent Profile entry for no output intent (stored as ""). const NO_PROFILE: &str = "None"; -const LABEL_WIDTH: f32 = 150.0; +pub(super) const LABEL_WIDTH: f32 = 150.0; /// Width of the section list (its frame adds 6 px a side). -const LIST_WIDTH: f32 = 150.0; +pub(super) const LIST_WIDTH: f32 = 150.0; /// The top rows' labels end where the section content starts, so their dropdowns line up with it. -const TOP_LABEL_WIDTH: f32 = LIST_WIDTH + 26.0; +pub(super) const TOP_LABEL_WIDTH: f32 = LIST_WIDTH + 26.0; /// Open the dialog with `params` (`document.exportPdf` options and `path?`) applied over their /// preset. @@ -191,12 +191,14 @@ fn lookup<'a>(m: &'a Map, path: &str) -> Option<&'a Value> { keys.try_fold(first, |v, k| v.get(k)) } -/// The value at `path`: the dialog's, else the default. -fn get<'a>(d: &'a Dialog, path: &str) -> &'a Value { - lookup(&d.fields, path).or_else(|| DEFAULTS.as_object().and_then(|m| lookup(m, path))).unwrap_or(&Value::Null) +/// The value at `path`: the dialog's, else the default of the settings it edits (the PDF +/// settings, or the print settings of the Print dialogs). +pub(super) fn get<'a>(d: &'a Dialog, path: &str) -> &'a Value { + let defaults = super::print::defaults(&d.kind).unwrap_or(&DEFAULTS); + lookup(&d.fields, path).or_else(|| defaults.as_object().and_then(|m| lookup(m, path))).unwrap_or(&Value::Null) } -fn set(d: &mut Dialog, path: &str, value: Value) { +pub(super) fn set(d: &mut Dialog, path: &str, value: Value) { let (parents, key) = path.rsplit_once('.').unwrap_or(("", path)); let mut map = &mut d.fields; for k in parents.split('.').filter(|k| !k.is_empty()) { @@ -212,24 +214,24 @@ fn set(d: &mut Dialog, path: &str, value: Value) { // ---------- widgets bound to a path ---------- -fn heading(ui: &mut egui::Ui, text: &str) { +pub(super) fn heading(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(6.0); ui.label(egui::RichText::new(text).font(theme::semibold(12.5)).color(t.text_strong)); ui.add_space(2.0); } -fn note(ui: &mut egui::Ui, text: &str) { +pub(super) fn note(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim)); } /// A labelled row. -fn row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { +pub(super) fn row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { row_with(ui, label, LABEL_WIDTH, add); } -fn row_with(ui: &mut egui::Ui, label: &str, width: f32, add: impl FnOnce(&mut egui::Ui)) { +pub(super) fn row_with(ui: &mut egui::Ui, label: &str, width: f32, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.allocate_ui_with_layout(egui::vec2(width, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { @@ -240,7 +242,7 @@ fn row_with(ui: &mut egui::Ui, label: &str, width: f32, add: impl FnOnce(&mut eg }); } -fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &str, label: &str, enabled: bool) { +pub(super) fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &str, label: &str, enabled: bool) { let on = get(d, path).as_bool().unwrap_or(false); if widgets::check(ui, label, on, enabled) { set(d, path, json!(!on)); @@ -253,12 +255,12 @@ fn choice_index(d: &Dialog, path: &str) -> Option { } /// The choice at `path`. -fn choice(d: &Dialog, path: &str) -> Option { +pub(super) fn choice(d: &Dialog, path: &str) -> Option { choice_index::(d, path).and_then(|i| T::ALL.get(i).copied()) } /// A dropdown of the choices `T` (options for which `enabled` is false are greyed). -fn pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, enabled: impl Fn(T) -> bool) -> bool { +pub(super) fn pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, enabled: impl Fn(T) -> bool) -> bool { let current = choice_index::(d, path); let label = current.and_then(|i| T::LABELS.get(i)).copied().unwrap_or_default(); let chosen = widgets::dropdown_with(ui, path, label, T::LABELS, width, |i| T::ALL.get(i).is_some_and(|c| enabled(*c))); @@ -267,7 +269,7 @@ fn pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, en true } -fn number(ui: &mut egui::Ui, d: &mut Dialog, path: &str, suffix: &str, enabled: bool) { +pub(super) fn number(ui: &mut egui::Ui, d: &mut Dialog, path: &str, suffix: &str, enabled: bool) { let v = get(d, path).as_f64().unwrap_or(0.0); ui.add_enabled_ui(enabled, |ui| { if let Some(x) = widgets::plain_field(ui, path, v, suffix, 3, 64.0) { @@ -277,7 +279,7 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, path: &str, suffix: &str, enabled: } /// A length in points, shown in the document's units. -fn length(ui: &mut egui::Ui, d: &mut Dialog, path: &str, unit: Unit, enabled: bool) { +pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, path: &str, unit: Unit, enabled: bool) { let v = get(d, path).as_f64(); ui.add_enabled_ui(enabled, |ui| { if let Some(x) = widgets::num_field(ui, path, v, unit, 80.0) { @@ -286,7 +288,7 @@ fn length(ui: &mut egui::Ui, d: &mut Dialog, path: &str, unit: Unit, enabled: bo }); } -fn text(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) { +pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) { let mut s = get(d, path).as_str().unwrap_or_default().to_string(); if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).desired_width(240.0)).changed() { set(d, path, json!(s)); @@ -324,23 +326,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { pick::(ui, d, "compatibility", 110.0, |c| standard.allows(c)); }); ui.add_space(10.0); - let section = SECTIONS.iter().copied().find(|s| *s == d.str("__section")).unwrap_or(SECTIONS[0]); + let section = current_section(d, &SECTIONS); ui.horizontal_top(|ui| { - egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| { - ui.set_width(LIST_WIDTH); - ui.set_min_height(380.0); - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 1.0; - for s in SECTIONS { - let sel = s == section; - let label = egui::RichText::new(s).size(12.5).color(if sel { t.text_strong } else { t.text }); - if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() - { - d.fields.insert("__section".into(), json!(s)); - } - } - }); - }); + section_frame(ui, |ui| section_list(ui, d, &SECTIONS, section)); ui.add_space(14.0); ui.vertical(|ui| { ui.set_width(500.0); @@ -359,6 +347,34 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The section `__section` names (default: the first). +pub(super) fn current_section(d: &Dialog, sections: &[&'static str]) -> &'static str { + sections.iter().copied().find(|s| *s == d.str("__section")).or_else(|| sections.first().copied()).unwrap_or_default() +} + +/// The frame of the section list column, at least as tall as the section content. +pub(super) fn section_frame(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| { + ui.set_width(LIST_WIDTH); + ui.set_min_height(380.0); + ui.vertical(add); + }); +} + +/// The section list: clicking a section shows it (`__section`). +pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str], section: &str) { + let t = Tokens::get(ui.ctx()); + ui.spacing_mut().item_spacing.y = 1.0; + for s in sections { + let sel = *s == section; + let label = egui::RichText::new(*s).size(12.5).color(if sel { t.text_strong } else { t.text }); + if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() { + d.fields.insert("__section".into(), json!(s)); + } + } +} + /// 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) { @@ -470,7 +486,7 @@ fn compression(ui: &mut egui::Ui, d: &mut Dialog) { flag(ui, d, "compression.compressText", "Compress text and line art", true); } -fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { +pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { let unit = app.session.general_unit(); const MARKS: [(&str, &str); 4] = [ ("marks.trim", "Trim marks"), @@ -693,16 +709,27 @@ fn summary(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { let base = if d.kind == PRESET_KIND { pdf::DEFAULT_PRESET } else { "the preset" }; note(ui, &format!("Every option matches {base}.")); } + option_rows(ui, changed, option_label); + heading(ui, "Warnings"); + warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default()); +} + +/// A Summary line per changed option (`{option, value}`), labelled by `label`. +pub(super) fn option_rows<'a>(ui: &mut egui::Ui, changed: impl IntoIterator, label: impl Fn(&str) -> String) { + let t = Tokens::get(ui.ctx()); 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.label(egui::RichText::new(format!("{}: {value}", label(c["option"].as_str().unwrap_or_default()))).color(t.text)); } - heading(ui, "Warnings"); - let warnings = v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default(); +} + +/// The Summary's warnings ("None." without any). +pub(super) fn warning_rows(ui: &mut egui::Ui, warnings: &[Value]) { + let t = Tokens::get(ui.ctx()); if warnings.is_empty() { note(ui, "None."); } diff --git a/crates/ui-egui/src/dialogs/tests_print.rs b/crates/ui-egui/src/dialogs/tests_print.rs new file mode 100644 index 00000000..490cbf67 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_print.rs @@ -0,0 +1,234 @@ +//! File → Print: the Print dialog (every section draws, Print and Done, the preview's drag), the +//! print service it prints through (a mock here) and the PDF it saves without one. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use super::print::{KIND, SECTIONS}; +use crate::print::{PrintJob, PrintService, Printer}; +use crate::state::Dialog; +use crate::{Services, VectorcraftApp, theme}; + +/// What the mock service was sent: (printer, title, PDF). +type Jobs = Rc, String, Vec)>>>; + +/// What the app wrote: (path, bytes). +type Written = Rc)>>>; + +struct MockPrint(Jobs); + +impl PrintService for MockPrint { + fn printers(&mut self) -> Vec { + vec![Printer { name: "Office".into(), default: false }, Printer { name: "Proofer".into(), default: true }] + } + + fn print(&mut self, job: &PrintJob) -> Result { + self.0.borrow_mut().push((job.printer.map(str::to_string), job.title.to_string(), job.pdf.to_vec())); + Ok(format!("Sent to {}", job.printer.unwrap_or("the default printer"))) + } +} + +/// An app with three 120 × 90 artboards and a rectangle, printing through the mock. +fn app() -> (VectorcraftApp, Jobs) { + let jobs = Jobs::default(); + let services = Services { print: Some(Box::new(MockPrint(jobs.clone()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 120, "height": 90, "artboards": 3})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + (app, jobs) +} + +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(); +} + +fn dialog(app: &mut VectorcraftApp) -> &mut Dialog { + app.ui.dialog.as_mut().expect("a dialog is open") +} + +fn set(app: &mut VectorcraftApp, field: &str, value: Value) { + dialog(app).fields.insert(field.into(), value); +} + +fn pages(pdf: &[u8]) -> usize { + vectorcraft_pdf::info(pdf, None).unwrap().pages.len() +} + +#[test] +fn print_opens_the_dialog_and_every_section_draws() { + let (mut app, _) = app(); + app.run("file.print", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + assert_eq!(super::DialogKind::of(KIND), Some(super::DialogKind::Print)); + let d = dialog(&mut app); + assert_eq!((d.str("printer"), d.bool("toFile")), ("Proofer".to_string(), false), "the system's default printer"); + assert_eq!(d.fields["copies"], 1, "the document's settings, defaults when never set up"); + for (s, mode) in SECTIONS.iter().flat_map(|s| [(s, "composite"), (s, "separations")]) { + set(&mut app, "__section", json!(s)); + set(&mut app, "output", json!({"mode": mode})); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "{s} closed the dialog"); + } + // Tiling, a custom paper and settings that can't print draw too. + set(&mut app, "scaling", json!("tileImageable")); + set(&mut app, "media", json!("custom")); + set(&mut app, "__sheet", json!(99)); + frame(&mut app); + set(&mut app, "copies", json!(0)); + set(&mut app, "__section", json!("Summary")); + frame(&mut app); + assert!(app.ui.dialog.is_some()); + let mut changed = vec![json!({"option": "scaling", "value": "tileImageable"}), json!({"option": "copies", "value": 0})]; + super::print::labelled(&mut changed); + assert_eq!((changed[0]["value"].clone(), changed[1]["value"].clone()), (json!("Tile Imageable Areas"), json!(0))); + // The menu item and its shortcut. + assert!(crate::menus::menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("file.print") && e.enabled)); + assert_eq!(crate::shortcut_editor::default_command_shortcut("file.print"), Some("Cmd+P")); +} + +#[test] +fn print_sends_every_page_to_the_chosen_printer_and_keeps_the_settings() { + let (mut app, jobs) = app(); + app.run("file.print", json!({})).unwrap(); + set(&mut app, "printer", json!("Office")); + set(&mut app, "copies", json!(2)); + set(&mut app, "marks", json!({"trim": true})); + let undo = app.session.active().unwrap().history.undo.len(); + let r = super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "Print closes the dialog"); + assert_eq!((r["pages"].clone(), r["printed"].clone(), r["printer"].clone()), (json!(6), json!(true), json!("Office"))); + let jobs = jobs.borrow(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].0.as_deref(), Some("Office")); + assert_eq!(pages(&jobs[0].2), 6, "3 artboards × 2 copies"); + assert_eq!(app.ui.status, "Sent to Office"); + // The settings are kept with the document as one undo step. + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + let saved = st.doc.print_setup.as_ref().unwrap(); + assert_eq!((saved["copies"].clone(), saved["marks"]["trim"].clone(), saved["marks"]["weight"].clone()), (json!(2), json!(true), json!(0.25))); + // The next Print starts from them. + app.run("file.print", json!({})).unwrap(); + assert_eq!(dialog(&mut app).fields["copies"], 2); +} + +#[test] +fn done_keeps_the_settings_without_printing_and_bad_settings_keep_the_dialog() { + let (mut app, jobs) = app(); + app.run("file.print", json!({})).unwrap(); + set(&mut app, "artboards", json!("range")); + set(&mut app, "range", json!("7")); + set(&mut app, "discard", json!(true)); + assert!(super::confirm(&mut app).is_err(), "artboard 7 doesn't exist"); + assert!(app.session.active().unwrap().doc.print_setup.is_none(), "nothing kept"); + assert!(!dialog(&mut app).fields.contains_key("discard"), "Done's press doesn't stick to the dialog"); + set(&mut app, "range", json!("2-3")); + set(&mut app, "discard", json!(true)); + super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(jobs.borrow().is_empty(), "Done doesn't print"); + let saved = app.session.active().unwrap().doc.print_setup.clone().unwrap(); + assert_eq!((saved["artboards"].clone(), saved["range"].clone()), (json!("range"), json!("2-3"))); + // Agents print without the dialog; the default printer when none is named. + let r = app.run("file.print", json!({"settings": {"copies": 3}})).unwrap(); + assert_eq!(r["pages"], 6); + assert_eq!(jobs.borrow()[0].0, None); + assert_eq!(pages(&jobs.borrow()[0].2), 6, "artboards 2-3 × 3 copies"); + let p = app.run("print.printers", json!({})).unwrap(); + assert_eq!( + (p["printers"][1]["name"].clone(), p["printers"][1]["default"].clone(), p["service"].clone()), + (json!("Proofer"), json!(true), json!(true)) + ); + assert!(!crate::menus::enabled(&app, "print.printerSetup"), "the mock has no settings to open"); +} + +#[test] +fn without_a_print_service_print_saves_a_pdf() { + let written: Written = Rc::default(); + let w = written.clone(); + let services = Services { + pick_save: Some(Box::new(|pick: &crate::FilePick| Some(format!("/tmp/{}", pick.name)))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("file.print", json!({})).unwrap(); + let d = dialog(&mut app); + assert!(d.bool("toFile"), "no printers here: PDF File"); + frame(&mut app); + let r = super::confirm(&mut app).unwrap(); + assert_eq!((r["printed"].clone(), r["pages"].clone()), (json!(false), json!(1))); + let w = written.borrow(); + assert_eq!(w.len(), 1); + assert!(w[0].0.ends_with(".pdf"), "{}", w[0].0); + assert_eq!(pages(&w[0].1), 1); + assert!(app.ui.status.starts_with("Saved /tmp/"), "{}", app.ui.status); + // Printing straight to a path writes it through the app too. + drop(w); + let r = app.run("file.print", json!({"path": "/tmp/job.pdf", "settings": {"copies": 2}})).unwrap(); + assert_eq!((r["path"].clone(), r["pages"].clone()), (json!("/tmp/job.pdf"), json!(2))); + assert_eq!(written.borrow()[1].0, "/tmp/job.pdf"); + assert!(app.run("print.printerSetup", json!({})).is_err()); + assert_eq!(app.run("print.printers", json!({})).unwrap(), json!({"printers": [], "service": false, "setup": false})); +} + +#[test] +fn dragging_the_preview_moves_the_placement_on_the_paper() { + let (mut app, _) = app(); + // The page as the preview shows it, for each way the paper can be turned. + for (settings, page_move) in [ + (json!({}), (10.0, 5.0)), + (json!({"autoRotate": false, "orientation": "portraitFlipped"}), (10.0, 5.0)), + (json!({"output": {"emulsion": "down"}, "transverse": true}), (-4.0, 7.0)), + (json!({"scaling": "custom", "scale": {"width": 50, "height": 200}}), (6.0, -8.0)), + ] { + let before = app.session.execute("print.preview", &json!({ "settings": settings })).unwrap(); + let sheet = &before["sheets"][0]; + let mut d = Dialog::new(KIND, settings.clone()); + super::print::move_placement(&mut d, sheet, page_move); + let moved = super::print::settings(&d); + let after = app.session.execute("print.preview", &json!({ "settings": moved })).unwrap(); + let (t0, t1) = (&before["sheets"][0]["trim"], &after["sheets"][0]["trim"]); + let delta = (t1[0].as_f64().unwrap() - t0[0].as_f64().unwrap(), t1[1].as_f64().unwrap() - t0[1].as_f64().unwrap()); + assert!((delta.0 - page_move.0).abs() < 0.02 && (delta.1 - page_move.1).abs() < 0.02, "{settings}: moved {delta:?}, dragged {page_move:?}"); + } +} + +#[test] +fn the_control_channel_fills_and_confirms_the_dialog() { + let (mut app, jobs) = app(); + let ctx = egui::Context::default(); + let call = |app: &mut VectorcraftApp, method: &str, params: Value| { + let (req, _) = crate::control::ControlRequest::new(method, params); + let crate::control::Outcome::Done(r) = crate::control::handle(app, &ctx, &req) else { panic!("not done") }; + assert_eq!(r["ok"], true, "{method}: {r}"); + r["result"].clone() + }; + call(&mut app, "engine.execute", json!({"command": "file.print"})); + call(&mut app, "ui.dialog.set", json!({"field": "copies", "value": 3})); + call(&mut app, "ui.dialog.set", json!({"field": "artboards", "value": "ignore"})); + let r = call(&mut app, "ui.dialog.confirm", json!({})); + assert_eq!(r["pages"], 3); + assert_eq!(pages(&jobs.borrow()[0].2), 3); + // Done through the channel, and Cancel. + call(&mut app, "engine.execute", json!({"command": "file.print"})); + call(&mut app, "ui.dialog.set", json!({"field": "copies", "value": 4})); + call(&mut app, "ui.dialog.cancel", json!({})); + assert_eq!(app.session.active().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 3, "Cancel keeps nothing"); + call(&mut app, "engine.execute", json!({"command": "file.print"})); + call(&mut app, "ui.dialog.set", json!({"field": "discard", "value": true})); + call(&mut app, "ui.dialog.set", json!({"field": "copies", "value": 5})); + call(&mut app, "ui.dialog.confirm", json!({})); + assert_eq!(app.session.active().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 5); + assert_eq!(jobs.borrow().len(), 1); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index f7ec064e..eab8fd8b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -294,7 +294,7 @@ fn view_file(app: &mut VectorcraftApp, path: &str) { } /// "Saved ", with the first of the result's warnings. -fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { +pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); app.status(match warnings.first() { Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()), @@ -433,7 +433,7 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params /// that path (else a picked or suggested name) → (path, the command's result without the data). /// The command runs before a path is asked for, so bad params never open a save dialog, except /// for Export Selection without a `format`, which takes it from the picked path. -fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> { +pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> { let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string)); if let Some(o) = params.as_object_mut() && o.get("format").is_none() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38d21fe1..6523394e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod palette; pub mod panels; pub mod place; pub mod prefs_dialog; +pub mod print; pub mod recovery; pub mod render_worker; pub mod shortcut_editor; @@ -160,6 +161,9 @@ pub struct Services { /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage; /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]). pub recovery_store: Option>, + /// File → Print: the system's printers and print queue (desktop), the browser's print dialog + /// (web). Without it Print saves the job as a PDF. + pub print: Option>, } /// Cached canvas raster. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 52927311..2ec05854 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -503,6 +503,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…]}", ), + ( + "file.print", + "Print…", + "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}", + ), + ( + "print.printers", + "Printers", + "", + "{} → {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)"), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -898,6 +911,17 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::css_properties::copy(app, p), "css.exportFile" => crate::panels::css_properties::export_file(app, p), + // File → Print…: its dialog; with params, print (or save the job as a PDF) without it. + "file.print" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::print::open(app), + "file.print" => crate::print::run(app, p), + "print.printers" => Ok(crate::print::printers(app)), + "print.printerSetup" => { + let printer = s("printer").filter(|n| !n.is_empty()); + match app.services.print.as_mut() { + Some(service) => service.setup(printer.as_deref()).map(|_| Value::Null), + None => Err("printing isn't available here".into()), + } + } _ => return None, }; Some(r) @@ -1135,6 +1159,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "css.copy" | "css.exportFile" => app.session.active().is_some(), + "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), _ => true, } } @@ -1213,7 +1238,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ), c("File Info…", "file.info"), Sep, - todos("Print…", "Cmd+P"), + c("Print…", "file.print"), ], ), ( diff --git a/crates/ui-egui/src/print.rs b/crates/ui-egui/src/print.rs new file mode 100644 index 00000000..71e134fd --- /dev/null +++ b/crates/ui-egui/src/print.rs @@ -0,0 +1,85 @@ +//! Printing through the platform. The host app installs a [`PrintService`] ([`crate::Services`]): +//! the desktop hands the job to the system's print spooler, the web to the browser's print dialog. +//! `file.print` with settings sends the engine's print-ready PDF (`file.print` in the engine) to +//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service; +//! `print.printers` lists the printers. + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::{VectorcraftApp, io}; + +/// A printer the system knows. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Printer { + pub name: String, + /// The system's default printer. + pub default: bool, +} + +/// A print job: a print-ready PDF, every copy and page in it. +pub struct PrintJob<'a> { + /// The printer; `None` = the system's default (the web: the browser's print dialog). + pub printer: Option<&'a str>, + /// The job's name in the print queue. + pub title: &'a str, + pub pdf: &'a [u8], +} + +/// The platform's printing ([`crate::Services::print`]). +pub trait PrintService { + /// The printers the system knows (none: only the default one, or the browser's dialog). + fn printers(&mut self) -> Vec; + /// Send `job` to its printer → what happened, for the status bar. + fn print(&mut self, job: &PrintJob) -> Result; + /// Whether Setup… can open the system's printer settings. + fn has_setup(&self) -> bool { + false + } + /// Setup…: open the system's settings of `printer` (`None`: the printers). + fn setup(&mut self, _printer: Option<&str>) -> Result<(), String> { + Err("there are no printer settings to open here".into()) + } +} + +/// `print.printers`: the system's printers, and what the print service offers. +pub fn printers(app: &mut VectorcraftApp) -> Value { + let (printers, setup) = app.services.print.as_mut().map(|s| (s.printers(), s.has_setup())).unwrap_or_default(); + json!({ "printers": printers, "service": app.services.print.is_some(), "setup": setup }) +} + +/// `file.print` with settings: print the active document with `settings` (over the document's) +/// on `printer` (default: the system's default), or save the job as a PDF at `path` (else a picked +/// file, the web downloads it) with `toFile`, a `path` or no print service. +pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some(); + let mut q = json!({}); + if let Some(set) = p.get("settings").filter(|v| !v.is_null()) { + q["settings"] = set.clone(); + } + if to_file || app.services.print.is_none() { + if let Some(path) = s("path") { + q["path"] = json!(path); + } + let (path, mut v) = io::run_to_file(app, "file.print", "pdf", q)?; + io::report_saved(app, &path, &v); + if !to_file { + app.ui.status.push_str(" (printing isn't available here: the job was saved as a PDF)"); + } + v["path"] = json!(path); + v["printed"] = json!(false); + return Ok(v); + } + let mut v = app.session.execute("file.print", &q).map_err(|e| e.to_string())?; + let data = v.as_object_mut().and_then(|o| o.remove("dataBase64")); + let pdf = data.as_ref().and_then(Value::as_str).and_then(vectorcraft_format::base64_decode).ok_or("the print job has no data")?; + let title = app.session.active().map(|d| d.title()).unwrap_or_default(); + let printer = s("printer").filter(|n| !n.is_empty()); + let service = app.services.print.as_mut().ok_or("no print service")?; + let done = service.print(&PrintJob { printer: printer.as_deref(), title: &title, pdf: &pdf })?; + app.status(done); + v["printer"] = json!(printer); + v["printed"] = json!(true); + Ok(v) +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8c0e8c32..c0569bce 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -392,3 +392,16 @@ the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorMo document starts on `cmyk`), `layers` (true: Write Layers; false: Flat Image, whose background list starts at White), `maxEditability` and `hiddenLayers` (with layers) and `embedIcc`. `ui.dialog.set` the fields and `ui.dialog.confirm` writes the file(s) with `document.export`. + +Print: File → Print… (`file.print` with no params, Ctrl/Cmd+P) opens the `print` dialog on the document's print +settings. Its fields are the `print.setup` settings (`copies`, `artboards`, `range`, `media`, `orientation`, +`placement`, `scaling`, `marks`, `bleed`, `output`, `graphics`, `color`, `margin`…; a section is an object, as +`{field: "marks", value: {"trim": true}}`) plus `printer` (a name from `print.printers`, `""` the system's default) and +`toFile` (save the job as a PDF), and the UI-only `__section` (General, Marks and Bleed, Output, Graphics, Color +Management, Advanced, Summary) and `__sheet` (the page the preview shows, 0-based). `ui.dialog.confirm` (Print) keeps +the settings with the document (`print.setup`, one undo step) and prints them: to the printer through the system's +print queue (the web: the browser's print dialog), else, with `toFile` or no printing available, a save panel for the +PDF. With `discard: true` it is Done: it only keeps the settings. Settings that can't print (a missing artboard, a +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 +settings (Setup…). diff --git a/docs/mcp.md b/docs/mcp.md index 7b967443..7bf0a233 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1488,3 +1488,21 @@ drawn. Files are at most 30000 pixels a side; PSD files don't open in VectorCraf {"name":"export","arguments":{"path":"/tmp/poster.psd","options":{"ppi":300,"maxEditability":true}}} {"name":"run_command","arguments":{"command":"document.export","params":{"format":"psd","layers":false,"colorModel":"cmyk"}}} ``` + +## Printing + +`print.setup {settings?}` keeps print settings with the document (one undo step; without `settings` it answers the +current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, marks and bleed, composite or +separations output, graphics and colour management options of File › Print. `print.preview {settings?}` lays the job +out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the `transform` from the +document onto the page (pt, y down), the document `area` it prints and its `trim` box on the page, plus `tiles`, the +`inks` of a separation and `warnings`. `file.print {settings?, path?}` writes the job as a print-ready PDF (one page per +sheet, tile, ink and copy); headless it answers `dataBase64`. In the app, `file.print` with no params opens the Print +dialog, and with params it prints through the system (`printer`: a name from `print.printers`, default the system's +default printer) or, with `toFile`, a `path` or no printing available, saves the PDF. + +```json +{"name":"run_command","arguments":{"command":"print.setup","params":{"settings":{"media":"a4","scaling":"fit","marks":{"trim":true}}}}} +{"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"}}} +``` From 7c748a5e3c7f9fdba7387fc875c72a6bb94be582 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 16:27:24 +0700 Subject: [PATCH 2/2] 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 --- crates/engine/src/cmd/fileio/mod.rs | 3 +- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/print.rs | 6 +- crates/engine/src/cmd/printpresets.rs | 246 ++++++++++++++++++ crates/engine/src/lib.rs | 7 + crates/engine/src/tests_printpresets.rs | 108 ++++++++ crates/engine/tests/import_fuzz.rs | 37 +++ crates/ui-egui/src/dialogs/mod.rs | 6 + crates/ui-egui/src/dialogs/print.rs | 217 +++++++++++++-- crates/ui-egui/src/dialogs/print_presets.rs | 163 ++++++++++++ .../src/dialogs/tests_print_presets.rs | 145 +++++++++++ crates/ui-egui/src/io.rs | 5 +- crates/ui-egui/src/menus.rs | 21 +- docs/control-protocol.md | 11 + docs/mcp.md | 15 ++ 16 files changed, 961 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/cmd/printpresets.rs create mode 100644 crates/engine/src/tests_printpresets.rs create mode 100644 crates/ui-egui/src/dialogs/print_presets.rs create mode 100644 crates/ui-egui/src/dialogs/tests_print_presets.rs diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 5cda41e5..af0f5fe8 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -613,13 +613,14 @@ 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))) + .chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS))) } /// File → Place dialog filters: "All placeable files", then one per readable format, then text. diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index c04120d1..560e8d28 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(), 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] diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3f2259ff..979b19ba 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -47,6 +47,7 @@ pub mod pdfcmds; mod place; pub mod prefscmds; pub mod print; +pub mod printpresets; pub mod rasterfx; mod recolor; pub mod recovery; @@ -239,6 +240,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(webexport::specs()); v.extend(assets::specs()); v.extend(css::specs()); + v.extend(printpresets::specs()); v }) } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 95982d45..3fd4ba06 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -51,7 +51,11 @@ fn saved(doc: &Document) -> Value { /// The settings of `doc` with `p.settings` over them, checked. fn settings(cmd: &str, doc: &Document, p: &Value) -> Result { - 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 { match p.get("settings") { None | Some(Value::Null) => {} Some(over @ Value::Object(_)) => merge(&mut v, over), diff --git a/crates/engine/src/cmd/printpresets.rs b/crates/engine/src/cmd/printpresets.rs new file mode 100644 index 00000000..c209d86b --- /dev/null +++ b/crates/engine/src/cmd/printpresets.rs @@ -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, +} + +pub fn specs() -> Vec { + 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 { + 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 { + 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 { + 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 { + 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) -> 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 { + let rows: Vec = 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 { + 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 { + 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 { + 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::>>()? + } + 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 { + 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 })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4ce3bd7c..8eaebb10 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -465,6 +465,10 @@ pub struct Prefs { /// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set. #[serde(skip_serializing_if = "Option::is_none")] pub web_export_settings: Option, + /// 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, } impl Default for Prefs { @@ -596,6 +600,7 @@ impl Default for Prefs { use_compression: false, web_export_presets: vec![], web_export_settings: None, + print_presets: vec![], } } } @@ -1210,6 +1215,8 @@ mod tests_previewbounds; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_printpresets; +#[cfg(test)] mod tests_printpreview; #[cfg(test)] mod tests_proxyitems; diff --git a/crates/engine/src/tests_printpresets.rs b/crates/engine/src/tests_printpresets.rs new file mode 100644 index 00000000..fafa25cb --- /dev/null +++ b/crates/engine/src/tests_printpresets.rs @@ -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) -> 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()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 80482de5..f395437a 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1105,3 +1105,40 @@ proptest! { 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))?; + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 06102517..dca6c0e4 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -42,6 +42,7 @@ pub mod place; pub mod placement_options; mod png_options; pub mod print; +pub mod print_presets; mod psd_options; pub mod raster_effects; pub mod recolor; @@ -238,6 +239,8 @@ registry! { SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC, PsdOptions: ["psdOptions"] => png_options::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). @@ -372,3 +375,6 @@ mod tests_psd; #[cfg(test)] mod tests_print; + +#[cfg(test)] +mod tests_print_presets; diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 127dd962..1d189736 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -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 //! printed page under the section list: the arrows step through the pages, dragging the art moves //! its placement on the paper. @@ -9,6 +10,11 @@ //! 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 //! 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}; @@ -24,12 +30,31 @@ use super::{DialogSpec, form}; use crate::state::Dialog; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, widgets}; +use vectorcraft_engine::cmd::printpresets::DEFAULT_PRESET; pub(super) const KIND: &str = "print"; pub(super) const SPEC: DialogSpec = 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"]; /// The default print settings as JSON: what a field an agent left out reads as. @@ -37,11 +62,11 @@ static DEFAULTS: LazyLock = LazyLock::new(|| serde_json::to_value(PrintSe /// 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> { - (kind == KIND).then(|| &*DEFAULTS) + (kind == KIND || kind == PRESET_KIND).then(|| &*DEFAULTS) } /// 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. const DEFAULT_PRINTER: &str = "Default Printer"; @@ -55,9 +80,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { let st = app.session.active().ok_or("no document")?; // 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 Value::Object(mut fields) = serde_json::to_value(saved).map_err(|e| e.to_string())? else { - return Err("print settings are not an object".into()); - }; + let mut fields = fields_of(&saved)?; let printers = crate::print::printers(app); 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(); @@ -66,10 +89,82 @@ pub fn open(app: &mut VectorcraftApp) -> Result { fields.insert("__printers".into(), printers); fields.insert("__section".into(), json!(SECTIONS[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 }); Ok(Value::Null) } +/// The settings as dialog fields. +fn fields_of(set: &PrintSettings) -> Result, 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 { + 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 { + 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 { + 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`). pub(super) fn settings(d: &Dialog) -> Value { 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 -/// 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 { + let mut d = d.clone(); // Done's flag holds for this press only: the dialog may stay open. - if let Some(open) = app.ui.dialog.as_mut() { - open.fields.remove("discard"); + let done = d.fields.remove("discard").is_some_and(|v| v == true); + 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 }); // 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.run("print.setup", p)?; - if d.bool("discard") { + if done { app.ui.dialog = None; return Ok(json!({ "settings": settings })); } @@ -106,7 +210,17 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { 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); let section = current_section(d, &SECTIONS); 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), "Color Management" => color(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), }); }); @@ -134,6 +248,49 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { 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::(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. enum Destination { 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); let sheet = pv.as_ref().ok().and_then(|v| current_sheet(d, v)); 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 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); @@ -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 /// (`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() { "marks" | "bleed" => (1, false), "output" => (2, true), @@ -580,7 +741,7 @@ fn section_of(option: &str) -> (usize, bool) { } /// `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 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::>().join(" › ") @@ -615,19 +776,21 @@ pub(super) fn labelled(changed: &mut [Value]) { } } -fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { +fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result, has_doc: bool) { let t = Tokens::get(ui.ctx()); - let v = match pv { - Ok(v) => v, + match pv { + 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) => { heading(ui, "Error"); 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"); let mut changed = vec![]; 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) { note(ui, "Every option is at its default."); } super::save_pdf::option_rows(ui, &changed, option_label); - heading(ui, "Warnings"); - super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default()); + if let Ok(v) = pv { + heading(ui, "Warnings"); + super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default()); + } } diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs new file mode 100644 index 00000000..763a071a --- /dev/null +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -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(()) +} diff --git a/crates/ui-egui/src/dialogs/tests_print_presets.rs b/crates/ui-egui/src/dialogs/tests_print_presets.rs new file mode 100644 index 00000000..ae4848fb --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_print_presets.rs @@ -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); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index eab8fd8b..73c5da31 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -15,13 +15,14 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs}; const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; /// 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 -/// imported. +/// graphic style library files open in the library panel and flattener, PDF and print presets +/// files are imported. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { let ext = fileio::extension(name); let 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::printpresets::PRESET_EXTS, "print.presets.import", "print 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)}))?; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2ec05854..7d4f958f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -507,7 +507,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.print", "Print…", "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", @@ -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)}", ), ("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). @@ -922,6 +934,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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, }; Some(r) @@ -1293,7 +1310,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Edit Original", "links.editOriginal"), Sep, c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), - todo("Print Presets…"), + c("Print Presets…", "ui.printPresetsDialog"), c("PDF Presets…", "ui.pdfPresetsDialog"), cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})), Sep, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c0569bce..8ccbb7d0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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?}` prints without the dialog; `print.printers` lists the printers and `print.printerSetup {printer?}` opens their system 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. diff --git a/docs/mcp.md b/docs/mcp.md index 7bf0a233..dbb65b3f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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":"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}}}}} +```