Print in grayscale prints on a Windows driver with no grey option (#5806) (#6863)

* Print in grayscale prints on a Windows driver with no grey option (#5806)

Windows refused Print in grayscale ("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, and an HP Photosmart driver whose grey is its own vendor setting.

- On Windows, a grayscale job's copy for the spooler is made grey first with Convert colours
  (all pages, comments and form fields, black kept black, pictures re-encoded where they can
  be), and the job says so (Job::grey_pages). The driver is still asked for grey where it has
  it; one without prints the grey pages instead of refusing them. A copy that can't be read
  goes as it is and asks the driver strictly, as before.
- craft-print 970644b7 (storytold/craft-libs#774) adds Job::grey_pages and asks a driver that
  lists only psk:Grayscale for that, not psk:Monochrome.
- Both the Print dialog and doc_print go through spooler_copy, which now takes the job.

* craft-print 1146cdc4: craft-libs main with #774 (grey pages) and #773 (the CUPS printer list waits at most 15 s, storytold/pdfcraft#6761)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-10 17:31:12 +01:00
1 parent 919db9bd51
commit fe70ecce73
6 files changed
+135 -18

No files matched your search

Generated
+1 -1
View File
@@ -1243,7 +1243,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d#0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d"
source = "git+https://github.com/storytold/craft-libs?rev=1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c#1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c"
dependencies = [
"craft-winprint-gdi",
"log",
+1 -1
View File
@@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c" }
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
+4 -2
View File
@@ -106,6 +106,8 @@ fn spool_job(
duplex,
scaling: spool::Scaling::Exact,
grayscale: print::driver_needs_grayscale(a.opt_bool("grayscale")?.unwrap_or(false), raster),
// Set when the copy sent is made grey (`spooler_copy`, #5806).
grey_pages: false,
title,
options: {
let mut options: Vec<(String, String)> = options.into_iter().chain(print::media_option(paper)).collect();
@@ -310,8 +312,8 @@ impl Automation {
}
(None, Some(printer)) => {
let printer = asked.as_ref().map_or(printer, |p| p.name.as_str());
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?;
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes);
let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?;
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job);
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
}
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
+123 -10
View File
@@ -6,20 +6,65 @@ use pdfcraft_cos::{SaveOptions, write_full};
use crate::guard;
/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler.
/// On Windows, whose PDF renderer prints an image of more than 2²² pixels from a shrunk copy,
/// large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131), so that a
/// scan prints at the printer's resolution. Elsewhere, with no large image, and when the copy
/// can't be read or written, the bytes go as they are: tiling never stops a print.
pub fn spooler_copy(bytes: Vec<u8>) -> Vec<u8> {
/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler
/// with `job`. Two things happen on Windows only; elsewhere the bytes go as they are.
///
/// - A job in grayscale is made grey here first ([`greyed`]), and `job.grey_pages` says so.
/// Windows has no colour conversion of its own, and a driver that lists no grey option (Microsoft
/// Print to PDF, or one with only a vendor's own grey setting) refused the job outright
/// (#5806). The driver is still asked for grey where it has it.
/// - Large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131): Windows'
/// PDF renderer prints an image of more than 2²² pixels from a shrunk copy, so a scan comes out
/// at the printer's resolution.
///
/// When the copy can't be read or written, the bytes go as they are: neither step ever stops a
/// print, and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey,
/// strictly, as before.
pub fn spooler_copy(bytes: Vec<u8>, job: &mut crate::print::spool::Job) -> Vec<u8> {
if !cfg!(windows) {
return bytes;
}
let bytes = Arc::new(bytes);
let mut bytes = Arc::new(bytes);
if job.grayscale {
match greyed(bytes.clone()) {
Some(Some(grey)) => {
bytes = Arc::new(grey);
job.grey_pages = true;
}
Some(None) => job.grey_pages = true,
None => {}
}
}
// Once `tiled` returns, its document is gone and `bytes` is the only owner left: no copy.
tiled(bytes.clone()).unwrap_or_else(|| Arc::try_unwrap(bytes).unwrap_or_else(|shared| (*shared).clone()))
}
/// `bytes` with every colour on every page, its comments and form fields included, turned grey by
/// Convert colours (black kept black, pictures re-encoded where they can be): `Some(Some(_))`.
/// `Some(None)` when nothing needed converting, and `None` when the copy can't be read or written.
///
/// Convert colours leaves shadings, patterns, inline images and pictures it can't re-encode
/// (indexed, CMYK, JPEG 2000 …) as they are; the driver is still asked for grey, so a driver with a
/// grey option makes those grey too, and one without prints them as they are.
pub fn greyed(bytes: Arc<Vec<u8>>) -> Option<Option<Vec<u8>>> {
guard(|| {
let mut doc = pdfcraft_cos::Document::open(bytes).ok()?;
let pages: Vec<usize> = (0..pdfcraft_model::pages(&doc).len()).collect();
let settings =
pdfcraft_edit::ConvertColours { target: pdfcraft_edit::ColourTarget::Gray, preserve_black: true, images: true, annotations: true };
let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, _: pdfcraft_edit::ColourTarget| {
pdfcraft_optimize::recolour_image(doc, s, pdfcraft_optimize::ImageTarget::Gray)
};
let done = pdfcraft_edit::convert_colours(&mut doc, &pages, &settings, &mut recolour).ok()?;
if done.colours == 0 && done.images == 0 {
return Some(None);
}
write_full(&doc, &SaveOptions::default()).ok().map(Some)
})
.ok()
.flatten()
}
/// `bytes` with their large images drawn as tiles, or `None` when there is none, or the copy
/// can't be read or written.
pub fn tiled(bytes: Arc<Vec<u8>>) -> Option<Vec<u8>> {
@@ -37,6 +82,7 @@ mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
use super::*;
use crate::print::spool::Job;
/// A one-page PDF drawing a `w` × `h` grey image over the page.
fn page_with_image(w: i64, h: i64) -> Vec<u8> {
@@ -90,16 +136,83 @@ mod tests {
fn a_print_with_nothing_to_tile_or_unreadable_goes_as_it_is() {
assert_eq!(tiled(Arc::new(page_with_image(100, 100))), None);
assert_eq!(tiled(Arc::new(b"not a PDF".to_vec())), None);
assert_eq!(spooler_copy(b"not a PDF".to_vec()), b"not a PDF");
let mut job = Job::default();
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job), b"not a PDF");
let small = page_with_image(100, 100);
assert_eq!(spooler_copy(small.clone()), small);
assert_eq!(spooler_copy(small.clone(), &mut job), small);
assert!(!job.grey_pages);
// A grayscale job whose copy can't be read asks the driver for grey as before.
let mut grey = Job { grayscale: true, ..Job::default() };
assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey), b"not a PDF");
assert!(!grey.grey_pages);
}
/// A one-page PDF filling the page in red.
fn red_page() -> Vec<u8> {
let mut doc = Document::new_empty();
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1 0 0 rg 0 0 612 792 re f".to_vec())));
let mut page = Dict::new();
page.set("Type", Object::name("Page"));
page.set("Parent", Object::Ref(pages));
page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()));
page.set("Contents", Object::Ref(content));
let page = doc.add(Object::Dict(page));
doc.update_dict(pages, |d| {
d.set("Count", Object::Int(1));
d.set("Kids", Object::Array(vec![Object::Ref(page)]));
})
.unwrap();
write_full(&doc, &SaveOptions::default()).unwrap()
}
/// The operators of the first page's content.
fn first_page_content(bytes: Vec<u8>) -> String {
let doc = Document::open(Arc::new(bytes)).unwrap();
let page = pdfcraft_model::pages(&doc)[0].obj;
let contents = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap();
match &*doc.get(contents) {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(),
other => panic!("{other:?}"),
}
}
/// #5806: a page in colour is made grey for a grayscale job, which says so.
#[test]
fn a_colour_page_is_made_grey_for_a_grayscale_job() {
let grey = greyed(Arc::new(red_page())).expect("readable").expect("converted");
let content = first_page_content(grey.clone());
assert!(!content.contains(" rg"), "{content}");
assert!(content.contains(" g"), "{content}");
// Already grey: nothing to convert, and the pages are still grey.
assert_eq!(greyed(Arc::new(grey)), Some(None));
assert_eq!(greyed(Arc::new(b"not a PDF".to_vec())), None);
}
/// On Windows a grayscale job's copy is grey and the job says its pages are; elsewhere the
/// bytes go as they are and the driver is asked, as before. A colour job is never converted.
#[test]
fn a_grayscale_job_sends_grey_pages_on_windows_only() {
let red = red_page();
let mut job = Job { grayscale: true, ..Job::default() };
let sent = spooler_copy(red.clone(), &mut job);
if cfg!(windows) {
assert!(job.grey_pages);
assert!(!first_page_content(sent).contains(" rg"));
} else {
assert!(!job.grey_pages);
assert_eq!(sent, red);
}
let mut colour = Job::default();
assert_eq!(spooler_copy(red.clone(), &mut colour), red);
assert!(!colour.grey_pages);
}
/// On Windows the spooler gets the tiled copy ([`tiled`]); elsewhere the bytes as they are.
#[test]
fn the_spooler_gets_tiles_on_windows_only() {
let large = page_with_image(2100, 2100);
let sent = spooler_copy(large.clone());
let sent = spooler_copy(large.clone(), &mut Job::default());
if cfg!(windows) {
assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref());
assert_ne!(sent, large);
+4 -4
View File
@@ -557,7 +557,7 @@ impl PdfCraftApp {
/// the progress card counts the sheets as they reach the spooler, and the spooler's answer
/// arrives as a notice.
#[cfg(not(target_arch = "wasm32"))]
fn send_to_printer(&mut self, printer: String, bytes: Vec<u8>, job: spool::Job) -> bool {
fn send_to_printer(&mut self, printer: String, bytes: Vec<u8>, mut job: spool::Job) -> bool {
let (tx, rx) = std::sync::mpsc::channel();
let ctx = self.ctx.clone();
let spooler = self.spooler.clone();
@@ -567,9 +567,9 @@ impl PdfCraftApp {
ctx.request_repaint();
}
};
// Large pictures become tiles on Windows (#5131): work on the whole file, kept off the
// UI thread with the rest.
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes);
// On Windows grayscale makes the pages grey (#5806) and large pictures become tiles
// (#5131): work on the whole file, kept off the UI thread with the rest.
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job);
// The receiver may be gone (the app quit): nothing to report to then.
let result = match &spooler {
Some(submit) => submit(&bytes, &job),
@@ -341,6 +341,8 @@ impl PrintDraft {
},
scaling: spool::Scaling::Exact,
grayscale: print::driver_needs_grayscale(self.grayscale, self.raster()),
// Set when the copy sent is made grey (`spooler_copy`, #5806).
grey_pages: false,
title: title.to_string(),
// Only for the printer the options were read from.
options: match (&self.printer, &self.driver_options) {