diff --git a/Cargo.lock b/Cargo.lock index 5d950f640..c49807145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9957,7 +9957,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0" +source = "git+https://github.com/dotCooCoo/winprint.rs?rev=72951d7455ebcab569fbd9386c513d3d9f53fb02#72951d7455ebcab569fbd9386c513d3d9f53fb02" dependencies = [ "flate2", "fmt-derive", diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index b9ae1fdab..50fbb43d6 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -34,8 +34,10 @@ log = "0.4" # storytold/pdfcraft#4941), writes QName values as prefix:local so drivers accept the merged # ticket (winprint.rs#12, storytold/pdfcraft#3823), and reads whitespace-padded integers in # PrintCapabilities (winprint.rs#3, storytold/pdfcraft#2430). Pinned to an immutable revision; -# review before bumping. -winprint = { git = "https://github.com/storytold/winprint.rs", rev = "bc06a7c48b768eb27a828c7986f6d2868b245ee0", default-features = false } +# review before bumping. The settings chosen in a driver's own dialog become a ticket through +# `PrintTicket::from_dev_mode`: until it is merged into storytold/winprint.rs, the pin is the +# fork commit that adds it on top of storytold/winprint.rs main (e90c444). +winprint = { git = "https://github.com/dotCooCoo/winprint.rs", rev = "72951d7455ebcab569fbd9386c513d3d9f53fb02", default-features = false } # GetDefaultPrinter (MIT). winsafe = { version = "0.0.29", default-features = false, features = ["winspool"] } # The job file's private folder. diff --git a/crates/print/README.md b/crates/print/README.md index 47cfb5f40..eb7af885e 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -10,7 +10,8 @@ let receipt = craft_print::submit(&pdf_bytes, &job)?; The caller generates the PDF, checks document permissions, and handles page selection, imposition, marks and bleed. This crate discovers printers, exposes CUPS driver options, -opens Windows printer preferences and submits jobs with copies, collation, duplex and colour. +opens Windows printer preferences, takes a Windows driver's own settings for one job, and +submits jobs with copies, collation, duplex and color. `command_preview` returns diagnostics without creating files or starting a process. - **macOS/Linux:** CUPS `lpstat`, `lpoptions` and `lp`. Jobs stream through stdin. Exact-size @@ -142,9 +143,49 @@ ticket names no colour, so the colour in the user's printing preferences (Proper Windows Settings) applies, black and white included. Single-page or single-copy jobs have no collating order to check. +`driver_settings_dialog(&mut job, parent)` opens the job printer's driver dialog +([`DocumentProperties`](https://learn.microsoft.com/en-us/windows/win32/printdocs/documentproperties)), +modal to the `parent` window handle, for that one job: options craft-print does not set, such +as media type, print quality and the driver's own options. The dialog opens on the job's +earlier choices in it for the same printer, without the ones the job decides (below), when the +driver still reads them, else on the settings the driver gives a new job; either way with the +job's copies and collation. OK stores the settings in `Job::driver_settings` for that printer. +Cancel leaves the job unchanged, and so do settings the dialog returns exactly as it opened on +them: some drivers return OK from Cancel with the settings they were given. Earlier settings +the driver can no longer read are replaced even then. Some drivers read settings that set no +fields (a DEVMODE whose [`dmFields`](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodew) is 0 +and that has no private section) without an error instead of refusing them; such settings stay +on the job after Cancel, and after OK with nothing changed. craft-print keeps the settings in +the job only and does not save them as the printer's defaults (`open_printer_preferences` does +that). When the job is printed, Windows converts the settings to a PrintTicket +([`PTConvertDevModeToPrintTicket`](https://learn.microsoft.com/en-us/windows/win32/api/prntvpt/nf-prntvpt-ptconvertdevmodetoprintticket)), +craft-print removes from it what the job's own ticket decides, and merges the job's ticket +after it. The dialog's copies, collation, paper, orientation and scaling are removed, and so +are its pages per sheet, binding (booklets included) and poster features. Its two-sided choice +is removed when `duplex` is `Some`, and its color when grayscale is on. The dialog does not +open on the job's two-sided or grayscale choice, so when the job later leaves that choice open, +the dialog's settings decide it. The driver's other options, vendor options and private +settings included, are kept, so a driver that also keeps pages per sheet in its private +settings can still apply it from there. The dialog's borderless choice is kept too, and on a +driver that enlarges the page for borderless printing it changes the printed size. The checks +above still run on the ticket the driver validates; they read the validated ticket, not the +driver's private settings. The job is refused when the driver replaces the dialog's media type, +resolution or print quality while it validates the job's ticket, which it can do when one +conflicts with the job's paper. Settings are used only on the printer whose name they were +chosen for: Windows may replace settings meant for another printer with defaults. +`command_preview` marks them as not used when the job, or the default printer for a job that +names none, is another printer. It does not ask the driver to read them, and it lists two-sided +printing and color that the job leaves open as the dialog's settings or the default. An error +names the step that failed: the printer, reading the driver's settings, the dialog (a driver +without one), the driver refusing the settings, or the driver replacing one of them. +`HAS_DRIVER_SETTINGS_DIALOG` is false and the call returns an error on other platforms, and CUPS +jobs ignore `driver_settings`. `examples/driver_settings_dialog.rs` opens the dialog for a +printer named on the command line and prints a one-page test sheet with the settings chosen. + `cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal), printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that -need no printer: the PrintTicket, the checks on the driver's validated ticket and capabilities, +need no printer: the PrintTicket, what is removed from the driver dialog's settings, the +dialog's Cancel, the checks on the driver's validated ticket and capabilities, paper matching, job file names, and the PDF sheet reader and its update (classic and compressed cross-references, turning, reframing, refusal, and thousands of truncated and corrupted files that must fail without a panic). On Windows it also submits a mixed-orientation exact-size job @@ -152,8 +193,9 @@ and a shrink-to-fit job to a temporary paused queue (inbox "Generic / Text Only" `LPT1:`) and checks each arrives as one job with all its sheets. The queue portion explicitly skips if the driver or administrative rights are absent. Windows CI installs the inbox test driver and sets `CRAFT_PRINT_REQUIRE_QUEUE=1`, making queue preparation/submission mandatory on -its runner. Two ignored tests check a real printer by hand: `an_installed_driver_validates_the_ticket` -(`CRAFT_PRINT_TEST_PRINTER`; sends no job) and `an_installed_printer_prints_a_file` +its runner. Ignored tests check a real printer by hand: `an_installed_driver_validates_the_ticket` +and `the_driver_dialogs_settings_leave_the_jobs_own_options` (`CRAFT_PRINT_TEST_PRINTER`; send no +job) and `an_installed_printer_prints_a_file` (`CRAFT_PRINT_TEST_PRINTER`, `CRAFT_PRINT_TEST_FILE`, optional `CRAFT_PRINT_TEST_SCALING=shrink` and `CRAFT_PRINT_TEST_DUPLEX=long`). Physical printer fidelity remains a hardware check. diff --git a/crates/print/examples/driver_settings_dialog.rs b/crates/print/examples/driver_settings_dialog.rs new file mode 100644 index 000000000..94c8b48bd --- /dev/null +++ b/crates/print/examples/driver_settings_dialog.rs @@ -0,0 +1,52 @@ +//! Open a printer driver's own settings dialog for one job, then print a one-page Letter test +//! sheet with the settings chosen there (Windows). +//! +//! `cargo run -p craft-print --example driver_settings_dialog -- "Printer name"` +//! +//! Change an option in the dialog and choose OK to print the sheet. Cancel, or OK with nothing +//! changed, prints nothing: some drivers return OK from Cancel, so the two look the same. With +//! `--preview` the job is described instead of sent. + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let printer = args.next().ok_or("usage: driver_settings_dialog [--preview]")?; + let preview = args.next().as_deref() == Some("--preview"); + let mut job = craft_print::Job { printer: Some(printer), title: "Driver settings test".into(), ..craft_print::Job::default() }; + if !craft_print::driver_settings_dialog(&mut job, None)? { + println!("canceled or unchanged: nothing was printed"); + return Ok(()); + } + if let Some(settings) = &job.driver_settings { + println!("settings for {}: {} bytes", settings.printer(), settings.dev_mode().as_bytes().len()); + } + if preview { + println!("{}", craft_print::command_preview(&job)?.join(" ")); + return Ok(()); + } + println!("{}", craft_print::submit(&test_sheet(), &job)?); + Ok(()) +} + +/// A one-page Letter PDF: a black square near each corner and a frame half an inch in. +fn test_sheet() -> Vec { + let content = "0 0 0 rg 36 36 36 36 re f 540 36 36 36 re f 36 720 36 36 re f 540 720 36 36 re f 0 0 0 RG 2 w 36 36 540 720 re S"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut pdf = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", offsets.len() + 1).as_bytes()); + for offset in &offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes()); + pdf +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 7793c3a7b..dcc9c53f9 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -93,6 +93,46 @@ pub struct Job { /// The printer driver's own job options (a PPD keyword and the chosen value), such as the /// paper tray or paper type; see [`printer_options`]. CUPS only. pub options: Vec<(String, String)>, + /// The settings chosen in the Windows printer driver's own dialog ([`driver_settings_dialog`]), + /// for this job only: media type, print quality and the driver's other options. The job's + /// own copies, collation, paper, orientation and scaling win over them, and so do `duplex` + /// when it is `Some` and `grayscale` when it is on; the dialog's pages per sheet, binding and + /// poster features are removed from them. A job printed on a printer of another name than the + /// one they were chosen for prints without them. Windows only; CUPS ignores them. + pub driver_settings: Option, +} + +/// The settings a Windows printer driver's own dialog returned for one printer +/// ([`driver_settings_dialog`]): its DEVMODE, private part included. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DriverSettings { + printer: String, + dev_mode: rgb::DevMode, +} + +impl DriverSettings { + /// Settings for `printer`, for example ones an app kept from an earlier + /// [`driver_settings_dialog`] (`rgb::DevMode::to_hex` and `from_hex`). + pub fn new(printer: impl Into, dev_mode: rgb::DevMode) -> Self { + Self { printer: printer.into(), dev_mode } + } + + /// The printer the settings were chosen for. + pub fn printer(&self) -> &str { + &self.printer + } + + /// The driver's DEVMODE, as its dialog returned it. + pub fn dev_mode(&self) -> &rgb::DevMode { + &self.dev_mode + } +} + +/// The job's [`Job::driver_settings`] when they were chosen for the printer named exactly +/// `printer`. Settings chosen for a printer of another name are left out, since Windows may +/// replace settings meant for another printer with defaults. +pub(crate) fn settings_for<'a>(job: &'a Job, printer: &str) -> Option<&'a DriverSettings> { + job.driver_settings.as_ref().filter(|settings| settings.printer() == printer) } impl Default for Job { @@ -107,6 +147,7 @@ impl Default for Job { grey_pages: false, title: "Craft".into(), options: Vec::new(), + driver_settings: None, } } } @@ -422,6 +463,87 @@ pub fn open_printer_preferences(printer: &str) -> Result<(), Error> { } } +/// Whether [`driver_settings_dialog`] can show the driver's own dialog for one job (Windows). +pub const HAS_DRIVER_SETTINGS_DIALOG: bool = cfg!(windows); + +/// Show the job printer's driver dialog (Windows: `DocumentProperties`), modal to `parent` (a +/// window handle), for this job only. The dialog opens on the job's [`Job::driver_settings`] when +/// they were chosen for this printer and the driver still reads them, without what the job +/// decides itself (see [`Job::driver_settings`]), else on the settings the driver gives a new +/// job, with the job's copies and collation. `Ok(true)`: the user chose OK and +/// `job.driver_settings` holds the new settings. `Ok(false)`: the user canceled, or the dialog +/// returned the settings it opened on unchanged (some drivers return OK from Cancel), and the job +/// is unchanged. The settings are kept in the job only; unlike [`open_printer_preferences`], +/// craft-print does not save them as the printer's defaults. +/// +/// The paper, orientation and scaling the job's ticket sets are its own, whatever the dialog +/// shows. An error says which step failed: the printer, reading the driver's settings, or the +/// dialog itself (a driver without one). Other platforms return an error. +pub fn driver_settings_dialog(job: &mut Job, parent: Option) -> Result { + #[cfg(windows)] + { + dialog_via(job, windows::default_printer(), windows::settings_seed, |printer, seed| rgb::settings_dialog(printer, Some(seed), parent)) + } + #[cfg(not(windows))] + { + let _ = (job, parent); + Err(Error::Spool("the printer driver's own settings open only on Windows".into())) + } +} + +/// [`driver_settings_dialog`] with the default printer, the settings the dialog opens on +/// (`seed`) and the dialog supplied, so tests can stand in for the driver. A job's printer is +/// used as it is named, an empty name included, as when the job is printed. +#[cfg(any(test, windows))] +pub(crate) fn dialog_via( + job: &mut Job, + default: Option, + seed: impl FnOnce(&Job, &str) -> Result, + dialog: impl FnOnce(&str, &rgb::DevMode) -> rgb::Result>, +) -> Result { + let printer = match &job.printer { + Some(printer) => printer.clone(), + None => default.filter(|p| !p.is_empty()).ok_or_else(|| Error::Spool("there is no default printer; choose a printer".into()))?, + }; + let seed = seed(job, &printer)?; + match dialog(&printer, &seed.dev_mode) { + // Some drivers return OK from Cancel with the settings they were given, so settings that + // come back unchanged leave the job as it is, unless they replace earlier settings the + // driver can no longer read. + Ok(Some(dev_mode)) if dev_mode == seed.dev_mode && !seed.replaces_unreadable => Ok(false), + Ok(Some(dev_mode)) => { + job.driver_settings = Some(DriverSettings::new(printer, dev_mode)); + Ok(true) + } + Ok(None) => Ok(false), + Err(e) => Err(Error::Spool(format!("the printer driver's settings dialog could not be shown: {e}"))), + } +} + +/// The settings [`driver_settings_dialog`] opens the driver's dialog on. +#[cfg(any(test, windows))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Seed { + pub dev_mode: rgb::DevMode, + /// The job has settings for this printer that the driver could not read, so the seed was + /// made without them. + pub replaces_unreadable: bool, +} + +/// The DEVMODE at the start of `bytes`, `dmSize + dmDriverExtra` long: the buffer +/// `PrintTicket::to_dev_mode` returns is larger than the settings it holds, and +/// `rgb::DevMode::from_bytes` takes only the exact length. `None` when `bytes` is shorter than its +/// sizes say. +#[cfg(any(test, windows))] +pub(crate) fn whole_dev_mode(mut bytes: Vec) -> Option> { + let word = |at: usize| bytes.get(at..at + 2).map(|w| usize::from(u16::from_le_bytes([w[0], w[1]]))); + let size = word(68)? + word(70)?; + (size <= bytes.len()).then(|| { + bytes.truncate(size); + bytes + }) +} + /// `lpstat`, forced to print untranslated messages so [`parse_lpstat`] and [`parse_lpstat_e`] can /// read them. /// @@ -898,9 +1020,13 @@ pub(crate) fn job_message(stdout: &[u8]) -> String { s.strip_suffix("(0 file(s))").map_or(s, str::trim_end).to_string() } -/// [`command_preview`] for the Windows backend. -fn windows_preview(job: &Job) -> Vec { - vec![ +/// [`command_preview`] for the Windows backend; `default` is the default printer's name. +fn windows_preview(job: &Job, default: Option<&str>) -> Vec { + // The driver's dialog decides what the job leaves open, when its settings are for the printer + // the job prints on and hold that choice; the preview does not ask the driver to read them. + let target = job.printer.as_deref().or(default); + let chosen = target.is_some_and(|printer| settings_for(job, printer).is_some()); + let mut preview = vec![ "xps-print".into(), format!("printer={}", job.printer.as_deref().unwrap_or("(default)")), format!("copies={}", job.copies.clamp(1, 999)), @@ -908,6 +1034,7 @@ fn windows_preview(job: &Job) -> Vec { format!( "duplex={}", match job.duplex { + None if chosen => "(driver settings or queue default)", None => "(queue default)", Some(Duplex::Off) => "one-sided", Some(Duplex::LongEdge) => "two-sided-long-edge", @@ -921,8 +1048,24 @@ fn windows_preview(job: &Job) -> Vec { Scaling::ShrinkToFit => "shrink-to-fit", } ), - format!("color={}", if job.grayscale { "monochrome" } else { "(driver default)" }), - ] + format!( + "color={}", + match (job.grayscale, chosen) { + (true, _) => "monochrome", + (false, true) => "(driver settings or driver default)", + (false, false) => "(driver default)", + } + ), + ]; + if let Some(settings) = &job.driver_settings { + let unused = match (chosen, target) { + (true, _) => "", + (false, Some(_)) => " (another printer: not used)", + (false, None) => " (no default printer: not used)", + }; + preview.push(format!("driver-settings={}{unused}", settings.printer())); + } + preview } /// The platform backend. No backend is available in the browser. @@ -949,7 +1092,13 @@ pub const fn backend() -> Option { pub fn command_preview(job: &Job) -> Result, Error> { match backend() { Some(Backend::Cups) => Ok(std::iter::once("lp".to_string()).chain(lp_args(job)).collect()), - Some(Backend::WindowsPdf) => Ok(windows_preview(job)), + Some(Backend::WindowsPdf) => { + #[cfg(windows)] + let default = windows::default_printer(); + #[cfg(not(windows))] + let default: Option = None; + Ok(windows_preview(job, default.as_deref())) + } None => Err(Error::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into())), } } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 174b8b56a..fbb0e7892 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -433,3 +433,202 @@ fn queue_duplex_defaults_and_explicit_scaling_reach_the_spooler() { assert!(shrink.iter().any(|a| a == "print-scaling=auto-fit")); assert!(!shrink.iter().any(|a| a.starts_with("fit-to-page="))); } + +/// Driver settings `len` bytes long whose header says `size` + `extra`, filled with `fill`. +fn dev_mode_bytes(size: u16, extra: u16, len: usize, fill: u8) -> Vec { + let mut bytes = vec![fill; len]; + bytes[68..70].copy_from_slice(&size.to_le_bytes()); + bytes[70..72].copy_from_slice(&extra.to_le_bytes()); + bytes +} + +fn dev_mode(fill: u8) -> rgb::DevMode { + rgb::DevMode::from_bytes(dev_mode_bytes(220, 8, 228, fill)).unwrap() +} + +fn seed(fill: u8) -> spool::Seed { + spool::Seed { dev_mode: dev_mode(fill), replaces_unreadable: false } +} + +#[test] +fn canceling_the_drivers_dialog_leaves_the_job_unchanged() { + let earlier = DriverSettings::new("Plotter", dev_mode(1)); + let mut job = Job { printer: Some("Plotter".into()), driver_settings: Some(earlier.clone()), ..Job::default() }; + let before = job.clone(); + let mut opened_on = None; + let changed = spool::dialog_via( + &mut job, + None, + |job, printer| { + assert_eq!(printer, "Plotter"); + Ok(spool::Seed { dev_mode: job.driver_settings.as_ref().unwrap().dev_mode().clone(), replaces_unreadable: false }) + }, + |printer, seed| { + opened_on = Some((printer.to_string(), seed.clone())); + Ok(None) + }, + ); + assert_eq!(changed, Ok(false)); + assert_eq!(job, before, "Cancel changes nothing"); + assert_eq!(opened_on, Some(("Plotter".to_string(), dev_mode(1))), "the dialog opened on the settings the seed gave"); +} + +#[test] +fn settings_the_dialog_returns_unchanged_leave_the_job_unchanged() { + // Some drivers return OK from Cancel with the settings they were given. + let mut job = Job { printer: Some("Plotter".into()), driver_settings: Some(DriverSettings::new("Plotter", dev_mode(1))), ..Job::default() }; + let before = job.clone(); + let changed = spool::dialog_via(&mut job, None, |_, _| Ok(seed(3)), |_, seed| Ok(Some(seed.clone()))); + assert_eq!(changed, Ok(false)); + assert_eq!(job, before, "the earlier settings stay"); + + let mut fresh = Job::default(); + assert_eq!(spool::dialog_via(&mut fresh, Some("Office".into()), |_, _| Ok(seed(3)), |_, seed| Ok(Some(seed.clone()))), Ok(false)); + assert_eq!(fresh, Job::default()); + + // Earlier settings the driver can no longer read are replaced, even by unchanged ones, so the + // job can print again. + let mut unreadable = before.clone(); + let opened = spool::Seed { dev_mode: dev_mode(3), replaces_unreadable: true }; + let changed = spool::dialog_via(&mut unreadable, None, |_, _| Ok(opened.clone()), |_, seed| Ok(Some(seed.clone()))); + assert_eq!(changed, Ok(true)); + assert_eq!(unreadable.driver_settings, Some(DriverSettings::new("Plotter", dev_mode(3)))); +} + +#[test] +fn the_drivers_dialog_sets_the_jobs_settings_for_its_printer() { + let mut job = Job { copies: 2, ..Job::default() }; + let mut opened_on = None; + let changed = spool::dialog_via( + &mut job, + Some("Office".into()), + |_, _| Ok(seed(1)), + |printer, _| { + opened_on = Some(printer.to_string()); + Ok(Some(dev_mode(2))) + }, + ); + assert_eq!(changed, Ok(true)); + assert_eq!(opened_on.as_deref(), Some("Office")); + assert_eq!(job.driver_settings, Some(DriverSettings::new("Office", dev_mode(2))), "a job for the default printer names it"); + assert_eq!(job.printer, None, "the job still prints on the default printer"); + assert_eq!(job.copies, 2); + + let mut named = Job { printer: Some("Plotter".into()), ..Job::default() }; + assert_eq!(spool::dialog_via(&mut named, Some("Office".into()), |_, _| Ok(seed(1)), |_, _| Ok(Some(dev_mode(2)))), Ok(true)); + assert_eq!(named.driver_settings, Some(DriverSettings::new("Plotter", dev_mode(2))), "the job's own printer, not the default"); +} + +#[test] +fn a_failed_step_is_named_and_leaves_the_job_unchanged() { + let job = Job { driver_settings: Some(DriverSettings::new("Office", dev_mode(1))), ..Job::default() }; + + let mut no_printer = job.clone(); + let mut asked = false; + let result = spool::dialog_via( + &mut no_printer, + None, + |_, _| Ok(seed(1)), + |_, _| { + asked = true; + Ok(None) + }, + ); + assert_eq!(result, Err(Error::Spool("there is no default printer; choose a printer".into()))); + assert!(!asked, "no dialog without a printer"); + assert_eq!(no_printer, job); + + // An empty printer name is looked up as it is, as when the job is printed, not replaced by + // the default printer. + let mut unnamed = Job { printer: Some(String::new()), ..job.clone() }; + let mut looked_up = None; + let result = spool::dialog_via( + &mut unnamed, + Some("Office".into()), + |_, printer| { + looked_up = Some(printer.to_string()); + Err(Error::Spool("the printer is not available".into())) + }, + |_, _| Ok(None), + ); + assert_eq!(result, Err(Error::Spool("the printer is not available".into()))); + assert_eq!(looked_up.as_deref(), Some("")); + + let mut unreadable = job.clone(); + let result = spool::dialog_via( + &mut unreadable, + Some("Office".into()), + |_, _| Err(Error::Spool("settings unreadable".into())), + |_, _| { + asked = true; + Ok(None) + }, + ); + assert_eq!(result, Err(Error::Spool("settings unreadable".into()))); + assert!(!asked, "no dialog without settings to open it on"); + assert_eq!(unreadable, job); + + let mut no_dialog = job.clone(); + let result = + spool::dialog_via(&mut no_dialog, Some("Office".into()), |_, _| Ok(seed(1)), |_, _| Err(rgb::PrintError::System("no dialog".into()))); + assert_eq!(result, Err(Error::Spool("the printer driver's settings dialog could not be shown: no dialog".into()))); + assert_eq!(no_dialog, job); +} + +#[cfg(not(windows))] +#[test] +fn the_driver_settings_dialog_opens_only_on_windows() { + let mut job = Job::default(); + const { assert!(!HAS_DRIVER_SETTINGS_DIALOG) }; + assert!(driver_settings_dialog(&mut job, None).is_err()); + assert_eq!(job, Job::default()); +} + +#[test] +fn driver_settings_apply_only_on_the_printer_they_were_chosen_for() { + let job = Job { driver_settings: Some(DriverSettings::new("Plotter", dev_mode(1))), ..Job::default() }; + assert_eq!(spool::settings_for(&job, "Plotter"), job.driver_settings.as_ref()); + assert_eq!(spool::settings_for(&job, "Office"), None, "another printer prints without them"); + assert_eq!(spool::settings_for(&job, "plotter"), None, "printer names are matched exactly, as when a job picks its printer"); + assert_eq!(spool::settings_for(&Job::default(), "Plotter"), None); +} + +#[test] +fn a_drivers_output_buffer_is_cut_to_its_settings() { + assert_eq!(spool::whole_dev_mode(dev_mode_bytes(220, 8, 9000, 0)).map(|b| b.len()), Some(228)); + assert_eq!(spool::whole_dev_mode(dev_mode_bytes(220, 0, 220, 0)).map(|b| b.len()), Some(220)); + assert_eq!(spool::whole_dev_mode(dev_mode_bytes(220, 8, 227, 0)), None, "shorter than it says"); + assert_eq!(spool::whole_dev_mode(vec![0; 71]), None); +} + +#[test] +fn driver_settings_reach_only_the_windows_job() { + let job = Job { driver_settings: Some(DriverSettings::new("Office (east)", dev_mode(1))), ..Job::default() }; + assert_eq!(lp_args(&job), lp_args(&Job::default()), "CUPS ignores them"); + assert_eq!( + spool::windows_preview(&job, Some("Office (east)")), + [ + "xps-print", + "printer=(default)", + "copies=1", + "collate=true", + "duplex=(driver settings or queue default)", + "scaling=exact", + "color=(driver settings or driver default)", + "driver-settings=Office (east)" + ] + ); + let set = Job { duplex: Some(Duplex::Off), grayscale: true, ..job.clone() }; + let preview = spool::windows_preview(&set, Some("Office (east)")); + assert!(preview.contains(&"duplex=one-sided".to_string()) && preview.contains(&"color=monochrome".to_string()), "{preview:?}"); + let other = spool::windows_preview(&Job { printer: Some("Office (west)".into()), ..job.clone() }, Some("Office (east)")); + assert_eq!(other[4], "duplex=(queue default)"); + assert_eq!(other[6], "color=(driver default)"); + assert_eq!(other[7], "driver-settings=Office (east) (another printer: not used)"); + // A job for the default printer prints on the printer that is the default now. + let moved = spool::windows_preview(&job, Some("Office (west)")); + assert_eq!(moved[4], "duplex=(queue default)"); + assert_eq!(moved[7], "driver-settings=Office (east) (another printer: not used)"); + let none = spool::windows_preview(&job, None); + assert_eq!(none[7], "driver-settings=Office (east) (no default printer: not used)"); +} diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 849ec8472..ddf2c3955 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -105,6 +105,67 @@ pub(crate) fn ticket_xml_with(job: &Job, landscape: bool, document_only: bool, g ) } +/// Whether the job's own ticket decides the Print Schema feature or parameter `name`, so the +/// driver dialog's value for it is left out ([`driver_settings_xml`]): copies, collation, paper, +/// orientation and scaling always; two-sided when the job sets it; color when it asks for grayscale. +/// Pages per sheet, binding (booklets included) and posters are left out too: they change the +/// size and the number of the sheets, which the job's own size and its copies rely on. The +/// parameters of these features start with their feature's name. Only the ticket's features are +/// removed: a driver that also keeps one of them in its private settings, which are kept, can +/// still apply it from there. +fn job_decides(name: &str, job: &Job) -> bool { + matches!( + name, + "JobCopiesAllDocuments" | "DocumentCopiesAllPages" | "PageCopies" | "JobCollateAllDocuments" | "DocumentCollate" | "PageOrientation" + ) || ["PageMediaSize", "PageScaling", "JobNUpAllDocumentsContiguously", "DocumentNUp", "JobBindAllDocuments", "DocumentBinding", "PagePoster"] + .iter() + .any(|feature| name.starts_with(feature)) + || (job.duplex.is_some() && matches!(name, "JobDuplexAllDocumentsContiguously" | "DocumentDuplex")) + || (job.grayscale && name == "PageOutputColor") +} + +/// `xml` without the Print Schema features and parameter initializers directly under its root +/// whose names `drop` picks. Everything else, vendor features and the driver's private settings +/// included, is kept as it is written. +fn without_schema_names(xml: &str, drop: impl Fn(&str) -> bool) -> Result { + let unreadable = || spool_error("the printer driver's settings are not a PrintTicket it can read; choose them again in its dialog"); + let document = roxmltree::Document::parse(xml).map_err(|_| unreadable())?; + let root = document.root_element(); + if !root.has_tag_name((NS_PSF, "PrintTicket")) { + return Err(unreadable()); + } + let mut out = String::with_capacity(xml.len()); + let mut copied = 0; + for node in root.children().filter(|n| n.has_tag_name((NS_PSF, "Feature")) || n.has_tag_name((NS_PSF, "ParameterInit"))) { + let Some(value) = node.attribute("name") else { continue }; + let (prefix, local) = value.split_once(':').map_or((None, value), |(p, n)| (Some(p), n)); + if node.lookup_namespace_uri(prefix) == Some(NS_PSK) && drop(local) { + let range = node.range(); + out.push_str(xml.get(copied..range.start).ok_or_else(unreadable)?); + copied = range.end; + } + } + out.push_str(xml.get(copied..).ok_or_else(unreadable)?); + Ok(out) +} + +/// The ticket of the settings chosen in the printer driver's dialog, as Windows converted them, +/// without what the job's own ticket decides ([`job_decides`]): merged before the job's ticket, +/// they keep the driver's other options (media type, quality, its private settings) and cannot +/// pull the paper, the copies or a two-sided or grayscale choice of the job away from it. +pub(crate) fn driver_settings_xml(converted: &str, job: &Job) -> Result { + without_schema_names(converted, |name| job_decides(name, job)) +} + +/// The job's options for the printer driver's dialog to open on: its copies and collation from +/// [`ticket_xml_with`]. The orientation and scaling come from the sheets when the job is printed. +/// The job's two-sided and grayscale choices are left out: the settings the dialog returns are +/// kept, and a value of the job's in them would apply as the dialog's own once the job leaves +/// that choice open. +pub(crate) fn dialog_xml(job: &Job) -> Result { + without_schema_names(&ticket_xml_with(job, false, false, Grey::Monochrome), |name| !matches!(name, "JobCopiesAllDocuments" | "DocumentCollate")) +} + /// Standard paper sizes (points, portrait) and the names drivers give them. A driver can list /// one size twice, a plotter's "ARCH D" next to a custom 24 × 36 in entry; the named one is /// preferred (from storytold/pdfcraft#599). @@ -401,6 +462,98 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Of Ok(()) } +/// The dialog features whose option a driver may replace while it validates the job's ticket, +/// and the words an error names them with. +const CHOSEN_OPTIONS: [(&str, &str); 3] = [("PageMediaType", "media type"), ("PageResolution", "resolution"), ("PageOutputQuality", "print quality")]; + +/// A qualified name as its namespace and local name. +type Expanded = (Option, String); + +/// The namespace and local name of a QName attribute value of `node`. +fn expanded(node: roxmltree::Node<'_, '_>, value: &str) -> Expanded { + let (prefix, local) = value.split_once(':').map_or((None, value), |(p, n)| (Some(p), n)); + (node.lookup_namespace_uri(prefix).map(str::to_string), local.to_string()) +} + +/// The option of a feature: its name, when it has one, and the value of each scored property +/// that holds one or refers to a parameter the ticket sets (a resolution's `ResolutionX` and +/// `ResolutionY`), trimmed. A QName value (`xsi:type="xsd:QName"`) is kept as its namespace and +/// local name, an integer as its number, any other value as its text. +struct FeatureOption { + name: Option, + scored: Vec<(Expanded, Expanded)>, +} + +/// The option of the Print Schema feature `feature` directly under the ticket's root; `None` +/// when the ticket has no such feature or the feature has no option. +fn feature_option(document: &roxmltree::Document<'_>, feature: &str) -> Option { + let option = document + .root_element() + .children() + .find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, feature))? + .children() + .find(|n| n.has_tag_name((NS_PSF, "Option")))?; + let scored = option + .children() + .filter(|n| n.has_tag_name((NS_PSF, "ScoredProperty"))) + .filter_map(|n| { + let value = match n.children().find(|v| v.has_tag_name((NS_PSF, "Value")) || v.has_tag_name((NS_PSF, "ParameterRef")))? { + value if value.has_tag_name((NS_PSF, "Value")) => value, + reference => { + let parameter = expanded(reference, reference.attribute("name")?); + document + .root_element() + .children() + .filter(|p| p.has_tag_name((NS_PSF, "ParameterInit"))) + .find(|p| p.attribute("name").is_some_and(|name| expanded(*p, name) == parameter))? + .children() + .find(|v| v.has_tag_name((NS_PSF, "Value")))? + } + }; + let text = value.text()?.trim(); + let value = match text.parse::() { + _ if is_qname_value(value) => expanded(value, text), + Ok(number) => (None, number.to_string()), + Err(_) => (None, text.to_string()), + }; + Some((expanded(n, n.attribute("name")?), value)) + }) + .collect(); + Some(FeatureOption { name: option.attribute("name").map(|value| expanded(option, value)), scored }) +} + +/// Whether `validated` is the option `chosen` names: the same name when `chosen` has one, and +/// the same value for each of `chosen`'s scored properties. +fn keeps_option(chosen: &FeatureOption, validated: Option<&FeatureOption>) -> bool { + validated.is_some_and(|validated| { + chosen.name.as_ref().is_none_or(|name| validated.name.as_ref() == Some(name)) + && chosen.scored.iter().all(|property| validated.scored.contains(property)) + }) +} + +/// Check that the driver's validated ticket (`validated`) kept the media type, resolution and +/// print quality of the settings chosen in its dialog (`chosen`, the ticket [`driver_settings_xml`] +/// returned). A driver can replace one of them when it conflicts with the job's paper, and Windows +/// reports the replacement as a success. An option is compared by its name and by the values of +/// its scored properties, such as a resolution's dots per inch; names are compared by namespace +/// and local name, so a driver that spells its namespace with another prefix keeps them. A feature +/// whose option in `chosen` has neither a name nor a scored value is not checked. +pub(crate) fn verify_chosen_options(chosen: &str, validated: &str) -> Result<(), Error> { + let chosen = roxmltree::Document::parse(chosen).map_err(spool_error)?; + let validated = roxmltree::Document::parse(validated).map_err(spool_error)?; + for (feature, words) in CHOSEN_OPTIONS { + if let Some(wanted) = feature_option(&chosen, feature) + && (wanted.name.is_some() || !wanted.scored.is_empty()) + && !keeps_option(&wanted, feature_option(&validated, feature).as_ref()) + { + return Err(spool_error(format!( + "the printer's driver replaced the {words} chosen in its dialog; nothing was sent (choose the settings again in its dialog)" + ))); + } + } + Ok(()) +} + /// XML Schema instance namespace (`xsi:type`). const NS_XSI: &str = "http://www.w3.org/2001/XMLSchema-instance"; /// XML Schema datatypes namespace (`xsd:integer`, `xsd:QName`). @@ -667,6 +820,212 @@ mod tests { assert!(!ticket_xml(&Job { printer: Some("Office \"east\" <1>".into()), ..Job::default() }, false).contains("east")); } + /// A ticket written for these tests in the shape of a converted driver dialog's settings: the + /// job-scope schema features, a custom paper with its parameters, pages per sheet, binding + /// and a poster, a media type, a vendor's quality and the driver's private settings. + fn converted_settings() -> String { + format!( + r#" + +4 + + + + + + +914400 + + +50 +2 + +0 +4 + + + + +AAECAw== +"# + ) + } + + /// The top-level schema names left in a ticket. + fn schema_names(xml: &str) -> Vec { + let document = roxmltree::Document::parse(xml).unwrap(); + document.root_element().children().filter(|n| named_in(*n, NS_PSK)).map(|n| n.attribute("name").unwrap_or_default().to_string()).collect() + } + + fn named_in(node: roxmltree::Node<'_, '_>, namespace: &str) -> bool { + node.attribute("name").and_then(|v| v.split_once(':')).is_some_and(|(prefix, _)| node.lookup_namespace_uri(Some(prefix)) == Some(namespace)) + } + + #[test] + fn the_driver_dialogs_settings_keep_what_the_job_leaves_open() { + let settings = converted_settings(); + let kept = driver_settings_xml(&settings, &Job::default()).unwrap(); + assert_eq!( + schema_names(&kept), + ["psk:JobDuplexAllDocumentsContiguously", "psk:DocumentDuplex", "psk:PageOutputColor", "psk:PageMediaType", "psk:PageResolution"], + "without a two-sided or grayscale choice of the job, the dialog's apply" + ); + for vendor in ["ns0000:Fine", "ns0000:PageMediaSizeSource", "ns0000:PageDevmodeSnapshot", "AAECAw=="] { + assert!(kept.contains(vendor), "{vendor} is kept"); + } + for job_owned in [ + "JobCopiesAllDocuments", + "DocumentCollate", + "PageMediaSize\"", + "MediaSizeWidth", + "Landscape", + "CustomSquare", + "PageScalingScale", + "PagesPerSheet", + "Booklet", + "JobBindAllDocumentsGutter", + "SheetsPerPage", + ] { + assert!(!kept.contains(job_owned), "{job_owned} is the job's"); + } + // Everything kept is the driver's own text, unchanged. + let line = r#""#; + assert!(kept.contains(line)); + + let job = Job { duplex: Some(Duplex::Off), grayscale: true, ..Job::default() }; + assert_eq!( + schema_names(&driver_settings_xml(&settings, &job).unwrap()), + ["psk:PageMediaType", "psk:PageResolution"], + "the job's two-sided and grayscale win" + ); + } + + #[test] + fn the_dialogs_settings_are_matched_by_namespace_not_by_prefix() { + // The schema keywords under another prefix, or unprefixed in the schema's default + // namespace, are still the job's; a vendor feature with a schema-like name is the driver's. + let settings = format!( + r#" + + + +"# + ); + let kept = driver_settings_xml(&settings, &Job::default()).unwrap(); + assert!(!kept.contains("k:Landscape")); + assert!(kept.contains("v:Mirrored")); + assert!(!kept.contains("name=\"PageMediaSize\""), "{kept}"); + assert!(roxmltree::Document::parse(&kept).is_ok()); + } + + #[test] + fn a_media_type_or_quality_the_driver_replaced_refuses_the_job() { + let chosen = driver_settings_xml(&converted_settings(), &Job::default()).unwrap(); + // The vendor's namespace under another prefix than the dialog's `ns0000`. + let ticket = |media: &str, resolution: &str, quality: &str| { + format!( + r#" + +{quality} +"# + ) + }; + assert!(verify_chosen_options(&chosen, &ticket("psk:PhotographicGlossy", "ns0001:Fine", "")).is_ok()); + let respelled = format!( + r#""# + ); + assert!(verify_chosen_options(&chosen, &respelled).is_ok(), "the same options under other prefixes"); + let replaced = [ + (ticket("psk:Plain", "ns0001:Fine", ""), "media type"), + (ticket("ns0001:PhotographicGlossy", "ns0001:Fine", ""), "media type"), + (ticket("psk:Plain", "ns0001:Fine", "").replace("PageMediaType", "Other"), "media type"), + (ticket("psk:PhotographicGlossy", "ns0001:Draft", ""), "resolution"), + (ticket("psk:PhotographicGlossy", "psk:Fine", ""), "resolution"), + ]; + for (replaced, words) in replaced { + let refused = verify_chosen_options(&chosen, &replaced); + assert!(matches!(&refused, Err(Error::Spool(m)) if m.contains(words) && m.contains("nothing was sent")), "{replaced}: {refused:?}"); + } + + let with_quality = format!( + r#""# + ); + let quality = |option: &str| { + ticket("psk:Plain", "psk:Other", &format!(r#""#)) + }; + assert!(verify_chosen_options(&with_quality, &quality("psk:High")).is_ok(), "no media type or resolution chosen: not checked"); + let refused = verify_chosen_options(&with_quality, &quality("psk:Draft")); + assert!(matches!(&refused, Err(Error::Spool(m)) if m.contains("print quality")), "{refused:?}"); + + // A resolution is also its dots per inch, with or without an option name. + let resolution = |name: &str, x: u32, y: u32| { + format!( + r#" +{x} + {y} +"# + ) + }; + for name in ["", r#" name="psk:Fine""#] { + let chosen = resolution(name, 600, 600); + assert!(verify_chosen_options(&chosen, &resolution(name, 600, 600)).is_ok(), "{name}"); + assert!(verify_chosen_options(&chosen, &resolution(name, 600, 600).replace(">600<", ">+0600<")).is_ok(), "{name}: the same number"); + for replaced in [resolution(name, 300, 300), resolution(name, 600, 300), quality("psk:High")] { + let refused = verify_chosen_options(&chosen, &replaced); + assert!(matches!(&refused, Err(Error::Spool(m)) if m.contains("resolution")), "{name} {replaced}: {refused:?}"); + } + } + assert!(verify_chosen_options(&resolution(r#" name="psk:Fine""#, 600, 600), &resolution("", 600, 600)).is_err(), "the name was dropped"); + + // Values the option refers to as parameters. + let by_reference = |x: u32| { + format!( + r#" + +{x}"# + ) + }; + assert!(verify_chosen_options(&by_reference(600), &by_reference(600)).is_ok()); + assert!(verify_chosen_options(&by_reference(600), &by_reference(300)).is_err(), "the parameter's value changed"); + + // A QName value under another prefix is the same value. + let qname = |prefix: &str, value: &str| { + format!( + r#" +{prefix}:{value} +"# + ) + }; + assert!(verify_chosen_options(&qname("ns0000", "Photo"), &qname("brpsk", "Photo")).is_ok()); + assert!(verify_chosen_options(&qname("ns0000", "Photo"), &qname("brpsk", "Text")).is_err()); + } + + #[test] + fn settings_that_are_not_a_ticket_are_refused() { + for bad in + ["", ""#] + { + let refused = driver_settings_xml(bad, &Job::default()); + assert!(matches!(&refused, Err(Error::Spool(m)) if m.contains("choose them again")), "{bad:?}: {refused:?}"); + } + } + + #[test] + fn the_driver_dialog_opens_on_the_jobs_copies_and_collation() { + let job = Job { copies: 3, collate: false, duplex: Some(Duplex::LongEdge), grayscale: true, ..Job::default() }; + let xml = dialog_xml(&job).unwrap(); + assert_eq!(schema_names(&xml), ["psk:JobCopiesAllDocuments", "psk:DocumentCollate"]); + for value in [">3<", "psk:Uncollated"] { + assert!(xml.contains(value), "missing {value}"); + } + // The job's two-sided and grayscale choices stay out of the settings the dialog returns, + // so they do not outlast the job's own choice. + for value in ["TwoSided", "Grayscale", "Monochrome"] { + assert!(!xml.contains(value), "{value} is the job's"); + } + assert_eq!(schema_names(&dialog_xml(&Job::default()).unwrap()), ["psk:JobCopiesAllDocuments", "psk:DocumentCollate"]); + } + /// A validated ticket as a driver returns it. fn validated(paper: (u32, u32), orientation: &str, scaling: Option<&str>) -> String { let scaling = scaling.map_or(String::new(), |s| format!(r#""#)); diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index d8f10db8a..d43e3981a 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -19,14 +19,14 @@ use winprint::ticket::{FeatureOptionPack as _, PageMediaSize, PrintCapabilities, use crate::sheets::{self, Fit, SAME}; use crate::ticket::{self, Paper}; -use crate::{Cancel, Error, Job, Printer, Scaling}; +use crate::{Cancel, Error, Job, Printer, Scaling, rgb}; fn spool_error(e: impl std::fmt::Display) -> Error { Error::Spool(e.to_string()) } /// The user's default printer; `None` when there is none. -fn default_printer() -> Option { +pub(crate) fn default_printer() -> Option { winsafe::GetDefaultPrinter().ok().filter(|n| !n.is_empty()) } @@ -148,7 +148,61 @@ fn hold(job: &Job, title: &str) { } } +/// The job's [`Job::driver_settings`] as a ticket for `device`, without what the job's own ticket +/// decides ([`ticket::driver_settings_xml`]); `None` when the job has none, or has settings +/// chosen for another printer ([`crate::settings_for`]). +fn chosen_settings(device: &PrinterDevice, job: &Job) -> Result, Error> { + let Some(settings) = crate::settings_for(job, device.name()) else { return Ok(None) }; + let converted = { + let _provider = print_ticket_provider(); + PrintTicket::from_dev_mode(device, settings.dev_mode().as_bytes()) + } + .map_err(|e| spool_error(format!("the printer's driver could not read the settings chosen in its dialog; choose them again: {e}")))?; + let xml = std::str::from_utf8(converted.get_xml()).map_err(spool_error)?; + Ok(Some(PrintTicket::from_xml(ticket::driver_settings_xml(xml, job)?))) +} + +/// The settings [`crate::driver_settings_dialog`] opens the driver's dialog on, for the queue +/// named `printer`: the job's earlier choices in that dialog, without what the job decides, +/// when they are for this printer and the driver still reads and merges them, else the settings the driver +/// gives a new job, with the job's copies and collation merged in, validated by the driver and +/// converted to its DEVMODE. +pub(crate) fn settings_seed(job: &Job, printer: &str) -> Result { + let device = select_device(PrinterDevice::all().map_err(spool_error)?, Some(printer), None)?; + // Earlier settings the driver no longer reads (after a driver update, or restored from an + // app's own copy) are left out, so the dialog still opens and can replace them. + let earlier = chosen_settings(&device, job); + let replaces_unreadable = earlier.is_err(); + let chosen = earlier.ok().flatten(); + let options = ticket::dialog_xml(job)?; + let unreadable = |e: &dyn std::fmt::Display| spool_error(format!("the printer driver's settings could not be read: {e}")); + let seed = |chosen: Option| -> Result { + let _provider = print_ticket_provider(); + let seed = { + let mut builder = PrintTicketBuilder::new(&device).map_err(|e| unreadable(&e))?; + if let Some(chosen) = chosen { + builder.merge(chosen).map_err(|e| unreadable(&e))?; + } + builder.merge(PrintTicket::from_xml(options.clone())).map_err(|e| unreadable(&e))?; + builder.build().map_err(|e| unreadable(&e))? + }; + let bytes = seed.to_dev_mode(&device).map_err(|e| unreadable(&e))?; + let whole = crate::whole_dev_mode(bytes).ok_or_else(|| unreadable(&"the driver's settings are shorter than they say"))?; + rgb::DevMode::from_bytes(whole).map_err(|e| unreadable(&e)) + }; + // Earlier settings the driver reads but then refuses to merge or convert back are left out + // the same way; an error with the job's own options alone is returned. + let had_earlier = chosen.is_some(); + match seed(chosen) { + Ok(dev_mode) => Ok(crate::Seed { dev_mode, replaces_unreadable }), + Err(_) if had_earlier => Ok(crate::Seed { dev_mode: seed(None)?, replaces_unreadable: true }), + Err(e) => Err(e), + } +} + fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape: bool) -> Result<(PrintTicket, (u32, u32)), Error> { + let chosen = chosen_settings(device, job)?; + let chosen_xml = chosen.as_ref().map(|chosen| chosen.get_xml().to_vec()); let capabilities_xml = { let _provider = print_ticket_provider(); #[cfg(test)] @@ -200,12 +254,20 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape let _provider = print_ticket_provider(); // The builder closes its provider when it is dropped, before the guard. let mut builder = PrintTicketBuilder::new(device).map_err(spool_error)?; + // The driver dialog's settings first, so the job's own options are merged over them. + if let Some(chosen) = chosen { + builder.merge(chosen).map_err(|e| spool_error(format!("the printer's driver refused the settings chosen in its dialog: {e}")))?; + } builder.merge(PrintTicket::from(document)).map_err(spool_error)?; builder.build().map_err(spool_error)? }; let validated = in_drivers_spelling(device, built); let xml = std::str::from_utf8(validated.get_xml()).map_err(spool_error)?; let paper = ticket::verify_ticket(xml, wanted, landscape)?; + // A media type, resolution or quality the driver replaced to resolve a conflict with the job's paper. + if let Some(chosen_xml) = &chosen_xml { + ticket::verify_chosen_options(std::str::from_utf8(chosen_xml).map_err(spool_error)?, xml)?; + } Ok((validated, paper)) } @@ -504,6 +566,64 @@ mod tests { } } + /// Settings from the driver's own dialog, here landscape and four uncollated copies converted + /// to the driver's DEVMODE, leave the job's paper, orientation and copies as the job asks; + /// settings chosen for another printer leave the job's ticket as it is without them; and + /// earlier settings with no fields set, which a driver either refuses or reads without an + /// error, open the dialog as if the job had none. + /// `CRAFT_PRINT_TEST_PRINTER` names the queue. Sends no job and opens no dialog. + #[test] + #[ignore = "needs CRAFT_PRINT_TEST_PRINTER naming an installed printer; sends no job"] + fn the_driver_dialogs_settings_leave_the_jobs_own_options() { + let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap(); + let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); + let chosen = format!( + r#" +4 + + +"#, + ticket::NS_PSF + ); + let bytes = { + let _provider = print_ticket_provider(); + PrintTicket::from_xml(chosen).to_dev_mode(&device) + } + .unwrap(); + let dev_mode = rgb::DevMode::from_bytes(crate::whole_dev_mode(bytes).unwrap()).unwrap(); + let job = Job { copies: 2, driver_settings: Some(crate::DriverSettings::new(device.name(), dev_mode.clone())), ..Job::default() }; + let (ticket, paper) = driver_ticket(&device, &job, (612.0, 792.0), false).unwrap(); + let xml = std::str::from_utf8(ticket.get_xml()).unwrap(); + eprintln!("{xml}"); + let capabilities = { + let _provider = print_ticket_provider(); + PrintCapabilities::fetch_xml_for_ticket(&device, None) + } + .unwrap(); + let offered = ticket::offered(std::str::from_utf8(&capabilities).unwrap()); + // Two pages, so the job's collation is checked too, on a driver that can collate. + let pages = if offered.collate == Some(false) { 1 } else { 2 }; + ticket::verify_job_options(xml, &job, pages, offered).unwrap(); + assert_eq!(paper, (215_900, 279_400)); + assert_eq!(ticket::verify_ticket(xml, Some(paper), false).unwrap(), paper, "the job's portrait sheet keeps its orientation"); + + let plain = Job { copies: 2, ..Job::default() }; + let other = Job { driver_settings: Some(crate::DriverSettings::new("Craft Test Print: no such queue", dev_mode)), ..plain.clone() }; + let xml_of = |job: &Job| driver_ticket(&device, job, (612.0, 792.0), false).unwrap().0.get_xml().to_vec(); + assert_eq!(xml_of(&other), xml_of(&plain), "settings for another printer are left out"); + + let mut zeros = vec![0u8; 220]; + zeros[68..70].copy_from_slice(&220u16.to_le_bytes()); + let zeroed = + Job { driver_settings: Some(crate::DriverSettings::new(device.name(), rgb::DevMode::from_bytes(zeros).unwrap())), ..plain.clone() }; + let seed = settings_seed(&zeroed, device.name()).unwrap(); + // Only the size is set (no dmFields, no private section): a driver may refuse such + // settings or read them without an error. + let refused = chosen_settings(&device, &zeroed).is_err(); + assert_eq!(seed.replaces_unreadable, refused, "the dialog replaces the settings only when the driver refuses them"); + assert_eq!(seed.dev_mode, settings_seed(&plain, device.name()).unwrap().dev_mode, "the dialog opens as if the job had no settings"); + } + /// A real job, for checking a printer by hand (paper, or a paused queue's spool file): /// `CRAFT_PRINT_TEST_FILE` printed on `CRAFT_PRINT_TEST_PRINTER`, shrink-to-fit when /// `CRAFT_PRINT_TEST_SCALING=shrink`, two-sided on the long edge when