M4.78: File > Print opens the Print dialog and prints through the system

The Print dialog (Ctrl/Cmd+P) edits the document's print settings in the
sections General, Marks and Bleed, Output, Graphics, Color Management,
Advanced and Summary, with a preview of each printed page under the section
list (drag the art to move its placement). It drives print.setup,
print.preview and file.print; the preview pages now carry their transform,
printed area and trim box. Print sends the job's PDF to the chosen printer
through the new PrintService (lp on macOS/Linux, the shell print verb on
Windows, a hidden frame and the browser's print dialog on the web); without
one, or with PDF File, it saves the PDF. Done only keeps the settings.
UI commands file.print, print.printers and print.printerSetup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 16:30:35 +07:00
1 parent f1f7b1f945
commit ffce1cf686
20 files changed
+1441 -60

No files matched your search

+1 -1
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"] }
+64 -5
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(())
}
+3
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()
}
}
+207
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);
}
}
+1 -1
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 {
+2 -2
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
+2
View File
@@ -1210,6 +1210,8 @@ mod tests_previewbounds;
#[cfg(test)]
mod tests_print;
#[cfg(test)]
mod tests_printpreview;
#[cfg(test)]
mod tests_proxyitems;
#[cfg(test)]
mod tests_rastersettings;
+53
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}");
}
}
}
+13 -2
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();
+5
View File
@@ -41,6 +41,7 @@ pub mod pdf_presets;
pub mod place;
pub mod placement_options;
mod png_options;
pub mod print;
mod psd_options;
pub mod raster_effects;
pub mod recolor;
@@ -236,6 +237,7 @@ 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,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@@ -367,3 +369,6 @@ mod tests_save_for_web;
#[cfg(test)]
mod tests_psd;
#[cfg(test)]
mod tests_print;
+2 -9
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() {
+642
View File
@@ -0,0 +1,642 @@
//! File → Print: the Print dialog. The printer (and Setup…) on top, then the sections General,
//! Marks and Bleed, Output, Graphics, Color Management, Advanced and Summary, with a preview of the
//! printed page under the section list: the arrows step through the pages, dragging the art moves
//! its placement on the paper.
//!
//! The fields are the print settings (`print.setup`'s; sections are objects such as `marks`) plus
//! `printer` (a name from `print.printers`, "" the system's default), `toFile` (save the job as a
//! PDF instead) and UI-only `__` keys, so agents fill the dialog with `ui.dialog.set`. Print (OK)
//! keeps the settings with the document (`print.setup`) and prints them (`file.print`); Done
//! (`discard: true`, then confirm) only keeps them. The preview, the inks and the Summary come
//! from `print.preview`: the dialog lays nothing out itself.
use std::sync::{Arc, LazyLock};
use serde_json::{Value, json};
use vectorcraft_color::cms::Intent;
use vectorcraft_geom::Rect as DRect;
use vectorcraft_pdf::{
Choice, Emulsion, FontDownload, Media, Orientation, Origin, OutputMode, PrintArtboards, PrintImage, PrintLayers, PrintScaling, PrintSettings,
};
use super::save_pdf::{TOP_LABEL_WIDTH, choice, current_section, flag, get, heading, length, note, number, pick, row, row_with, set, text};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
pub(super) const KIND: &str = "print";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Print".into(), body, confirm, ok: Some("Print"), discard: Some("Done"), ..DialogSpec::FORM };
pub(super) const SECTIONS: [&str; 7] = ["General", "Marks and Bleed", "Output", "Graphics", "Color Management", "Advanced", "Summary"];
/// The default print settings as JSON: what a field an agent left out reads as.
static DEFAULTS: LazyLock<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).then(|| &*DEFAULTS)
}
/// The fields that aren't print settings.
const NOT_SETTINGS: [&str; 3] = ["printer", "toFile", "discard"];
/// The Printer list's entries besides the system's printers.
const DEFAULT_PRINTER: &str = "Default Printer";
const PDF_FILE: &str = "PDF File";
/// The preview's size in the section column.
const PREVIEW: egui::Vec2 = egui::vec2(super::save_pdf::LIST_WIDTH - 12.0, 168.0);
/// Open the Print dialog on the document's print settings and the system's default printer.
pub fn open(app: &mut VectorcraftApp) -> Result<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 Value::Object(mut fields) = serde_json::to_value(saved).map_err(|e| e.to_string())? else {
return Err("print settings are not an object".into());
};
let printers = crate::print::printers(app);
let list = printers["printers"].as_array().map(Vec::as_slice).unwrap_or_default();
let default = list.iter().find(|p| p["default"] == true).or(list.first()).and_then(|p| p["name"].as_str()).unwrap_or_default();
fields.insert("printer".into(), json!(default));
fields.insert("toFile".into(), json!(!printers["service"].as_bool().unwrap_or(false)));
fields.insert("__printers".into(), printers);
fields.insert("__section".into(), json!(SECTIONS[0]));
fields.insert("__sheet".into(), json!(0));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(Value::Null)
}
/// The print settings the dialog stands for (`print.setup`'s `settings`).
pub(super) fn settings(d: &Dialog) -> Value {
let mut p = form::params(d);
if let Some(o) = p.as_object_mut() {
for k in NOT_SETTINGS {
o.remove(k);
}
}
p
}
/// Print, or Done (`discard`): keep the settings with the document (one undo step when they
/// changed), then print them; settings that can't print keep the dialog open, unchanged.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
// Done's flag holds for this press only: the dialog may stay open.
if let Some(open) = app.ui.dialog.as_mut() {
open.fields.remove("discard");
}
let settings = settings(d);
let p = json!({ "settings": settings });
// Laid out first, so a bad range or overlap is refused before anything is kept.
app.session.execute("print.preview", &p).map_err(|e| e.to_string())?;
app.run("print.setup", p)?;
if d.bool("discard") {
app.ui.dialog = None;
return Ok(json!({ "settings": settings }));
}
let r = crate::print::run(app, &json!({ "settings": settings, "printer": d.str("printer"), "toFile": d.bool("toFile") }))?;
app.ui.dialog = None;
Ok(r)
}
// ---------- the dialog ----------
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
printer_row(app, ui, d);
ui.add_space(10.0);
let section = current_section(d, &SECTIONS);
let preview = preview_of(app, ui.ctx(), &settings(d));
ui.horizontal_top(|ui| {
super::save_pdf::section_frame(ui, |ui| {
super::save_pdf::section_list(ui, d, &SECTIONS, section);
ui.add_space(10.0);
page_preview(app, ui, d, &preview);
});
ui.add_space(14.0);
ui.vertical(|ui| {
ui.set_width(500.0);
ui.label(egui::RichText::new(section).font(theme::semibold(14.0)).color(t.text_strong));
egui::ScrollArea::vertical().id_salt(("print", section)).max_height(360.0).auto_shrink([false, false]).show(ui, |ui| match section {
"Marks and Bleed" => super::save_pdf::marks_and_bleeds(app, ui, d),
"Output" => output(ui, d, &preview),
"Graphics" => graphics(ui, d),
"Color Management" => color(ui, d),
"Advanced" => advanced(app, ui, d),
"Summary" => summary(ui, d, &preview),
_ => general(app, ui, d),
});
});
});
false
}
/// What the Printer list offers.
enum Destination {
Printer(String),
/// The system's default printer (the web: the browser's print dialog).
Default,
File,
}
/// The Printer row: the system's printers (or its default one), PDF File; Setup….
fn printer_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let printers = d.fields.get("__printers");
let names: Vec<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 = pv.as_ref().err().map_or("Nothing to print", |_| "Can't print these settings: see the Summary");
let font = egui::FontId::proportional(11.0);
let galley = ui.painter().layout(text.into(), font, t.text_dim, rect.width() - 16.0);
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text_dim);
return;
};
let size = egui::vec2(sheet["width"].as_f64().unwrap_or(0.0) as f32, sheet["height"].as_f64().unwrap_or(0.0) as f32);
let fit = ((rect.width() - 16.0) / size.x).min((rect.height() - 16.0) / size.y);
if !(fit.is_finite() && fit > 0.0) {
return;
}
let page = egui::Rect::from_center_size(rect.center(), size * fit);
let to_screen = |x: f64, y: f64| page.min + egui::vec2(x as f32, y as f32) * fit;
let painter = ui.painter();
painter.rect_filled(page.translate(egui::vec2(2.0, 2.0)), 0.0, egui::Color32::from_black_alpha(60));
painter.rect_filled(page, 0.0, egui::Color32::WHITE);
// Dragging moves the art (and its trim box) on the paper; the placement changes on release.
let tiling = choice::<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).
fn section_of(option: &str) -> (usize, bool) {
match option.split('.').next().unwrap_or_default() {
"marks" | "bleed" => (1, false),
"output" => (2, true),
"graphics" => (3, true),
"color" => (4, true),
"margin" => (5, false),
_ => (0, false),
}
}
/// `output.mode` → "Output › Mode", `placement.x` → "General › Placement › X".
fn option_label(option: &str) -> String {
let (section, own) = section_of(option);
let keys = option.split('.').skip(usize::from(own)).map(|k| form::humanize(k).trim_end_matches(':').to_string());
std::iter::once(SECTIONS.get(section).copied().unwrap_or_default().to_string()).chain(keys).collect::<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>) {
let t = Tokens::get(ui.ctx());
let v = match pv {
Ok(v) => v,
Err(e) => {
heading(ui, "Error");
ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text));
return;
}
};
heading(ui, "Pages");
let per_copy = v["sheets"].as_array().map_or(0, Vec::len);
ui.label(egui::RichText::new(format!("{} in all, {per_copy} per copy", v["pages"])).color(t.text));
heading(ui, "Options");
let mut changed = vec![];
vectorcraft_engine::cmd::fileio::pdf::changed("", &settings(d), &DEFAULTS, &mut changed);
changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()).0);
labelled(&mut changed);
if changed.is_empty() {
note(ui, "Every option is at its default.");
}
super::save_pdf::option_rows(ui, &changed, option_label);
heading(ui, "Warnings");
super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default());
}
+64 -37
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.");
}
+234
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);
}
+2 -2
View File
@@ -294,7 +294,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 +433,7 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params
/// that path (else a picked or suggested name) → (path, the command's result without the data).
/// The command runs before a path is asked for, so bad params never open a save dialog, except
/// for Export Selection without a `format`, which takes it from the picked path.
fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> {
pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> {
let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string));
if let Some(o) = params.as_object_mut()
&& o.get("format").is_none()
+4
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.
+26 -1
View File
@@ -503,6 +503,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…]}",
),
(
"file.print",
"Print…",
"Cmd+P",
"{} open the Print dialog (dialog `print`: the print.setup settings, printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}",
),
(
"print.printers",
"Printers",
"",
"{} → {printers: [{name, default}] (the system's printers), service (printing is available; else Print saves a PDF), setup (print.printerSetup can open the printer settings)}",
),
("print.printerSetup", "Printer Setup…", "", "{printer?} open the system's settings of the printer (the Print dialog's Setup…; desktop)"),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@@ -898,6 +911,17 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<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()),
}
}
_ => return None,
};
Some(r)
@@ -1135,6 +1159,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(),
"file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"css.copy" | "css.exportFile" => app.session.active().is_some(),
"print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()),
_ => true,
}
}
@@ -1213,7 +1238,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
),
c("File Info…", "file.info"),
Sep,
todos("Print…", "Cmd+P"),
c("Print…", "file.print"),
],
),
(
+85
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)
}
+13
View File
@@ -392,3 +392,16 @@ the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorMo
document starts on `cmyk`), `layers` (true: Write Layers; false: Flat Image, whose background list starts at White),
`maxEditability` and `hiddenLayers` (with layers) and `embedIcc`. `ui.dialog.set` the fields and `ui.dialog.confirm`
writes the file(s) with `document.export`.
Print: File → Print… (`file.print` with no params, Ctrl/Cmd+P) opens the `print` dialog on the document's print
settings. Its fields are the `print.setup` settings (`copies`, `artboards`, `range`, `media`, `orientation`,
`placement`, `scaling`, `marks`, `bleed`, `output`, `graphics`, `color`, `margin`…; a section is an object, as
`{field: "marks", value: {"trim": true}}`) plus `printer` (a name from `print.printers`, `""` the system's default) and
`toFile` (save the job as a PDF), and the UI-only `__section` (General, Marks and Bleed, Output, Graphics, Color
Management, Advanced, Summary) and `__sheet` (the page the preview shows, 0-based). `ui.dialog.confirm` (Print) keeps
the settings with the document (`print.setup`, one undo step) and prints them: to the printer through the system's
print queue (the web: the browser's print dialog), else, with `toFile` or no printing available, a save panel for the
PDF. With `discard: true` it is Done: it only keeps the settings. Settings that can't print (a missing artboard, a
range of no tiles) answer with an error and keep the dialog open. `file.print {settings?, printer?, toFile?, path?}`
prints without the dialog; `print.printers` lists the printers and `print.printerSetup {printer?}` opens their system
settings (Setup…).
+18
View File
@@ -1488,3 +1488,21 @@ drawn. Files are at most 30000 pixels a side; PSD files don't open in VectorCraf
{"name":"export","arguments":{"path":"/tmp/poster.psd","options":{"ppi":300,"maxEditability":true}}}
{"name":"run_command","arguments":{"command":"document.export","params":{"format":"psd","layers":false,"colorModel":"cmyk"}}}
```
## Printing
`print.setup {settings?}` keeps print settings with the document (one undo step; without `settings` it answers the
current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, marks and bleed, composite or
separations output, graphics and colour management options of File › Print. `print.preview {settings?}` lays the job
out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the `transform` from the
document onto the page (pt, y down), the document `area` it prints and its `trim` box on the page, plus `tiles`, the
`inks` of a separation and `warnings`. `file.print {settings?, path?}` writes the job as a print-ready PDF (one page per
sheet, tile, ink and copy); headless it answers `dataBase64`. In the app, `file.print` with no params opens the Print
dialog, and with params it prints through the system (`printer`: a name from `print.printers`, default the system's
default printer) or, with `toFile`, a `path` or no printing available, saves the PDF.
```json
{"name":"run_command","arguments":{"command":"print.setup","params":{"settings":{"media":"a4","scaling":"fit","marks":{"trim":true}}}}}
{"name":"run_command","arguments":{"command":"print.preview","params":{"settings":{"copies":2,"output":{"mode":"separations"}}}}}
{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf"}}}
```