diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 42443f4b..5ead8602 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -82,6 +82,11 @@ pub struct Job { pub duplex: Option, pub scaling: Scaling, pub grayscale: bool, + /// The caller has already made the pages grey (storytold/pdfcraft#5806): `grayscale` is then + /// asked of the driver where it has a grey option, but a driver that has none, or doesn't keep + /// it, still prints the job instead of refusing it. Off, a driver that drops `grayscale` is + /// refused, since the pages would come out in colour. Windows only; CUPS never refuses. + pub grey_pages: bool, pub title: String, /// 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. @@ -97,6 +102,7 @@ impl Default for Job { duplex: None, scaling: Scaling::Exact, grayscale: false, + grey_pages: false, title: "Craft".into(), options: Vec::new(), } diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 8b6b5e92..65a15ec0 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -34,12 +34,44 @@ pub(crate) fn ticket_xml(job: &Job, landscape: bool) -> String { ticket_xml_for_duplex(job, landscape, false) } +/// [`ticket_xml_with`] asking for grey as `Monochrome`, as every ticket did before drivers with +/// only `Grayscale` were asked for that. +#[cfg(test)] +pub(crate) fn ticket_xml_for_duplex(job: &Job, landscape: bool, document_only: bool) -> String { + ticket_xml_with(job, landscape, document_only, Grey::Monochrome) +} + +/// The Print Schema option a ticket asks for grey output with (`PageOutputColor`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Grey { + Monochrome, + Grayscale, +} + +/// The grey option to ask this driver for: `Grayscale` when its PrintCapabilities list that and +/// not `Monochrome` (Windows drops an option the driver doesn't list when it validates the +/// ticket, and the driver's default, colour, comes back); `Monochrome` otherwise, also when the +/// capabilities can't be read. +pub(crate) fn grey_option(capabilities_xml: &str) -> Grey { + let Ok(document) = roxmltree::Document::parse(capabilities_xml) else { return Grey::Monochrome }; + let root = document.root_element(); + let offers = |option: &str| { + root.children() + .filter(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, "PageOutputColor")) + .flat_map(|f| f.children().filter(|n| n.has_tag_name((NS_PSF, "Option")))) + .any(|n| named(n, option)) + }; + if offers("Grayscale") && !offers("Monochrome") { Grey::Grayscale } else { Grey::Monochrome } +} + /// Build the job's PrintTicket XML with orientation, copies, collation and no scaling. /// `document_only` selects `DocumentDuplex`; otherwise an explicit duplex choice uses /// `JobDuplexAllDocumentsContiguously`. With no duplex choice, neither feature is emitted. -/// Paper is added separately by the caller. The caller reads capabilities before choosing the -/// feature and returns an error when those capabilities cannot be read. -pub(crate) fn ticket_xml_for_duplex(job: &Job, landscape: bool, document_only: bool) -> String { +/// Grayscale asks for `grey` ([`grey_option`]); colour asks for nothing, so the driver's own +/// colour setting stands. Paper is added separately by the caller. The caller reads +/// capabilities before choosing the feature and returns an error when those capabilities +/// cannot be read. +pub(crate) fn ticket_xml_with(job: &Job, landscape: bool, document_only: bool, grey: Grey) -> String { let orientation = if landscape { "Landscape" } else { "Portrait" }; let duplex = match job.duplex { None => String::new(), @@ -56,7 +88,11 @@ pub(crate) fn ticket_xml_for_duplex(job: &Job, landscape: bool, document_only: b let collate = if job.collate { "Collated" } else { "Uncollated" }; // Only grayscale is asked for. Without it the ticket says nothing about colour, so a black and // white chosen in the driver's own preferences (Properties…) still applies (storytold/pdfcraft#2422). - let color = if job.grayscale { "\n " } else { "" }; + let color = match (job.grayscale, grey) { + (false, _) => "", + (true, Grey::Monochrome) => "\n ", + (true, Grey::Grayscale) => "\n ", + }; let copies = job.copies.clamp(1, 999); format!( r#" @@ -225,6 +261,9 @@ pub(crate) struct Offered { pub duplex: Option, /// The colour feature (`PageOutputColor`) has an option other than Monochrome and Grayscale. pub color: Option, + /// The colour feature has a grey option (Monochrome or Grayscale). A driver without one may + /// still have a vendor's own grey setting, which a ticket can't ask for by name. + pub grey: Option, /// The collation feature (`DocumentCollate`) has a Collated option. pub collate: Option, } @@ -247,6 +286,7 @@ pub(crate) fn offered(capabilities_xml: &str) -> Offered { duplex: Some(options("JobDuplexAllDocumentsContiguously").chain(options("DocumentDuplex")).next().is_some()), // A vendor's own option might be colour, so only the two schema keywords count as not. color: Some(options("PageOutputColor").any(|n| !named(n, "Monochrome") && !named(n, "Grayscale"))), + grey: Some(options("PageOutputColor").any(|n| named(n, "Monochrome") || named(n, "Grayscale"))), collate: Some(options("DocumentCollate").any(|n| named(n, "Collated"))), } } @@ -348,7 +388,14 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Of } } let monochrome_only = offered.color == Some(false); - if job.grayscale && !monochrome_only && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) { + // Pages the caller has already made grey print grey whatever the driver does with colour, so + // grey on the driver is best-effort: a driver with no grey option (Microsoft Print to PDF), or + // only a vendor's own (storytold/pdfcraft#5806), still prints. + if job.grayscale + && !job.grey_pages + && !monochrome_only + && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) + { return Err(refused("grayscale")); } Ok(()) @@ -945,7 +992,7 @@ mod tests { #[test] fn a_driver_without_a_duplex_feature_prints_one_side_and_is_not_refused_for_it() { let offered = |duplex| offered(&capabilities_xml(duplex, COLOR, COLLATE)); - assert_eq!(offered(true), Offered { duplex: Some(true), color: Some(true), collate: Some(true) }); + assert_eq!(offered(true), Offered { duplex: Some(true), color: Some(true), grey: Some(true), collate: Some(true) }); assert_eq!(offered(false).duplex, Some(false)); assert_eq!(super::offered(""), Offered::default(), "another namespace is not capabilities"); assert_eq!(super::offered("") && !asked.contains("psk:Monochrome")); + assert_eq!(ticket_xml_with(&gray, false, false, Grey::Monochrome), ticket_xml(&gray, false)); + assert!(!ticket_xml_with(&Job::default(), false, false, Grey::Grayscale).contains("PageOutputColor"), "colour asks nothing"); + // The driver keeps it, and the check reads it back. + assert!(verify_job_options(&asked, &gray, 3, Offered::default()).is_ok()); + } + + /// storytold/pdfcraft#5806: pages the caller has already made grey print on a driver with no + /// grey option (Microsoft Print to PDF lists only Color; an HP driver has only its own + /// `ns0000:JobHPPrintInGrayScale`), and on one that dropped the request. Without them, such a + /// driver is still refused, since the pages would come out in colour. + #[test] + fn grey_pages_print_on_a_driver_that_has_no_grey_option() { + let caps = |colors: &[&str]| offered(&capabilities_xml(true, colors, COLLATE)); + assert_eq!(caps(&["psk:Color"]).grey, Some(false)); + assert_eq!(caps(&["psk:Color", "ns0000:Gray"]).grey, Some(false), "a vendor's option can't be asked for by name"); + assert_eq!(caps(COLOR).grey, Some(true)); + assert_eq!(caps(&["psk:Grayscale"]).grey, Some(true)); + assert_eq!(super::offered(", requested: Option<&str>, default: .ok_or_else(|| spool_error(format!("the printer is not available: {name} (reopen Print to refresh the list)"))) } -/// The ticket the driver accepted: [`ticket::ticket_xml_for_duplex`] with the driver's own paper option for +/// The ticket the driver accepted: [`ticket::ticket_xml_with`] with the driver's own paper option for /// `sheet` (its exact option, so a vendor's sizes survive; a ticket with dimensions only can come /// back as Letter) and its own "no scaling" option, merged and validated against the printer's /// capabilities. For exact size the driver must have that paper; for shrink-to-fit without one, @@ -176,7 +176,7 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape } }; let document_only = ticket::document_duplex_only(capabilities_xml, job.duplex); - let requested = PrintTicket::from_xml(ticket::ticket_xml_for_duplex(job, landscape, document_only)); + let requested = PrintTicket::from_xml(ticket::ticket_xml_with(job, landscape, document_only, ticket::grey_option(capabilities_xml))); let mut document = PrintTicketDocument::parse_from_bytes(requested.get_xml()).map_err(spool_error)?; if let Some(media) = media { let media_ticket: PrintTicket = media.clone().into();