collective

Browse / github.com/storytold/craft-libs / patch

feat(print): Windows sends PostScript drivers the document's colours (CMYK, via Ghostscript)

feat(print): Windows sends PostScript drivers the document's colours (CMYK, via Ghostscript) ## Problem On Windows, craft-print draws each sheet with Direct2D into the XPS print API. That page is RGB, so a PostScript driver converts a document's CMYK to RGB and back. Colours print paler than from Acrobat, and pure black becomes four-colour black. A print shop using PdfCraft reported exactly this: "the colours are washed out compared with Acrobat". On macOS/Linux the PDF goes to CUPS as it is, so only Windows is affected. ## Change When both of these hold, each sheet goes to the printer as PostScript with its colours unchanged: - Ghostscript is installed: the newest `%ProgramFiles

Status
open
Score
0.00
Adopters
0 other operators
Author
bulgariamitko (imported from pull request #179, open)
Base
82a24b006eefa036b43c09a67fb9a3fbd17ffe63
Size
+176 −3 in 7 files
Review
pending
Shared
2026-10-28
Id
7da89601de2109448b08738c

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/print/Cargo.toml crates/print/README.md crates/print/src/lib.rs crates/print/src/postscript.rs crates/print/src/tests.rs crates/print/src/windows.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -674,7 +674,7 @@ dependencies = [ [[package]] name = "craft-winprint-gdi" version = "0.1.0"-source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"+source = "git+https://github.com/bulgariamitko/winprint.rs?rev=b460b2b0ce3e90502a888341ec367c28443d1e12#b460b2b0ce3e90502a888341ec367c28443d1e12" dependencies = [  "windows-sys 0.61.2", ]diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml--- a/crates/print/Cargo.toml+++ b/crates/print/Cargo.toml@@ -9,7 +9,8 @@ description = "System PDF print spooler for the Craft apps: CUPS and Windows"  [dependencies] # Safe API; Win32 calls are isolated in this maintained MIT OR Apache-2.0 dependency.-craft-winprint-gdi = { git = "https://github.com/storytold/winprint.rs", rev = "417ac3c4d14d3da5485a99c7d5ce8f05664ec312" }+# PostScript passthrough (print_postscript): storytold/winprint.rs PR, pinned to its head until merged.+craft-winprint-gdi = { git = "https://github.com/bulgariamitko/winprint.rs", rev = "b460b2b0ce3e90502a888341ec367c28443d1e12" }  [lints] workspace = truediff --git a/crates/print/README.md b/crates/print/README.md--- a/crates/print/README.md+++ b/crates/print/README.md@@ -25,6 +25,14 @@ opens Windows printer preferences and submits jobs with copies, collation, duple   sent. Paper is the driver's own option for the sheet's size (to 0.25 mm), preferring the one   named like the standard size ("ARCH D" over a custom 24 × 36 in entry). This backend comes   from storytold/pdfcraft#599 and #1132.+  A Direct2D/XPS page is RGB, so a PostScript driver turns a document's CMYK into RGB and back+  and it prints paler than from programs that send PostScript (Acrobat). When Ghostscript is+  installed (`%ProgramFiles%\gs`, or `CRAFT_PRINT_GHOSTSCRIPT`) and the driver takes+  `POSTSCRIPT_PASSTHROUGH`, each sheet is converted to EPS (`eps2write`: CMYK, spot colours and+  overprint as in the document; grey for a greyscale job) and passed through the driver with+  the validated ticket's settings as its DEVMODE (`craft-winprint-gdi`). That is the only+  program started; without Ghostscript, with another driver, or if it fails, the job takes the+  XPS route.   A job has one ticket, so one paper and orientation. Sheets of the other orientation are   turned back with `/Rotate`, so portrait and landscape sheets still print as one job. Exact   size refuses sheets of different sizes and paper the driver lacks. Shrink-to-fit uses thediff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs--- a/crates/print/src/lib.rs+++ b/crates/print/src/lib.rs@@ -3,11 +3,15 @@ //! `lp` with the job options (copies, collation, duplex, colour). On Windows the printers come //! from the spooler and jobs are drawn by the in-box `Windows.Data.Pdf` into the XPS print API //! with the options as a PrintTicket, all in this process (`windows.rs`): no PowerShell or other-//! program is started, so printing works where scripts are locked down. Other platforms report+//! program is started, so printing works where scripts are locked down — except an installed+//! Ghostscript, which turns the sheets into PostScript for a PostScript driver so their colours+//! print as they are (`postscript.rs`). Other platforms report //! that printing isn't available yet; the print-ready PDF can still be saved.  #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(test, windows))]+mod postscript; #[cfg(any(test, windows))] mod progress; #[cfg(any(test, windows))]diff --git a/crates/print/src/postscript.rs b/crates/print/src/postscript.rsnew file mode 100644--- /dev/null+++ b/crates/print/src/postscript.rs@@ -0,0 +1,57 @@+//! Windows: the PostScript route. A job drawn into the XPS print API reaches the driver as RGB,+//! so the document's CMYK is turned into RGB and back by the driver and prints paler than from a+//! program that sends PostScript (Acrobat does). When Ghostscript is installed and the driver+//! takes `POSTSCRIPT_PASSTHROUGH`, each sheet is converted to EPS by Ghostscript (colours as in+//! the document: CMYK, spot colours, overprint) and passed through the driver, which still+//! applies the job's validated settings. Without either, the job takes the XPS route as before.+//! This is the one place a program is started, and only an installed Ghostscript: where it is+//! blocked or fails, the job takes the XPS route.++use std::ffi::OsString;+use std::path::{Path, PathBuf};++/// The newest `gswin64c.exe` among Ghostscript's install folders (`gs10.04.0`, …, each with+/// `bin\gswin64c.exe`), by version; folders that aren't a version are skipped.+pub(crate) fn newest_ghostscript(folders: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {+    folders+        .into_iter()+        .filter_map(|folder| {+            let name = folder.file_name()?.to_str()?.strip_prefix("gs")?.to_string();+            let version: Vec<u32> = name.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;+            Some((version, folder.join("bin").join("gswin64c.exe")))+        })+        .max_by(|a, b| a.0.cmp(&b.0))+        .map(|(_, exe)| exe)+}++/// Ghostscript's arguments: every page of `pdf` to its own EPS (`pattern` holds `%d`), its+/// colours unchanged — or grey for a greyscale job.+pub(crate) fn ghostscript_args(pdf: &Path, pattern: &Path, grayscale: bool) -> Vec<OsString> {+    let mut args: Vec<OsString> = ["-q", "-dNOPAUSE", "-dBATCH", "-dSAFER", "-sDEVICE=eps2write"].map(OsString::from).into();+    if grayscale {+        args.extend(["-sColorConversionStrategy=Gray", "-dProcessColorModel=/DeviceGray"].map(OsString::from));+    }+    let mut output = OsString::from("-sOutputFile=");+    output.push(pattern);+    args.push(output);+    args.push(pdf.into());+    args+}++/// Where a sheet goes on the paper (points from its top-left): centred, at 100%, smaller only+/// when the paper is.+pub(crate) fn sheet_box(paper: (f64, f64), sheet: (f64, f64)) -> (f64, f64, f64, f64) {+    let scale = (paper.0 / sheet.0).min(paper.1 / sheet.1).min(1.0);+    let (w, h) = (sheet.0 * scale, sheet.1 * scale);+    ((paper.0 - w) / 2.0, (paper.1 - h) / 2.0, w, h)+}++/// A DEVMODE as the PrintTicket conversion returns it, cut to the length its header gives+/// (`dmSize` + `dmDriverExtra`): the conversion's buffer can be longer.+pub(crate) fn devmode(mut bytes: Vec<u8>) -> Vec<u8> {+    let word = |at: usize| bytes.get(at..at + 2).map(|w| usize::from(u16::from_le_bytes([w[0], w[1]])));+    if let (Some(size), Some(extra)) = (word(68), word(70)) {+        bytes.truncate(size + extra);+    }+    bytes+}diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs--- a/crates/print/src/tests.rs+++ b/crates/print/src/tests.rs@@ -272,3 +272,47 @@ 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="))); }++#[test]+fn the_newest_ghostscript_is_chosen_by_version() {+    use std::path::PathBuf;+    let root = PathBuf::from("C:/Program Files/gs");+    let found = crate::postscript::newest_ghostscript(["gs9.56.1", "gs10.01.2", "gs10.4.0", "fonts", "gsview"].map(|n| root.join(n)));+    assert_eq!(found, Some(root.join("gs10.4.0").join("bin").join("gswin64c.exe")));+    assert_eq!(crate::postscript::newest_ghostscript([root.join("fonts")]), None);+}++#[test]+fn ghostscript_writes_one_eps_per_sheet_in_the_documents_colours() {+    use std::path::Path;+    let colour = crate::postscript::ghostscript_args(Path::new("job.pdf"), Path::new("dir/sheet-%d.eps"), false);+    let colour: Vec<_> = colour.iter().map(|a| a.to_string_lossy().into_owned()).collect();+    assert!(colour.contains(&"-sDEVICE=eps2write".to_string()), "{colour:?}");+    assert!(colour.contains(&"-dSAFER".to_string()), "{colour:?}");+    assert!(!colour.iter().any(|a| a.contains("ColorConversionStrategy")), "colours stay as in the document: {colour:?}");+    assert_eq!(colour[colour.len() - 2..], ["-sOutputFile=dir/sheet-%d.eps".to_string(), "job.pdf".to_string()]);+    let grey = crate::postscript::ghostscript_args(Path::new("job.pdf"), Path::new("dir/sheet-%d.eps"), true);+    assert!(grey.iter().any(|a| a == "-sColorConversionStrategy=Gray"), "{grey:?}");+}++#[test]+fn a_postscript_sheet_is_centred_at_full_size_and_shrunk_only_for_smaller_paper() {+    let a4 = (595.0, 842.0);+    assert_eq!(crate::postscript::sheet_box(a4, a4), (0.0, 0.0, 595.0, 842.0));+    // The driver's A4 a fraction of a point larger: the sheet stays at 100%, centred.+    let (x, y, w, h) = crate::postscript::sheet_box((595.3, 842.4), a4);+    assert!((x - 0.15).abs() < 1e-9 && (y - 0.2).abs() < 1e-9 && (w, h) == a4, "{x} {y} {w} {h}");+    // A5 paper for an A4 sheet: shrunk to fit, centred.+    let (x, y, w, h) = crate::postscript::sheet_box((420.0, 595.0), a4);+    assert!((w - 420.0).abs() < 1e-9 && (h - 842.0 * 420.0 / 595.0).abs() < 1e-9 && x == 0.0 && y > 0.0);+}++#[test]+fn a_converted_devmode_is_cut_to_its_own_length() {+    // dmSize 220 and dmDriverExtra 1200 in the header; the conversion's buffer is longer.+    let mut bytes = vec![0u8; 33_424];+    bytes[68..70].copy_from_slice(&220u16.to_le_bytes());+    bytes[70..72].copy_from_slice(&1200u16.to_le_bytes());+    assert_eq!(crate::postscript::devmode(bytes).len(), 1420);+    assert_eq!(crate::postscript::devmode(vec![0u8; 10]).len(), 10, "too short to read: left for the DEVMODE check to refuse");+}diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs--- a/crates/print/src/windows.rs+++ b/crates/print/src/windows.rs@@ -11,11 +11,15 @@ //! and WinRT calls, so there is no `unsafe` here.  use std::io::Write as _;+use std::os::windows::process::CommandExt as _;+use std::path::{Path, PathBuf};+use std::sync::mpsc::SyncSender;  use winprint::printer::{PrinterDevice, WinPdfPrinter}; use winprint::ticket::document::{NS_PSK, OwnedName, PrintFeature, PrintTicketDocument, reader::ParsableXmlDocument as _}; use winprint::ticket::{FeatureOptionPack as _, PageMediaSize, PrintCapabilities, PrintTicket, PrintTicketBuilder}; +use crate::postscript; use crate::sheets::{self, Fit, SAME}; use crate::ticket::{self, Paper}; use crate::{Error, Job, Printer, Scaling};@@ -118,6 +122,58 @@ fn imageable_margin(device: &PrinterDevice, ticket: &PrintTicket) -> f64 {         .map_or(0.0, ticket::points) } +/// Ghostscript's console program: `CRAFT_PRINT_GHOSTSCRIPT`, else the newest under+/// `%ProgramFiles%\gs`, where its installer puts it.+fn ghostscript() -> Option<PathBuf> {+    if let Some(program) = std::env::var_os("CRAFT_PRINT_GHOSTSCRIPT") {+        return Some(program.into());+    }+    let root = std::env::var_os("ProgramFiles")?;+    let folders = std::fs::read_dir(Path::new(&root).join("gs")).ok()?.filter_map(|e| e.ok().map(|e| e.path()));+    postscript::newest_ghostscript(folders).filter(|p| p.is_file())+}++/// The PostScript route ([`postscript`]) for the job file at `pdf`, every sheet `sheet` points+/// as printed: `None` when it doesn't apply — no Ghostscript, a driver that doesn't take+/// PostScript, settings that don't convert, or Ghostscript failing — and the job goes to XPS.+/// The driver gets the validated ticket's settings as its DEVMODE and makes the copies.+fn postscript_job(+    device: &PrinterDevice,+    ticket: &PrintTicket,+    job: &Job,+    pdf: &Path,+    sheet: (f64, f64),+    count: usize,+    sender: &SyncSender<(usize, usize)>,+) -> Option<Result<(), Error>> {+    use crate::rgb::{DevMode, PostScriptJob, PostScriptSheet, page, postscript_passthrough, print_postscript};+    const CREATE_NO_WINDOW: u32 = 0x0800_0000;+    let program = ghostscript()?;+    let settings = DevMode::from_bytes(postscript::devmode(ticket.to_dev_mode(device).ok()?)).ok()?;+    if !postscript_passthrough(device.name(), Some(&settings)).ok()? {+        return None;+    }+    let dir = pdf.parent()?;+    let converted = std::process::Command::new(&program)+        .args(postscript::ghostscript_args(pdf, &dir.join("sheet-%d.eps"), job.grayscale))+        .creation_flags(CREATE_NO_WINDOW)+        .output()+        .ok()?;+    if !converted.status.success() {+        return None;+    }+    let eps: Vec<Vec<u8>> = (1..=count).map(|i| std::fs::read(dir.join(format!("sheet-{i}.eps")))).collect::<Result<_, _>>().ok()?;+    let rect = postscript::sheet_box(page(device.name(), Some(&settings)).ok()?.paper, sheet);+    let sheets: Vec<PostScriptSheet<'_>> = eps.iter().map(|eps| PostScriptSheet { eps, size: sheet, rect }).collect();+    let target = PostScriptJob { printer: device.name(), settings: Some(&settings), title: &job.title, output_file: None };+    Some(+        print_postscript(&target, &sheets, &mut |done| {+            let _ = sender.send((done, count));+        })+        .map_err(spool_error),+    )+}+ /// Spool a print-ready PDF. The sheets are read first ([`sheets::read`]); everything that talks /// to the driver runs on a thread of its own, so WinRT and COM get an apartment of their own /// whoever calls. Windows.Data.Pdf reads from a path, so the job goes to a file in a private@@ -157,6 +213,9 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM         let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(written)?;         file.write_all(updated.as_deref().unwrap_or(pdf)).map_err(written)?;         drop(file);+        if let Some(printed) = postscript_job(&device, &ticket, job, &path, paper, count, &sender) {+            return printed;+        }         WinPdfPrinter::new(device)             .print_with_progress(&path, ticket, |processed, total| {                 // Page acceptance is distinct from terminal job success. The callback