print: grey pages print on a Windows driver with no grey option; ask for the one it lists (#774)

storytold/pdfcraft#5806: Print in grayscale was refused ("the printer's driver did not retain
the requested grayscale; nothing was sent") on drivers that list no grey option the ticket can
name: Microsoft Print to PDF lists only Color, and an HP Photosmart driver has only its own
ns0000:JobHPPrintInGrayScale.

- Job::grey_pages: the caller has already made the pages grey. Grayscale is still asked of the
  driver, but a driver that has no grey option, or doesn't keep it, prints the job instead of
  refusing it. Off (the default), such a driver is refused as before, since the pages would
  come out in colour.
- The ticket asks for psk:Grayscale when the driver lists that and not psk:Monochrome
  (grey_option); Windows dropped the Monochrome it didn't list and colour came back.
- Offered::grey records whether PageOutputColor has a grey option at all.
This commit is contained in:
FredApps authored and GitHub committed 2026-11-10 15:16:57 +01:00
1 parent 3edc157a2c
commit 1146cdc4f9
3 files changed
+112 -8

No files matched your search

+6
View File
@@ -82,6 +82,11 @@ pub struct Job {
pub duplex: Option<Duplex>,
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(),
}
+104 -6
View File
@@ -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 <psf:Feature name=\"psk:PageOutputColor\"><psf:Option name=\"psk:Monochrome\"/></psf:Feature>" } else { "" };
let color = match (job.grayscale, grey) {
(false, _) => "",
(true, Grey::Monochrome) => "\n <psf:Feature name=\"psk:PageOutputColor\"><psf:Option name=\"psk:Monochrome\"/></psf:Feature>",
(true, Grey::Grayscale) => "\n <psf:Feature name=\"psk:PageOutputColor\"><psf:Option name=\"psk:Grayscale\"/></psf:Feature>",
};
let copies = job.copies.clamp(1, 999);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
@@ -225,6 +261,9 @@ pub(crate) struct Offered {
pub duplex: Option<bool>,
/// The colour feature (`PageOutputColor`) has an option other than Monochrome and Grayscale.
pub color: Option<bool>,
/// 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<bool>,
/// The collation feature (`DocumentCollate`) has a Collated option.
pub collate: Option<bool>,
}
@@ -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("<psf:PrintCapabilities xmlns:psf=\"x\"/>"), Offered::default(), "another namespace is not capabilities");
assert_eq!(super::offered("<not xml"), Offered::default());
@@ -1006,6 +1053,57 @@ mod tests {
assert!(verify_job_options(&dropped, &gray, 3, Offered::default()).is_err(), "unknown stays strict");
}
/// storytold/pdfcraft#5806: a driver whose only grey option is `Grayscale` is asked for that,
/// since Windows drops a `Monochrome` it doesn't list and the driver's colour comes back.
#[test]
fn the_ticket_asks_for_the_grey_option_the_driver_lists() {
let grey = |colors: &[&str]| grey_option(&capabilities_xml(true, colors, COLLATE));
assert_eq!(grey(&["psk:Color", "psk:Grayscale"]), Grey::Grayscale);
assert_eq!(grey(&["psk:Color", "psk:Monochrome"]), Grey::Monochrome);
assert_eq!(grey(&["psk:Color", "psk:Grayscale", "psk:Monochrome"]), Grey::Monochrome, "both: as before");
assert_eq!(grey(&["psk:Color"]), Grey::Monochrome, "none to ask for: as before");
assert_eq!(grey(&["psk:Color", "ns0000:Grayscale"]), Grey::Monochrome, "a vendor's option is not the schema's");
assert_eq!(grey_option("<not xml"), Grey::Monochrome);
let gray = Job { grayscale: true, ..Job::default() };
let asked = ticket_xml_with(&gray, false, false, Grey::Grayscale);
assert!(asked.contains("<psf:Option name=\"psk:Grayscale\"/>") && !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("<not xml").grey, None);
let gray = Job { grayscale: true, ..Job::default() };
let grey_pages = Job { grey_pages: true, ..gray.clone() };
// What such a driver returns: colour, or nothing about colour.
let color = ticket_xml(&gray, false).replace("psk:Monochrome", "psk:Color");
let dropped = without(&ticket_xml(&gray, false), "PageOutputColor");
for xml in [&color, &dropped] {
assert!(verify_job_options(xml, &gray, 3, caps(&["psk:Color"])).is_err(), "colour pages are refused");
assert!(verify_job_options(xml, &grey_pages, 3, caps(&["psk:Color"])).is_ok(), "grey pages print");
assert!(verify_job_options(xml, &grey_pages, 3, Offered::default()).is_ok(), "unread capabilities too");
assert!(verify_job_options(xml, &grey_pages, 3, caps(COLOR)).is_ok(), "and a driver that dropped it");
}
// The other options are still checked.
let copies = Job { copies: 2, ..grey_pages.clone() };
assert!(verify_job_options(&color, &copies, 3, caps(&["psk:Color"])).is_err(), "copy count");
assert!(!Job::default().grey_pages, "off unless the caller says so");
}
#[test]
fn copies_on_a_driver_that_cannot_collate_are_accepted_only_uncollated() {
let caps = |collations: &[&str]| offered(&capabilities_xml(true, COLOR, collations));
+2 -2
View File
@@ -109,7 +109,7 @@ fn select_device(devices: Vec<PrinterDevice>, 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();