mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
f1f7b1f945
commit
ffce1cf686
20 files changed
+1441
-60
No files matched your search
@@ -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"] }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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"}}}
|
||||
```
|
||||
Reference in new issue
Block a user