diff --git a/Cargo.lock b/Cargo.lock index aef1801a53..1c64e1f20c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index aaafc8805a..e6f29521ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index ca423b194b..1e1f6e5fe0 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -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\")")), diff --git a/crates/engine/src/spooler.rs b/crates/engine/src/spooler.rs index d3fbecd339..56347d8f09 100644 --- a/crates/engine/src/spooler.rs +++ b/crates/engine/src/spooler.rs @@ -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) -> Vec { +/// 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, job: &mut crate::print::spool::Job) -> Vec { 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>) -> Option>> { + guard(|| { + let mut doc = pdfcraft_cos::Document::open(bytes).ok()?; + let pages: Vec = (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>) -> Option> { @@ -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 { @@ -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 { + 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) -> 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); diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index c5b90ec12a..c220a5bd2f 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -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, job: spool::Job) -> bool { + fn send_to_printer(&mut self, printer: String, bytes: Vec, 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), diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index cf65351141..5b02687b62 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -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) {