Merge pull request #116 from storytold/pr/m4-print-dialog

M4.78–M4.79: Print dialog, system printing and print presets
This commit is contained in:
BFlat 2026-10-05 16:36:16 +07:00 committed by GitHub
commit 9f8b7334f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2375 additions and 66 deletions

View File

@ -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"] }

View File

@ -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<String, String> {
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<Printer> {
// Browsers don't tell pages about printers: their print dialog lists them.
vec![]
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
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(())
}

View File

@ -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()
}
}

View File

@ -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<Printer> {
system::printers()
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
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<PathBuf, String> {
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<String, String> {
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<String> {
let mut names: Vec<String> = 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<String> {
let line = text.lines().find(|l| l.contains(':'))?;
let name = line.rsplit_once(':')?.1.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
#[cfg(any(test, windows))]
fn windows_printers(text: &str) -> Vec<Printer> {
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<Printer> {
// `-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<String, String> {
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<Printer> {
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<String, String> {
// 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);
}
}

View File

@ -613,13 +613,14 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
}
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel), flattener presets and PDF presets (imported).
/// libraries (which open in the library panel), flattener, PDF and print presets (imported).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
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.

View File

@ -239,7 +239,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `(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<Value>) {
pub fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec<Value>) {
match (v, default) {
(Value::Object(o), Value::Object(d)) => {
for (k, x) in o {

View File

@ -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]

View File

@ -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
})
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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}");
}
}
}

View File

@ -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))?;
}
}

View File

@ -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<Arc<Node>>, which: PrintLayers) {
/// down through sublayers (also what the Print dialog's preview shows).
pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, 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<PrintPreview, PdfE
height: l.size.1,
orientation: l.orientation,
scale: [l.scale.0 * 100.0, l.scale.1 * 100.0],
transform: (l.view * l.place).as_coeffs(),
area: [l.area.x0, l.area.y0, l.area.x1, l.area.y1],
trim: [l.page_trim.x0, l.page_trim.y0, l.page_trim.x1, l.page_trim.y1],
})
})
.collect();

View File

@ -41,6 +41,8 @@ pub mod pdf_presets;
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;
@ -236,6 +238,9 @@ registry! {
RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC,
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).
@ -367,3 +372,9 @@ mod tests_save_for_web;
#[cfg(test)]
mod tests_psd;
#[cfg(test)]
mod tests_print;
#[cfg(test)]
mod tests_print_presets;

View File

@ -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() {

View File

@ -0,0 +1,807 @@
//! 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.
//!
//! 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.
//!
//! `preset` names the print preset whose settings were loaded last: setting it (the list, or
//! `ui.dialog.set`) loads that preset's settings. Save Preset… names the settings as a preset
//! (`print.presets.save`). The same dialog edits a preset (kind `printPreset`, from Edit → Print
//! Presets): a `name` instead of the preset and printer, and OK saves the preset.
use std::sync::{Arc, LazyLock};
use 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};
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.
static DEFAULTS: LazyLock<Value> = 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 || kind == PRESET_KIND).then(|| &*DEFAULTS)
}
/// The fields that aren't print settings.
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";
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<Value, String> {
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 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();
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));
// 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<serde_json::Map<String, Value>, String> {
match serde_json::to_value(set).map_err(|e| e.to_string())? {
Value::Object(fields) => Ok(fields),
_ => Err("print settings are not an object".into()),
}
}
/// Load the settings of the preset `preset` names once it changed (the list, or `ui.dialog.set`).
fn sync_preset(app: &VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
let name = d.str("preset");
if d.kind != KIND || name == d.str(LOADED) {
return Ok(());
}
d.fields.insert(LOADED.into(), json!(name));
let set = app.session.print_preset_settings(&name).ok_or_else(|| format!("no print preset named `{name}`"))?;
d.fields.extend(fields_of(&set)?);
Ok(())
}
/// Save Preset…: save the settings under the typed name and pick that preset.
fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<Value, String> {
let r = app.run("print.presets.save", json!({ "name": d.str(SAVE_AS), "preset": DEFAULT_PRESET, "settings": settings(d) }))?;
d.fields.remove(SAVE_AS);
d.fields.insert("preset".into(), r["name"].clone());
d.fields.insert(LOADED.into(), r["name"].clone());
Ok(r)
}
/// Open the preset editor on the saved preset `name`, or on a new preset starting from `preset`
/// (default: [Default]).
pub fn open_preset(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
let s = |k: &str| params.get(k).and_then(Value::as_str);
let (base, name, editing) = match s("name") {
Some(name) => {
let p = app.session.prefs.print_presets.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim()));
let p = p.ok_or_else(|| format!("no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it)"))?;
(p.name.clone(), p.name.clone(), p.name.clone())
}
None => (s("preset").unwrap_or(DEFAULT_PRESET).to_string(), app.session.new_print_preset_name(), String::new()),
};
let set = app.session.print_preset_settings(&base).ok_or_else(|| format!("no print preset named `{base}`"))?;
let mut fields = fields_of(&set)?;
fields.insert("name".into(), json!(name));
fields.insert(EDITING.into(), json!(editing));
fields.insert("__section".into(), json!(SECTIONS[0]));
fields.insert("__sheet".into(), json!(0));
app.ui.dialog = Some(Dialog { kind: PRESET_KIND.into(), fields });
Ok(Value::Null)
}
/// Save Preset (the preset editor's OK): `print.presets.save`, then back to Edit → Print Presets on
/// it. A new preset may not take the name of another one.
fn confirm_preset(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (name, editing) = (d.str("name"), d.str(EDITING));
let p = if editing.is_empty() {
if app.session.print_preset_settings(&name).is_some() {
return Err(format!("a preset named `{}` exists", name.trim()));
}
json!({ "name": name, "preset": DEFAULT_PRESET, "settings": settings(d) })
} else {
json!({ "name": editing, "newName": name, "settings": settings(d) })
};
let r = app.run("print.presets.save", p)?;
super::print_presets::open(app, r["name"].as_str());
Ok(r)
}
/// The print settings the dialog stands for (`print.setup`'s `settings`).
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. While
/// Save Preset… asks for a name, it saves the preset instead.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut d = d.clone();
// Done's flag holds for this press only: the dialog may stay open.
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 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 done {
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());
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));
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, app.session.active().is_some()),
_ => general(app, ui, d),
});
});
});
false
}
/// The Print Preset row ([Default], the saved presets; [Custom] once the settings differ from the
/// preset's) with Save Preset…, and while Save Preset… asks for a name, the row taking it.
fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
const CUSTOM: &str = "[Custom]";
let name = d.str("preset");
let current = serde_json::from_value::<PrintSettings>(settings(d)).ok();
let same = current.is_some() && current == app.session.print_preset_settings(&name);
let names: Vec<&str> = std::iter::once(DEFAULT_PRESET).chain(app.session.prefs.print_presets.iter().map(|p| p.name.as_str())).collect();
let mut picked = None;
let asking = d.fields.contains_key(SAVE_AS);
let mut ask = false;
row_with(ui, "Print Preset:", TOP_LABEL_WIDTH, |ui| {
picked =
widgets::dropdown(ui, "print-preset", if same { &name } else { CUSTOM }, &names, 300.0).and_then(|i| names.get(i)).map(|n| n.to_string());
let save = ui.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, "Save Preset…", 96.0)).inner;
ask = save.on_hover_text("Save these settings as a print preset").clicked();
});
if let Some(n) = picked {
// Picking the preset shown again brings its settings back.
d.fields.insert(LOADED.into(), json!(""));
d.fields.insert("preset".into(), json!(n));
if let Err(e) = sync_preset(app, d) {
app.status(e);
}
}
if ask {
d.fields.insert(SAVE_AS.into(), json!(app.session.new_print_preset_name()));
}
if d.fields.contains_key(SAVE_AS) {
row_with(ui, "Save as preset:", TOP_LABEL_WIDTH, |ui| {
form::text_edit(ui, d, SAVE_AS, 200.0);
if widgets::flat_button(ui, "Save", 52.0).clicked()
&& let Err(e) = save_preset(app, d)
{
app.status(e);
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
d.fields.remove(SAVE_AS);
}
});
}
}
/// What the Printer list offers.
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<String> = 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<Destination> = 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<String> = 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<Result<Value, String>> {
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<Result<Value, String>>)>(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<DRect> {
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<Value, String>) {
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 = 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);
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::<PrintScaling>(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::<egui::Vec2>(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<f64> = 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::<PrintLayers>(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::<egui::Vec2>(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::<Vec<_>>()).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<egui::TextureHandle> {
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<f64>, 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::<PrintArtboards>(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::<Media>(ui, d, "media", 160.0, |_| true);
});
// A paper size shows its size; Custom takes one.
let media = choice::<Media>(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::<Orientation>(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::<PrintLayers>(ui, d, "printLayers", 220.0, |_| true);
});
let scaling = choice::<PrintScaling>(d, "scaling").unwrap_or(PrintScaling::None);
let tiles = scaling.tiles();
row(ui, "Placement:", |ui| {
ui.add_enabled_ui(!tiles, |ui| {
let origin = choice::<Origin>(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::<PrintScaling>(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<Value, String>) {
row(ui, "Mode:", |ui| {
pick::<OutputMode>(ui, d, "output.mode", 160.0, |_| true);
});
row(ui, "Emulsion:", |ui| {
pick::<Emulsion>(ui, d, "output.emulsion", 160.0, |_| true);
});
row(ui, "Image:", |ui| {
pick::<PrintImage>(ui, d, "output.image", 160.0, |_| true);
});
let separations = choice::<OutputMode>(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::<FontDownload>(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::<Intent>(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).
pub(super) 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".
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::<Vec<_>>().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<T: Choice>(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::<PrintArtboards>(id),
"media" => label::<Media>(id),
"orientation" => label::<Orientation>(id),
"printLayers" => label::<PrintLayers>(id),
"placement.origin" => label::<Origin>(id),
"scaling" => label::<PrintScaling>(id),
"marks.kind" => label::<vectorcraft_pdf::MarkKind>(id),
"output.mode" => label::<OutputMode>(id),
"output.emulsion" => label::<Emulsion>(id),
"output.image" => label::<PrintImage>(id),
"graphics.fonts" => label::<FontDownload>(id),
"color.intent" => serde_json::from_value::<Intent>(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<Value, String>, has_doc: bool) {
let t = Tokens::get(ui.ctx());
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));
}
}
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);
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());
}
}

View File

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

View File

@ -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<String, Value>, 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<T: Choice>(d: &Dialog, path: &str) -> Option<usize> {
}
/// The choice at `path`.
fn choice<T: Choice>(d: &Dialog, path: &str) -> Option<T> {
pub(super) fn choice<T: Choice>(d: &Dialog, path: &str) -> Option<T> {
choice_index::<T>(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<T: Choice>(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, enabled: impl Fn(T) -> bool) -> bool {
pub(super) fn pick<T: Choice>(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, enabled: impl Fn(T) -> bool) -> bool {
let current = choice_index::<T>(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<T: Choice>(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::<Compatibility>(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<Item = &'a Value>, 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.");
}

View File

@ -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<RefCell<Vec<(Option<String>, String, Vec<u8>)>>>;
/// What the app wrote: (path, bytes).
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
struct MockPrint(Jobs);
impl PrintService for MockPrint {
fn printers(&mut self) -> Vec<Printer> {
vec![Printer { name: "Office".into(), default: false }, Printer { name: "Proofer".into(), default: true }]
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
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);
}

View File

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

View File

@ -15,13 +15,14 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs};
const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"];
/// 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<String>) -> 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)}))?;
@ -294,7 +295,7 @@ fn view_file(app: &mut VectorcraftApp, path: &str) {
}
/// "Saved <path>", 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 +434,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()

View File

@ -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<std::sync::Arc<dyn vectorcraft_engine::cmd::recovery::RecoveryStore>>,
/// 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<Box<dyn print::PrintService>>,
}
/// Cached canvas raster.

View File

@ -503,6 +503,31 @@ 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, 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",
"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)"),
(
"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).
@ -898,6 +923,22 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}),
"css.copy" => 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()),
}
}
"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)
@ -1135,6 +1176,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 +1255,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
),
c("File Info…", "file.info"),
Sep,
todos("Print…", "Cmd+P"),
c("Print…", "file.print"),
],
),
(
@ -1268,7 +1310,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
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,

View File

@ -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<Printer>;
/// Send `job` to its printer → what happened, for the status bar.
fn print(&mut self, job: &PrintJob) -> Result<String, String>;
/// 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<Value, String> {
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)
}

View File

@ -392,3 +392,27 @@ 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…).
Print presets: in the `print` dialog, `preset` names the print preset whose settings were loaded last; setting it
(`ui.dialog.set {field: "preset", value: "Posters"}`) loads that preset's settings, and the list reads `[Custom]` once
they differ. Save Preset… shows a name field (`__savePresetAs`): while it shows, `ui.dialog.confirm` saves the dialog's
settings as that preset (`print.presets.save`) and selects it instead of printing. `ui.printPresetsDialog {selected?}`
opens Edit → Print Presets (dialog `printPresets`, field `selected`); `[Default]` is protected. New… and Edit… open the
preset editor, which `ui.printPresetDialog {name? (a saved preset to edit) | preset? (the preset a new one starts
from)}` opens too: dialog `printPreset`, the Print dialog's settings fields plus `name`; `ui.dialog.confirm` runs
`print.presets.save` and returns to Print Presets (a new preset can't take a name in use). Delete, Import… and
Export… are `print.presets.delete`, `print.presets.import` and `print.presets.export`; `ui.dialog.confirm` closes.
Opening a `.vcprintpresets` file with `app.open` imports its presets.

View File

@ -1488,3 +1488,36 @@ 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"}}}
```
## 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}}}}}
```