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"}}} +```